From 586bae953d4793e674259512d8531ec48a4266e0 Mon Sep 17 00:00:00 2001 From: Surma Date: Wed, 11 Nov 2026 22:04:16 +0000 Subject: [PATCH] build: unify production crates and vendor the patched shell Assisted-by: SurmAgent:OpenAI --- .cargo/config.toml | 7 + .gitignore | 3 +- AGENTS.md | 11 +- Cargo.lock | 6944 +++++++++++++++- Cargo.toml | 165 +- README.md | 4 +- flake.nix | 159 +- host/tests/it/common.rs | 4 +- host/tests/it/harness_shutdown.rs | 2 +- host/tests/it/init.rs | 2 +- host/tests/packaged-builder.nu | 30 + userland/.cargo/config.toml | 9 - userland/Cargo.lock | 7040 ----------------- userland/Cargo.toml | 15 - userland/brush/.gitignore | 1 - userland/brush/Cargo.lock | 6739 ---------------- userland/brush/README.md | 22 + userland/brush/build.nu | 42 +- userland/brush/source/.cargo/config.toml | 10 + userland/brush/source/.cliffignore | 9 + .../platform-cfg-outside-sys-test.yml | 21 + .../rules/platform-cfg-outside-sys.yml | 40 + userland/brush/source/.config/nextest.toml | 8 + .../brush/source/.devcontainer/Dockerfile | 55 + .../source/.devcontainer/devcontainer.json | 18 + .../.devcontainer/install-rust-tools.sh | 21 + .../ISSUE_TEMPLATE/compatibility-bug.yml | 86 + .../source/.github/ISSUE_TEMPLATE/config.yml | 5 + .../ISSUE_TEMPLATE/feature-request.yml | 52 + .../source/.github/ISSUE_TEMPLATE/other.yml | 19 + .../source/.github/copilot-instructions.md | 405 + userland/brush/source/.github/dependabot.yml | 62 + .../brush/source/.github/workflows/cd.yaml | 310 + .../source/.github/workflows/ci-reports.yaml | 138 + .../brush/source/.github/workflows/ci.yaml | 848 ++ .../brush/source/.github/workflows/codeql.yml | 55 + .../.github/workflows/devcontainer.yaml | 72 + .../brush/source/.github/workflows/docs.yaml | 59 + .../brush/source/.github/workflows/hooks.yaml | 72 + .../.github/workflows/install-script.yaml | 77 + .../source/.github/workflows/shellcheck.yaml | 42 + .../.github/workflows/workflow-checks.yaml | 45 + userland/brush/source/.gitignore | 4 + userland/brush/source/.pre-commit-config.yaml | 247 + userland/brush/source/.rgignore | 5 + userland/brush/source/.vscode/extensions.json | 7 + userland/brush/source/.vscode/launch.json | 65 + userland/brush/source/.vscode/settings.json | 20 + userland/brush/source/AGENTS.md | 349 + userland/brush/source/CHANGELOG.md | 1074 +++ userland/brush/source/CODE_OF_CONDUCT.md | 131 + userland/brush/source/CONTRIBUTING.md | 230 + userland/brush/source/LICENSE | 21 + userland/brush/source/README.md | 163 + userland/brush/source/benchmarks/.gitignore | 1 + userland/brush/source/benchmarks/bench.py | 974 +++ .../brush/source/brush-builtins/Cargo.toml | 181 + userland/brush/source/brush-builtins/LICENSE | 1 + .../brush/source/brush-builtins/src/alias.rs | 61 + .../brush/source/brush-builtins/src/bg.rs | 75 + .../brush/source/brush-builtins/src/bind.rs | 575 ++ .../brush/source/brush-builtins/src/break_.rs | 43 + .../source/brush-builtins/src/builder.rs | 20 + .../source/brush-builtins/src/builtin_.rs | 67 + .../brush/source/brush-builtins/src/caller.rs | 58 + .../brush/source/brush-builtins/src/cd.rs | 99 + .../brush/source/brush-builtins/src/colon.rs | 36 + .../source/brush-builtins/src/command.rs | 155 + .../source/brush-builtins/src/complete.rs | 788 ++ .../source/brush-builtins/src/continue_.rs | 43 + .../source/brush-builtins/src/declare.rs | 713 ++ .../brush/source/brush-builtins/src/dirs.rs | 106 + .../brush/source/brush-builtins/src/dot.rs | 37 + .../brush/source/brush-builtins/src/echo.rs | 73 + .../brush/source/brush-builtins/src/enable.rs | 87 + .../brush/source/brush-builtins/src/eval.rs | 44 + .../brush/source/brush-builtins/src/exec.rs | 97 + .../brush/source/brush-builtins/src/exit.rs | 33 + .../brush/source/brush-builtins/src/export.rs | 207 + .../source/brush-builtins/src/factory.rs | 352 + .../brush/source/brush-builtins/src/false_.rs | 34 + .../brush/source/brush-builtins/src/fc.rs | 303 + .../brush/source/brush-builtins/src/fg.rs | 77 + .../source/brush-builtins/src/getopts.rs | 373 + .../brush/source/brush-builtins/src/hash.rs | 127 + .../brush/source/brush-builtins/src/help.rs | 144 + .../source/brush-builtins/src/history.rs | 273 + .../brush/source/brush-builtins/src/jobs.rs | 86 + .../brush/source/brush-builtins/src/kill.rs | 200 + .../brush/source/brush-builtins/src/let_.rs | 52 + .../brush/source/brush-builtins/src/lib.rs | 140 + .../brush/source/brush-builtins/src/lookup.rs | 156 + .../source/brush-builtins/src/mapfile.rs | 212 + .../brush/source/brush-builtins/src/popd.rs | 37 + .../brush/source/brush-builtins/src/printf.rs | 252 + .../brush/source/brush-builtins/src/pushd.rs | 68 + .../brush/source/brush-builtins/src/pwd.rs | 47 + .../brush/source/brush-builtins/src/read.rs | 906 +++ .../source/brush-builtins/src/return_.rs | 43 + .../brush/source/brush-builtins/src/set.rs | 283 + .../brush/source/brush-builtins/src/shift.rs | 47 + .../brush/source/brush-builtins/src/shopt.rs | 156 + .../source/brush-builtins/src/suspend.rs | 36 + .../brush/source/brush-builtins/src/test.rs | 59 + .../brush/source/brush-builtins/src/times.rs | 43 + .../brush/source/brush-builtins/src/trap.rs | 127 + .../brush/source/brush-builtins/src/true_.rs | 34 + .../brush/source/brush-builtins/src/type_.rs | 114 + .../brush/source/brush-builtins/src/ulimit.rs | 552 ++ .../brush/source/brush-builtins/src/umask.rs | 100 + .../source/brush-builtins/src/unalias.rs | 47 + .../brush/source/brush-builtins/src/unimp.rs | 32 + .../brush/source/brush-builtins/src/unset.rs | 132 + .../brush/source/brush-builtins/src/wait.rs | 84 + .../tests/process_launcher_tests.rs | 487 ++ .../tests/registration_tests.rs | 83 + .../tests/source_program_tests.rs | 81 + .../tests/task_spawner_tests.rs | 143 + .../source/brush-bundled-tests/Cargo.toml | 30 + .../source/brush-bundled-tests/src/main.rs | 88 + .../source/brush-bundled-tests/tests/shim.rs | 212 + .../source/brush-clap-adapter/Cargo.toml | 26 + .../brush/source/brush-clap-adapter/LICENSE | 1 + .../examples/custom-builtin.rs | 153 + .../source/brush-clap-adapter/src/adapter.rs | 470 ++ .../source/brush-clap-adapter/src/lib.rs | 143 + .../tests/impl_parse_args_tests.rs | 499 ++ userland/brush/source/brush-core/Cargo.toml | 91 + userland/brush/source/brush-core/LICENSE | 1 + .../source/brush-core/examples/call-func.rs | 74 + .../source/brush-core/examples/readme.rs | 30 + .../brush-core/sausage-process/deps/io.wit | 299 + .../brush-core/sausage-process/process.wit | 42 + .../brush/source/brush-core/src/arithmetic.rs | 423 + .../source/brush-core/src/braceexpansion.rs | 105 + .../brush/source/brush-core/src/builtins.rs | 249 + .../source/brush-core/src/builtins/args.rs | 266 + .../brush-core/src/builtins/registry.rs | 225 + .../source/brush-core/src/builtins/state.rs | 80 + .../brush/source/brush-core/src/callstack.rs | 787 ++ .../brush/source/brush-core/src/commands.rs | 1008 +++ .../brush/source/brush-core/src/completion.rs | 1641 ++++ .../brush-core/src/completion/quoting.rs | 166 + .../source/brush-core/src/completion/words.rs | 341 + userland/brush/source/brush-core/src/env.rs | 571 ++ userland/brush/source/brush-core/src/error.rs | 483 ++ .../brush/source/brush-core/src/escape.rs | 630 ++ .../brush/source/brush-core/src/expansion.rs | 2457 ++++++ .../brush-core/src/expansion/fieldsplit.rs | 297 + .../source/brush-core/src/extendedtests.rs | 625 ++ .../brush/source/brush-core/src/extensions.rs | 209 + .../brush/source/brush-core/src/functions.rs | 126 + .../brush/source/brush-core/src/history.rs | 1060 +++ .../brush/source/brush-core/src/int_utils.rs | 67 + .../brush/source/brush-core/src/interfaces.rs | 5 + .../brush-core/src/interfaces/keybindings.rs | 420 + .../brush/source/brush-core/src/interp.rs | 2183 +++++ .../brush/source/brush-core/src/ioutils.rs | 80 + userland/brush/source/brush-core/src/jobs.rs | 496 ++ .../brush/source/brush-core/src/keywords.rs | 34 + userland/brush/source/brush-core/src/lib.rs | 65 + .../source/brush-core/src/namedoptions.rs | 878 ++ .../brush/source/brush-core/src/openfiles.rs | 446 ++ .../brush/source/brush-core/src/options.rs | 395 + .../brush/source/brush-core/src/pathcache.rs | 59 + .../brush/source/brush-core/src/pathsearch.rs | 235 + .../brush/source/brush-core/src/patterns.rs | 1076 +++ .../brush/source/brush-core/src/processes.rs | 190 + .../brush/source/brush-core/src/prompt.rs | 289 + userland/brush/source/brush-core/src/regex.rs | 277 + .../brush/source/brush-core/src/results.rs | 311 + userland/brush/source/brush-core/src/shell.rs | 699 ++ .../source/brush-core/src/shell/builder.rs | 299 + .../brush-core/src/shell/builtin_registry.rs | 35 + .../source/brush-core/src/shell/callstack.rs | 367 + .../source/brush-core/src/shell/completion.rs | 48 + .../brush/source/brush-core/src/shell/env.rs | 36 + .../source/brush-core/src/shell/execution.rs | 319 + .../source/brush-core/src/shell/expansion.rs | 52 + .../brush/source/brush-core/src/shell/fs.rs | 297 + .../source/brush-core/src/shell/funcs.rs | 126 + .../source/brush-core/src/shell/history.rs | 126 + .../brush-core/src/shell/initscripts.rs | 167 + .../brush/source/brush-core/src/shell/io.rs | 112 + .../brush-core/src/shell/job_control.rs | 20 + .../source/brush-core/src/shell/parsing.rs | 69 + .../source/brush-core/src/shell/prompts.rs | 77 + .../source/brush-core/src/shell/readline.rs | 42 + .../source/brush-core/src/shell/state.rs | 124 + .../source/brush-core/src/shell/traps.rs | 108 + .../brush/source/brush-core/src/sourceinfo.rs | 44 + userland/brush/source/brush-core/src/sys.rs | 50 + .../brush/source/brush-core/src/sys/fs.rs | 37 + .../source/brush-core/src/sys/hostname.rs | 3 + .../source/brush-core/src/sys/os_pipes.rs | 3 + .../brush/source/brush-core/src/sys/stubs.rs | 28 + .../brush-core/src/sys/stubs/async_pipe.rs | 30 + .../brush-core/src/sys/stubs/commands.rs | 120 + .../source/brush-core/src/sys/stubs/fd.rs | 13 + .../source/brush-core/src/sys/stubs/fs.rs | 146 + .../source/brush-core/src/sys/stubs/input.rs | 16 + .../brush-core/src/sys/stubs/network.rs | 3 + .../source/brush-core/src/sys/stubs/poll.rs | 15 + .../brush-core/src/sys/stubs/resource.rs | 19 + .../source/brush-core/src/sys/stubs/signal.rs | 119 + .../brush-core/src/sys/stubs/terminal.rs | 86 + .../source/brush-core/src/sys/stubs/users.rs | 50 + .../brush-core/src/sys/tokio_process.rs | 109 + .../source/brush-core/src/sys/tokio_tasks.rs | 106 + .../brush/source/brush-core/src/sys/unix.rs | 30 + .../brush-core/src/sys/unix/async_pipe.rs | 23 + .../brush-core/src/sys/unix/commands.rs | 125 + .../source/brush-core/src/sys/unix/env.rs | 8 + .../source/brush-core/src/sys/unix/fd.rs | 87 + .../source/brush-core/src/sys/unix/fs.rs | 420 + .../source/brush-core/src/sys/unix/input.rs | 84 + .../source/brush-core/src/sys/unix/network.rs | 3 + .../source/brush-core/src/sys/unix/poll.rs | 143 + .../brush-core/src/sys/unix/resource.rs | 31 + .../source/brush-core/src/sys/unix/signal.rs | 303 + .../brush-core/src/sys/unix/terminal.rs | 135 + .../source/brush-core/src/sys/unix/users.rs | 90 + .../brush-core/src/sys/wasm/async_pipe.rs | 37 + .../source/brush-core/src/sys/wasm/env.rs | 10 + .../source/brush-core/src/sys/wasm/fs.rs | 68 + .../source/brush-core/src/sys/wasm/mod.rs | 18 + .../source/brush-core/src/sys/wasm/pipes.rs | 203 + .../brush-core/src/sys/wasm}/process.rs | 0 .../src/sys/wasm/sausage-lookup.rs} | 0 .../source/brush-core/src/sys/wasm/tasks.rs | 37 + .../source/brush-core/src/sys/windows.rs | 25 + .../source/brush-core/src/sys/windows/env.rs | 207 + .../source/brush-core/src/sys/windows/fs.rs | 464 ++ .../brush-core/src/sys/windows/network.rs | 3 + .../brush-core/src/sys/windows/users.rs | 86 + userland/brush/source/brush-core/src/tasks.rs | 43 + .../brush/source/brush-core/src/terminal.rs | 106 + userland/brush/source/brush-core/src/tests.rs | 43 + .../brush/source/brush-core/src/timing.rs | 137 + .../source/brush-core/src/trace_categories.rs | 20 + userland/brush/source/brush-core/src/traps.rs | 235 + .../brush/source/brush-core/src/variables.rs | 1122 +++ .../source/brush-core/src/wellknownvars.rs | 761 ++ .../tests/builtin_contract_tests.rs | 435 + .../brush-core/tests/builtin_state_tests.rs | 107 + .../brush-core/tests/cancellation_tests.rs | 96 + .../source/brush-core/tests/history_tests.rs | 23 + .../brush-core/tests/kill_on_drop_tests.rs | 162 + .../source/brush-core/tests/readme_snippet.rs | 50 + .../brush-coreutils-builtins/Cargo.toml | 279 + .../source/brush-coreutils-builtins/LICENSE | 1 + .../brush-coreutils-builtins/src/lib.rs | 207 + .../brush-experimental-builtins/Cargo.toml | 27 + .../brush-experimental-builtins/LICENSE | 1 + .../brush-experimental-builtins/src/lib.rs | 45 + .../brush-experimental-builtins/src/save.rs | 28 + .../brush/source/brush-interactive/Cargo.toml | 51 + .../brush/source/brush-interactive/LICENSE | 1 + .../brush/source/brush-interactive/build.rs | 19 + .../brush-interactive/src/completeness.rs | 186 + .../brush-interactive/src/completion.rs | 159 + .../source/brush-interactive/src/error.rs | 29 + .../brush-interactive/src/highlighting.rs | 597 ++ .../brush-interactive/src/input_backend.rs | 52 + .../src/interactive_shell.rs | 461 ++ .../brush/source/brush-interactive/src/lib.rs | 54 + .../src/minimal/input_backend.rs | 194 + .../brush-interactive/src/minimal/mod.rs | 3 + .../source/brush-interactive/src/options.rs | 28 + .../src/reedline/completer.rs | 92 + .../src/reedline/edit_mode.rs | 695 ++ .../src/reedline/highlighter.rs | 51 + .../brush-interactive/src/reedline/history.rs | 306 + .../src/reedline/input_backend.rs | 298 + .../brush-interactive/src/reedline/mod.rs | 9 + .../brush-interactive/src/reedline/prompt.rs | 69 + .../src/reedline/validator.rs | 15 + .../source/brush-interactive/src/refs.rs | 9 + .../source/brush-interactive/src/styles.rs | 89 + .../brush-interactive/src/term_detection.rs | 412 + .../brush-interactive/src/term_integration.rs | 300 + .../brush-interactive/src/trace_categories.rs | 3 + .../src/vt/completion_list.rs | 211 + .../brush-interactive/src/vt/input_backend.rs | 276 + .../source/brush-interactive/src/vt/keymap.rs | 605 ++ .../source/brush-interactive/src/vt/keys.rs | 708 ++ .../brush-interactive/src/vt/line_buffer.rs | 356 + .../brush-interactive/src/vt/line_editor.rs | 841 ++ .../brush-interactive/src/vt/line_reader.rs | 1120 +++ .../source/brush-interactive/src/vt/mod.rs | 18 + .../source/brush-interactive/src/vt/render.rs | 926 +++ .../source/brush-interactive/src/vt/style.rs | 20 + .../brush-interactive/src/vt/terminal.rs | 162 + .../source/brush-interactive/src/zsh_hooks.rs | 350 + userland/brush/source/brush-parser/Cargo.toml | 66 + userland/brush/source/brush-parser/LICENSE | 1 + .../source/brush-parser/benches/parser.rs | 280 + .../source/brush-parser/examples/miette.rs | 24 + .../source/brush-parser/examples/serde.rs | 31 + .../source/brush-parser/src/arithmetic.rs | 193 + userland/brush/source/brush-parser/src/ast.rs | 2485 ++++++ .../brush/source/brush-parser/src/error.rs | 132 + userland/brush/source/brush-parser/src/lib.rs | 37 + .../source/brush-parser/src/parser/mod.rs | 276 + .../source/brush-parser/src/parser/peg.rs | 857 ++ ...sts__parse_arith_and_non_arith_parens.snap | 126 + ...ush_parser__parser__tests__parse_case.snap | 124 + ..._parser__parser__tests__parse_case_ns.snap | 124 + ...arse_function_with_pipe_redirection-2.snap | 111 + ..._parse_function_with_pipe_redirection.snap | 125 + ...rse_here_doc_with_no_trailing_newline.snap | 71 + ..._parser__parser__tests__parse_program.snap | 152 + ...ser__parser__tests__parse_redirection.snap | 56 + .../src/parser/tests/and_or_lists.rs | 82 + .../src/parser/tests/assignments.rs | 248 + .../brush-parser/src/parser/tests/complex.rs | 255 + .../src/parser/tests/compound_commands.rs | 373 + .../src/parser/tests/extended_test.rs | 268 + .../src/parser/tests/functions.rs | 124 + .../src/parser/tests/here_docs.rs | 166 + .../brush-parser/src/parser/tests/mod.rs | 267 + .../src/parser/tests/pipelines.rs | 93 + .../src/parser/tests/redirections.rs | 218 + .../src/parser/tests/simple_commands.rs | 137 + ...nd_or_lists__parse_and_or_in_sequence.snap | 61 + ...and_or_lists__parse_and_or_with_pipes.snap | 49 + ...ests__and_or_lists__parse_chained_and.snap | 47 + ...tests__and_or_lists__parse_chained_or.snap | 47 + ...sts__and_or_lists__parse_mixed_and_or.snap | 47 + ...tests__and_or_lists__parse_simple_and.snap | 43 + ..._tests__and_or_lists__parse_simple_or.snap | 43 + ..._assignments__parse_assignment_append.snap | 35 + ...ments__parse_assignment_append_quoted.snap | 35 + ...__assignments__parse_assignment_array.snap | 44 + ...ments__parse_assignment_array_element.snap | 34 + ...e_assignment_array_element_expression.snap | 34 + ...gnments__parse_assignment_array_empty.snap | 31 + ...gnments__parse_assignment_array_mixed.snap | 47 + ...arse_assignment_array_quoted_elements.snap | 40 + ...__parse_assignment_array_with_indices.snap | 53 + ...__assignments__parse_assignment_empty.snap | 34 + ..._assignments__parse_assignment_quoted.snap | 34 + ..._assignments__parse_assignment_simple.snap | 34 + ...ments__parse_assignment_single_quoted.snap | 34 + ...nments__parse_assignment_with_command.snap | 48 + ..._assignment_with_command_substitution.snap | 34 + ...ents__parse_assignment_with_expansion.snap | 34 + ...assignments__parse_declare_assignment.snap | 42 + ..._assignments__parse_export_assignment.snap | 38 + ...__assignments__parse_local_assignment.snap | 38 + ...signments__parse_multiple_assignments.snap | 56 + ...rse_multiple_assignments_with_command.snap | 49 + ...omplex__parse_arithmetic_in_condition.snap | 80 + ..._complex__parse_backgrounded_commands.snap | 49 + ...omplex__parse_brace_expansion_context.snap | 31 + ...sts__complex__parse_case_no_semicolon.snap | 58 + ...ts__complex__parse_case_with_newlines.snap | 58 + ...ex__parse_command_substitution_nested.snap | 31 + ...mplex__parse_complex_array_operations.snap | 72 + ...rser__tests__complex__parse_coprocess.snap | 27 + ...ests__complex__parse_extglob_patterns.snap | 39 + ...__parse_for_with_command_substitution.snap | 52 + ...ests__complex__parse_function_with_if.snap | 110 + ...__tests__complex__parse_glob_patterns.snap | 39 + ...tests__complex__parse_mixed_sequences.snap | 71 + ...ts__complex__parse_multiple_here_docs.snap | 49 + ...tests__complex__parse_nested_if_while.snap | 102 + ...ex__parse_parameter_expansion_complex.snap | 39 + ...lex__parse_pipeline_with_redirections.snap | 63 + ...s__complex__parse_shebang_and_program.snap | 64 + ...plex__parse_subshell_with_assignments.snap | 84 + ...tests__complex__parse_tilde_expansion.snap | 49 + ...nds__parse_arith_and_non_arith_parens.snap | 71 + ...nd_commands__parse_arithmetic_complex.snap | 25 + ...mpound_commands__parse_arithmetic_for.snap | 54 + ...se_arithmetic_for_adjacent_semicolons.snap | 54 + ..._parse_arithmetic_for_empty_condition.snap | 54 + ...nds__parse_arithmetic_for_empty_parts.snap | 54 + ..._commands__parse_arithmetic_increment.snap | 25 + ...ithmetic_open_with_line_continuations.snap | 25 + ...und_commands__parse_arithmetic_simple.snap | 25 + ..._compound_commands__parse_brace_group.snap | 42 + ...commands__parse_brace_group_multiline.snap | 60 + ...ompound_commands__parse_case_continue.snap | 88 + ...ound_commands__parse_case_fallthrough.snap | 88 + ...ommands__parse_case_multiple_patterns.snap | 122 + ..._compound_commands__parse_case_simple.snap | 58 + ...ests__compound_commands__parse_for_in.snap | 60 + ...ound_commands__parse_for_in_multiline.snap | 60 + ...s__compound_commands__parse_for_no_in.snap | 47 + ...sts__compound_commands__parse_if_elif.snap | 116 + ...sts__compound_commands__parse_if_else.snap | 80 + ...compound_commands__parse_if_multiline.snap | 80 + ...s__compound_commands__parse_if_simple.snap | 56 + ...pound_commands__parse_nested_subshell.snap | 53 + ...ds__parse_nested_subshell_split_close.snap | 53 + ...nds__parse_nested_subshell_split_open.snap | 53 + ...ts__compound_commands__parse_subshell.snap | 42 + ...nds__parse_subshell_multiple_commands.snap | 60 + ...tests__compound_commands__parse_until.snap | 66 + ...tests__compound_commands__parse_while.snap | 66 + ...pound_commands__parse_while_multiline.snap | 78 + ...xtended_test__parse_extended_test_and.snap | 29 + ...test__parse_extended_test_arith_equal.snap | 29 + ...arse_extended_test_arith_greater_than.snap | 29 + ...__parse_extended_test_arith_less_than.snap | 29 + ...__parse_extended_test_arith_not_equal.snap | 29 + ...ded_test__parse_extended_test_complex.snap | 32 + ...d_test__parse_extended_test_directory.snap | 26 + ..._test__parse_extended_test_executable.snap | 26 + ...test__parse_extended_test_file_exists.snap | 26 + ...est__parse_extended_test_greater_than.snap | 29 + ...d_test__parse_extended_test_less_than.snap | 29 + ...xtended_test__parse_extended_test_not.snap | 26 + ...extended_test__parse_extended_test_or.snap | 29 + ...st__parse_extended_test_parenthesized.snap | 26 + ...ed_test__parse_extended_test_readable.snap | 26 + ...ended_test__parse_extended_test_regex.snap | 29 + ...parse_extended_test_regex_with_spaces.snap | 29 + ...est__parse_extended_test_string_equal.snap | 29 + ...__parse_extended_test_string_non_zero.snap | 26 + ..._parse_extended_test_string_not_equal.snap | 29 + ...t__parse_extended_test_string_pattern.snap | 29 + ...arse_extended_test_string_zero_length.snap | 26 + ...ed_test__parse_extended_test_writable.snap | 26 + ...ests__functions__parse_function_basic.snap | 48 + ...ts__functions__parse_function_keyword.snap | 48 + ...s__parse_function_keyword_with_parens.snap | 48 + ...__functions__parse_function_multiline.snap | 66 + ...arse_function_with_extended_test_body.snap | 32 + ..._with_extended_test_body_and_redirect.snap | 37 + ...tions__parse_function_with_local_vars.snap | 73 + ...nctions__parse_function_with_redirect.snap | 59 + ...__parse_function_with_stderr_redirect.snap | 56 + ...ns__parse_function_with_subshell_body.snap | 48 + ...ests__here_docs__parse_here_doc_basic.snap | 38 + ...arse_here_doc_double_quoted_delimiter.snap | 37 + ...ests__here_docs__parse_here_doc_empty.snap | 38 + ...t_open_in_nested_command_substitution.snap | 31 + ...s__parse_here_doc_no_trailing_newline.snap | 38 + ...docs__parse_here_doc_quoted_delimiter.snap | 37 + ...here_docs__parse_here_doc_tab_removal.snap | 39 + ...cs__parse_here_doc_with_command_after.snap | 50 + ...e_docs__parse_here_doc_with_expansion.snap | 38 + ...ts__here_docs__parse_here_doc_with_fd.snap | 38 + ...re_docs__parse_pathological_here_docs.snap | 9 + ..._parser__parser__tests__parse_program.snap | 152 + ...ts__pipelines__parse_multi_stage_pipe.snap | 55 + ...ts__pipelines__parse_negated_pipeline.snap | 32 + ...pelines__parse_negated_timed_pipeline.snap | 44 + ...es__parse_pipe_with_multiple_commands.snap | 55 + ...ts__pipelines__parse_pipe_with_stderr.snap | 34 + ...__tests__pipelines__parse_simple_pipe.snap | 43 + ...ests__pipelines__parse_timed_pipeline.snap | 43 + ...pipelines__parse_timed_pipeline_posix.snap | 43 + ...ests__redirections__parse_here_string.snap | 31 + ...ions__parse_here_string_with_variable.snap | 31 + ...e_io_number_in_range_still_recognized.snap | 31 + ...io_number_out_of_range_does_not_panic.snap | 35 + ...ions__parse_process_substitution_read.snap | 73 + ...ons__parse_process_substitution_write.snap | 54 + ...__redirections__parse_redirect_append.snap | 35 + ..._redirections__parse_redirect_clobber.snap | 35 + ...redirections__parse_redirect_fd_close.snap | 31 + ...s__redirections__parse_redirect_input.snap | 31 + ...redirections__parse_redirect_multiple.snap | 39 + ...__redirections__parse_redirect_output.snap | 35 + ...ions__parse_redirect_output_and_error.snap | 31 + ...arse_redirect_output_and_error_append.snap | 31 + ...directions__parse_redirect_read_write.snap | 31 + ...ions__parse_redirect_stderr_to_stdout.snap | 31 + ...edirections__parse_redirect_stdin_dup.snap | 31 + ...ions__parse_redirect_stdout_to_stderr.snap | 31 + ...__tests__simple_commands__parse_colon.snap | 25 + ...__parse_command_with_backslash_escape.snap | 31 + ...se_command_with_backtick_substitution.snap | 31 + ...rse_command_with_command_substitution.snap | 31 + ...mands__parse_command_with_quoted_args.snap | 31 + ...nds__parse_command_with_single_quotes.snap | 31 + ...commands__parse_command_with_variable.snap | 31 + ...ts__simple_commands__parse_echo_hello.snap | 31 + ...mple_commands__parse_echo_hello_world.snap | 35 + ...ser__tests__simple_commands__parse_ls.snap | 25 + ...ts__simple_commands__parse_ls_la_home.snap | 35 + ...r__tests__simple_commands__parse_true.snap | 25 + .../brush-parser/src/parser/winnow_str.rs | 20 + .../brush/source/brush-parser/src/pattern.rs | 386 + .../brush/source/brush-parser/src/prompt.rs | 204 + .../brush-parser/src/readline_binding.rs | 241 + .../source/brush-parser/src/snapshot_tests.rs | 124 + ...tests__tokenize_arithmetic_expression.snap | 33 + ...ize_arithmetic_expression_with_parens.snap | 21 + ...nize_arithmetic_expression_with_space.snap | 21 + ...tests__tokenize_backquote_with_escape.snap | 33 + ...tokenize_braced_parameter_expansion-2.snap | 21 + ...__tokenize_braced_parameter_expansion.snap | 21 + ...ced_parameter_expansion_with_escaping.snap | 21 + ..._tests__tokenize_command_substitution.snap | 33 + ...mmand_substitution_containing_extglob.snap | 33 + ...ze_command_substitution_with_subshell.snap | 21 + ...r__tokenizer__tests__tokenize_comment.snap | 33 + ...nizer__tests__tokenize_comment_at_eof.snap | 21 + ...lex_here_docs_in_command_substitution.snap | 33 + ...kenizer__tests__tokenize_double_quote.snap | 21 + ...e_double_quoted_arithmetic_expression.snap | 21 + ...ze_double_quoted_command_substitution.snap | 21 + ...nizer__tests__tokenize_empty_here_doc.snap | 81 + ...r__tests__tokenize_escaped_whitespace.snap | 33 + ...tokenizer__tests__tokenize_here_doc-2.snap | 81 + ...tokenizer__tests__tokenize_here_doc-3.snap | 93 + ...tokenizer__tests__tokenize_here_doc-4.snap | 81 + ...__tokenizer__tests__tokenize_here_doc.snap | 117 + ...nize_here_doc_in_command_substitution.snap | 33 + ...in_double_quoted_command_substitution.snap | 33 + ...uoted_command_substitution_with_space.snap | 33 + ...__tokenize_here_doc_with_other_tokens.snap | 117 + ...s__tokenize_here_doc_with_tab_removal.snap | 81 + ...er__tests__tokenize_line_continuation.snap | 21 + ...r__tests__tokenize_multiple_here_docs.snap | 165 + ...kenize_operator_followed_by_backslash.snap | 69 + ..._tokenizer__tests__tokenize_operators.snap | 45 + ...zer__tests__tokenize_simple_backquote.snap | 33 + ...kenizer__tests__tokenize_single_quote.snap | 21 + ..._tests__tokenize_special_parameters-2.snap | 21 + ..._tests__tokenize_special_parameters-3.snap | 21 + ..._tests__tokenize_special_parameters-4.snap | 21 + ..._tests__tokenize_special_parameters-5.snap | 21 + ...r__tests__tokenize_special_parameters.snap | 21 + ...kenize_unbraced_parameter_expansion-2.snap | 21 + ...tokenize_unbraced_parameter_expansion.snap | 21 + ...tokenizer__tests__tokenize_whitespace.snap | 45 + ...ord__tests__brace_expansion_parsing-2.snap | 22 + ..._word__tests__brace_expansion_parsing.snap | 16 + ...tests__parse_ansi_c_quoted_escape_seq.snap | 14 + ...word__tests__parse_ansi_c_quoted_text.snap | 14 + ...rd__tests__parse_arithmetic_expansion.snap | 16 + ...arse_arithmetic_expansion_with_parens.snap | 16 + ...word__tests__parse_backquoted_command.snap | 14 + ...e_backquoted_command_in_double_quotes.snap | 20 + ...e_command_sub_with_balanced_backticks.snap | 20 + ...mmand_sub_with_balanced_double_quotes.snap | 20 + ...mmand_sub_with_balanced_single_quotes.snap | 20 + ..._command_sub_with_unbalanced_backtick.snap | 20 + ...mand_sub_with_unbalanced_single_quote.snap | 20 + ...rd__tests__parse_command_substitution.snap | 14 + ...nd_substitution_with_embedded_extglob.snap | 14 + ...and_substitution_with_embedded_quotes.snap | 14 + ...and_substitution_with_multibyte_chars.snap | 24 + ...word__tests__parse_double_quoted_text.snap | 33 + ...parse_extglob_with_embedded_parameter.snap | 27 + ...sts__parse_gettext_double_quoted_text.snap | 33 + .../brush/source/brush-parser/src/source.rs | 94 + .../source/brush-parser/src/test_command.rs | 105 + .../source/brush-parser/src/tokenizer.rs | 2634 ++++++ .../brush-parser/src/tokenizer/char_reader.rs | 60 + .../brush/source/brush-parser/src/word.rs | 1793 +++++ userland/brush/source/brush-shell/Cargo.toml | 168 + userland/brush/source/brush-shell/LICENSE | 1 + userland/brush/source/brush-shell/about.hbs | 70 + userland/brush/source/brush-shell/about.toml | 17 + .../brush/source/brush-shell/benches/shell.rs | 174 + userland/brush/source/brush-shell/build.rs | 33 + .../brush/source/brush-shell/examples/gen.rs | 18 + userland/brush/source/brush-shell/src/args.rs | 466 ++ .../brush/source/brush-shell/src/brushctl.rs | 263 + .../brush/source/brush-shell/src/bundled.rs | 289 + .../brush/source/brush-shell/src/config.rs | 441 ++ .../brush/source/brush-shell/src/entry.rs | 1100 +++ .../source/brush-shell/src/error_formatter.rs | 20 + .../brush/source/brush-shell/src/events.rs | 174 + userland/brush/source/brush-shell/src/lib.rs | 12 + userland/brush/source/brush-shell/src/main.rs | 5 + .../source/brush-shell/src/productinfo.rs | 36 + .../tests/cases/brush/brushctl.yaml | 59 + .../brush-shell/tests/cases/brush/cli.yaml | 331 + .../cases/brush/command_substitution.yaml | 200 + .../tests/cases/brush/completion.yaml | 18 + .../tests/cases/brush/external_commands.yaml | 172 + .../brush-shell/tests/cases/brush/help.yaml | 33 + .../tests/cases/brush/here_documents.yaml | 145 + .../tests/cases/brush/pipelines.yaml | 263 + .../tests/cases/brush/redirection.yaml | 170 + .../tests/cases/brush/scripting.yaml | 412 + ...ing_xtrace-file_captures_trace_output.snap | 12 + .../tests/cases/brush/tracing.yaml | 14 + .../tests/cases/brush/zsh_hooks.yaml | 626 ++ .../tests/cases/compat/and_or.yaml | 83 + .../tests/cases/compat/arguments.yaml | 659 ++ .../tests/cases/compat/arithmetic.yaml | 478 ++ .../tests/cases/compat/arrays.yaml | 500 ++ .../tests/cases/compat/assignments.yaml | 85 + .../tests/cases/compat/background_jobs.yaml | 183 + .../brush-shell/tests/cases/compat/basic.yaml | 153 + .../tests/cases/compat/builtins/alias.yaml | 319 + .../tests/cases/compat/builtins/bind.yaml | 186 + .../tests/cases/compat/builtins/builtin.yaml | 133 + .../tests/cases/compat/builtins/caller.yaml | 67 + .../tests/cases/compat/builtins/cd.yaml | 224 + .../tests/cases/compat/builtins/colon.yaml | 13 + .../tests/cases/compat/builtins/command.yaml | 483 ++ .../tests/cases/compat/builtins/common.yaml | 20 + .../tests/cases/compat/builtins/compgen.yaml | 655 ++ .../tests/cases/compat/builtins/complete.yaml | 434 + .../tests/cases/compat/builtins/declare.yaml | 1460 ++++ .../tests/cases/compat/builtins/dot.yaml | 176 + .../tests/cases/compat/builtins/echo.yaml | 65 + .../tests/cases/compat/builtins/enable.yaml | 54 + .../tests/cases/compat/builtins/eval.yaml | 99 + .../tests/cases/compat/builtins/exec.yaml | 133 + .../tests/cases/compat/builtins/exit.yaml | 120 + .../tests/cases/compat/builtins/export.yaml | 335 + .../tests/cases/compat/builtins/fc.yaml | 328 + .../tests/cases/compat/builtins/getopts.yaml | 527 ++ .../tests/cases/compat/builtins/hash.yaml | 226 + .../tests/cases/compat/builtins/help.yaml | 29 + .../tests/cases/compat/builtins/history.yaml | 847 ++ .../tests/cases/compat/builtins/jobs.yaml | 148 + .../tests/cases/compat/builtins/kill.yaml | 245 + .../tests/cases/compat/builtins/let.yaml | 58 + .../tests/cases/compat/builtins/local.yaml | 37 + .../tests/cases/compat/builtins/mapfile.yaml | 295 + .../tests/cases/compat/builtins/printf.yaml | 535 ++ .../tests/cases/compat/builtins/ps4.yaml | 15 + .../compat/builtins/pushd_popd_dirs.yaml | 157 + .../tests/cases/compat/builtins/pwd.yaml | 94 + .../tests/cases/compat/builtins/read.yaml | 567 ++ .../tests/cases/compat/builtins/readonly.yaml | 90 + .../tests/cases/compat/builtins/return.yaml | 181 + .../tests/cases/compat/builtins/set.yaml | 382 + .../tests/cases/compat/builtins/shift.yaml | 40 + .../tests/cases/compat/builtins/shopt.yaml | 91 + .../tests/cases/compat/builtins/suspend.yaml | 17 + .../tests/cases/compat/builtins/test.yaml | 390 + .../tests/cases/compat/builtins/times.yaml | 31 + .../tests/cases/compat/builtins/trap.yaml | 1002 +++ .../cases/compat/builtins/true_false.yaml | 7 + .../tests/cases/compat/builtins/type.yaml | 155 + .../tests/cases/compat/builtins/typeset.yaml | 10 + .../tests/cases/compat/builtins/ulimit.yaml | 162 + .../tests/cases/compat/builtins/umask.yaml | 39 + .../tests/cases/compat/builtins/unalias.yaml | 21 + .../tests/cases/compat/builtins/unset.yaml | 301 + .../tests/cases/compat/builtins/wait.yaml | 176 + .../tests/cases/compat/callstack.yaml | 649 ++ .../tests/cases/compat/complete_commands.yaml | 22 + .../tests/cases/compat/completion.yaml | 67 + .../compat/compound_cmds/arithmetic.yaml | 16 + .../compat/compound_cmds/arithmetic_for.yaml | 204 + .../cases/compat/compound_cmds/brace.yaml | 5 + .../cases/compat/compound_cmds/case.yaml | 185 + .../cases/compat/compound_cmds/coproc.yaml | 865 ++ .../tests/cases/compat/compound_cmds/for.yaml | 286 + .../tests/cases/compat/compound_cmds/if.yaml | 99 + .../cases/compat/compound_cmds/select.yaml | 10 + .../cases/compat/compound_cmds/subshell.yaml | 54 + .../cases/compat/compound_cmds/until.yaml | 101 + .../cases/compat/compound_cmds/while.yaml | 127 + .../tests/cases/compat/errors.yaml | 744 ++ .../tests/cases/compat/extended_tests.yaml | 705 ++ .../tests/cases/compat/fd_management.yaml | 72 + .../tests/cases/compat/functions.yaml | 250 + .../brush-shell/tests/cases/compat/here.yaml | 742 ++ .../brush-shell/tests/cases/compat/ifs.yaml | 889 +++ .../tests/cases/compat/interactive.yaml | 288 + .../tests/cases/compat/line_continuation.yaml | 65 + .../brush-shell/tests/cases/compat/list.yaml | 29 + .../tests/cases/compat/nameref.yaml | 2288 ++++++ .../cases/compat/options/bash_xtracefd.yaml | 84 + .../tests/cases/compat/options/default.yaml | 8 + .../tests/cases/compat/options/errtrace.yaml | 212 + .../tests/cases/compat/options/extdebug.yaml | 314 + .../tests/cases/compat/options/functrace.yaml | 314 + .../cases/compat/options/inherit-errexit.yaml | 76 + .../tests/cases/compat/options/set-B.yaml | 9 + .../tests/cases/compat/options/set-C.yaml | 27 + .../tests/cases/compat/options/set-a.yaml | 20 + .../tests/cases/compat/options/set-e.yaml | 1331 ++++ .../tests/cases/compat/options/set-n.yaml | 7 + .../tests/cases/compat/options/set-t.yaml | 7 + .../tests/cases/compat/options/set-u.yaml | 539 ++ .../tests/cases/compat/options/set-x.yaml | 257 + .../cases/compat/options/shopt-failglob.yaml | 79 + .../cases/compat/options/shopt-lastpipe.yaml | 11 + .../compat/options/shopt-login_shell.yaml | 18 + .../cases/compat/options/shopt-nullglob.yaml | 79 + .../tests/cases/compat/path_search.yaml | 291 + .../compat/patterns/filename_expansion.yaml | 409 + .../tests/cases/compat/patterns/patterns.yaml | 232 + .../tests/cases/compat/piped_script.yaml | 208 + .../tests/cases/compat/pipeline.yaml | 205 + .../tests/cases/compat/process.yaml | 17 + .../tests/cases/compat/prompt.yaml | 372 + .../tests/cases/compat/redirection.yaml | 353 + .../tests/cases/compat/signals.yaml | 611 ++ .../tests/cases/compat/simple_commands.yaml | 92 + .../cases/compat/special_parameters.yaml | 309 + .../tests/cases/compat/status.yaml | 131 + .../tests/cases/compat/variables.yaml | 169 + .../tests/cases/compat/well_known_vars.yaml | 293 + .../cases/compat/word_expansion/arrays.yaml | 134 + .../cases/compat/word_expansion/braces.yaml | 371 + .../word_expansion/command_substitution.yaml | 410 + .../word_expansion/param_transformation.yaml | 160 + .../cases/compat/word_expansion/params.yaml | 938 +++ .../cases/compat/word_expansion/quotes.yaml | 84 + .../cases/compat/word_expansion/tilde.yaml | 188 + .../cases/compat/word_expansion/vars.yaml | 77 + .../source/brush-shell/tests/compat_tests.rs | 152 + .../brush-shell/tests/completion_tests.rs | 1221 +++ .../brush-shell/tests/images/.gitignore | 17 + .../source/brush-shell/tests/images/README.md | 39 + .../brush-shell/tests/images/mkosi.conf | 26 + .../brush-shell/tests/images/mkosi.postinst | 22 + .../mkosi.profiles/container/mkosi.conf | 6 + .../tests/images/mkosi.profiles/vm/mkosi.conf | 21 + .../brush-shell/tests/integration_tests.rs | 63 + .../brush-shell/tests/interactive_tests.rs | 706 ++ .../brush-shell/tests/pty_help_tests.rs | 63 + .../brush-shell/tests/pty_startup_tests.rs | 192 + .../tests/reedline_interactive_tests.rs | 270 + .../tests/utils/process-helpers.sh | 28 + .../source/brush-shell/tests/version_tests.rs | 48 + .../brush-shell/tests/vt_interactive_tests.rs | 63 + .../source/brush-test-harness/Cargo.toml | 57 + .../brush/source/brush-test-harness/LICENSE | 1 + .../brush/source/brush-test-harness/build.rs | 11 + .../brush-test-harness/src/comparison.rs | 374 + .../source/brush-test-harness/src/config.rs | 391 + .../brush-test-harness/src/execution.rs | 348 + .../source/brush-test-harness/src/lib.rs | 40 + .../brush-test-harness/src/reporting.rs | 524 ++ .../source/brush-test-harness/src/runner.rs | 712 ++ .../source/brush-test-harness/src/testcase.rs | 216 + .../source/brush-test-harness/src/util.rs | 155 + .../brush-winnow-args-adapter/Cargo.toml | 21 + .../source/brush-winnow-args-adapter/LICENSE | 1 + .../brush-winnow-args-adapter/src/adapter.rs | 441 ++ .../brush-winnow-args-adapter/src/lib.rs | 116 + userland/brush/source/brush/Cargo.toml | 34 + userland/brush/source/brush/LICENSE | 1 + userland/brush/source/brush/src/main.rs | 5 + userland/brush/source/cliff.toml | 91 + userland/brush/source/clippy.toml | 1 + userland/brush/source/deny.toml | 92 + userland/brush/source/docs/README.md | 15 + userland/brush/source/docs/demos/demo.tape | 146 + userland/brush/source/docs/demos/fzf.tape | 59 + .../brush/source/docs/explanation/README.md | 6 + .../source/docs/explanation/architecture.md | 89 + .../source/docs/extras/brand/brush-mark-b.svg | 29 + .../extras/brand/brush-wordmark-black.svg | 3 + .../extras/brand/brush-wordmark-white.svg | 3 + .../docs/extras/brand/brush-wordmark.svg | 3 + .../docs/extras/brand/mascot/turtle-dark.png | Bin 0 -> 331876 bytes .../docs/extras/brand/mascot/turtle-light.png | Bin 0 -> 197574 bytes .../source/docs/extras/brush-screenshot.png | Bin 0 -> 34213 bytes userland/brush/source/docs/how-to/README.md | 8 + userland/brush/source/docs/how-to/build.md | 5 + .../brush/source/docs/how-to/record-tapes.md | 16 + userland/brush/source/docs/how-to/release.md | 15 + .../source/docs/how-to/run-benchmarks.md | 31 + .../brush/source/docs/how-to/run-tests.md | 48 + .../brush/source/docs/how-to/upgrade-msrv.md | 142 + .../brush/source/docs/reference/README.md | 12 + .../source/docs/reference/compatibility.md | 222 + .../source/docs/reference/configuration.md | 121 + .../brush/source/docs/reference/examples.md | 18 + .../source/docs/reference/experimental.md | 153 + .../docs/reference/integration-testing.md | 17 + .../source/docs/reference/msrv-policy.md | 106 + .../source/docs/reference/related-projects.md | 9 + .../brush/source/docs/reference/zsh-hooks.md | 112 + userland/brush/source/e2e/README.md | 180 + userland/brush/source/e2e/atuin/Dockerfile | 26 + userland/brush/source/e2e/atuin/entrypoint.sh | 22 + userland/brush/source/e2e/atuin/skip-list.txt | 3 + .../brush/source/e2e/atuin/tests/README.md | 19 + .../source/e2e/atuin/tests/test_atuin.py | 116 + .../brush/source/e2e/atuin/xfail-list.txt | 11 + .../source/e2e/bash-completion/Dockerfile | 17 + .../source/e2e/bash-completion/entrypoint.sh | 20 + .../source/e2e/bash-completion/report-only | 4 + .../brush/source/e2e/bash-tests/Dockerfile | 31 + .../brush/source/e2e/bash-tests/entrypoint.sh | 8 + .../brush/source/e2e/bash-tests/skip-list.txt | 3 + .../source/e2e/bash-tests/tests/conftest.py | 46 + .../source/e2e/bash-tests/tests/test_bash.py | 235 + .../source/e2e/bash-tests/xfail-list.txt | 86 + userland/brush/source/e2e/blesh/Dockerfile | 16 + userland/brush/source/e2e/blesh/entrypoint.sh | 15 + .../brush/source/e2e/blesh/select-args.txt | 1 + userland/brush/source/e2e/blesh/skip-list.txt | 4 + .../source/e2e/blesh/tests/test_blesh.py | 135 + .../brush/source/e2e/blesh/xfail-list.txt | 14 + userland/brush/source/e2e/fzf/Dockerfile | 17 + userland/brush/source/e2e/fzf/Gemfile | 3 + userland/brush/source/e2e/fzf/entrypoint.sh | 15 + userland/brush/source/e2e/fzf/select-args.txt | 1 + userland/brush/source/e2e/fzf/skip-list.txt | 17 + userland/brush/source/e2e/fzf/xfail-list.txt | 21 + userland/brush/source/e2e/lib/e2e_skip.py | 25 + userland/brush/source/e2e/lib/pty_shell.py | 61 + userland/brush/source/e2e/lib/rcfile.py | 11 + userland/brush/source/e2e/lib/run_pytest.sh | 20 + userland/brush/source/e2e/lib/tmux_shell.py | 125 + userland/brush/source/e2e/mise/Dockerfile | 31 + userland/brush/source/e2e/mise/entrypoint.sh | 7 + userland/brush/source/e2e/mise/skip-list.txt | 3 + .../brush/source/e2e/mise/tests/test_mise.py | 86 + userland/brush/source/e2e/mise/xfail-list.txt | 10 + userland/brush/source/e2e/nvm/Dockerfile | 30 + userland/brush/source/e2e/nvm/entrypoint.sh | 43 + userland/brush/source/e2e/nvm/skip-list.txt | 7 + .../brush/source/e2e/nvm/tests/test_nvm.py | 79 + userland/brush/source/e2e/nvm/xfail-list.txt | 7 + .../brush/source/e2e/oh-my-git/Dockerfile | 15 + .../brush/source/e2e/oh-my-git/entrypoint.sh | 7 + .../brush/source/e2e/oh-my-git/skip-list.txt | 3 + .../e2e/oh-my-git/tests/test_oh_my_git.py | 109 + .../brush/source/e2e/oh-my-git/xfail-list.txt | 3 + userland/brush/source/e2e/shim/bash | 5 + userland/brush/source/e2e/starship/Dockerfile | 25 + .../brush/source/e2e/starship/entrypoint.sh | 7 + .../brush/source/e2e/starship/skip-list.txt | 3 + .../e2e/starship/tests/test_starship.py | 82 + .../brush/source/e2e/starship/xfail-list.txt | 3 + userland/brush/source/e2e/zoxide/Dockerfile | 26 + .../brush/source/e2e/zoxide/entrypoint.sh | 7 + .../brush/source/e2e/zoxide/skip-list.txt | 3 + .../source/e2e/zoxide/tests/test_zoxide.py | 67 + .../brush/source/e2e/zoxide/xfail-list.txt | 3 + userland/brush/source/fuzz/.gitignore | 4 + userland/brush/source/fuzz/Cargo.toml | 58 + .../fuzz/fuzz_targets/fuzz_arithmetic.rs | 97 + .../fuzz/fuzz_targets/fuzz_highlight.rs | 92 + .../source/fuzz/fuzz_targets/fuzz_parse.rs | 82 + userland/brush/source/lychee.toml | 8 + userland/brush/source/release-plz.toml | 35 + userland/brush/source/rust-toolchain.toml | 30 + userland/brush/source/rustfmt.toml | 1 + userland/brush/source/samples/config.toml | 22 + .../brush/source/schemas/config.schema.json | 54 + .../scripts/compare-benchmark-results.py | 102 + .../brush/source/scripts/enum-lib-crates.sh | 10 + .../source/scripts/format-api-diff-report.py | 69 + .../brush/source/scripts/install/install.sh | 373 + .../scripts/install/tests/test_install.py | 392 + .../source/scripts/start-dev-container.py | 137 + .../source/scripts/test-across-shells.sh | 218 + .../source/scripts/test-code-coverage.sh | 17 + userland/brush/source/sgconfig.yml | 5 + userland/brush/source/xtask/Cargo.toml | 37 + userland/brush/source/xtask/src/analyze.rs | 208 + userland/brush/source/xtask/src/check.rs | 312 + .../brush/source/xtask/src/check/crates.rs | 105 + userland/brush/source/xtask/src/ci.rs | 224 + userland/brush/source/xtask/src/common.rs | 167 + userland/brush/source/xtask/src/e2e.rs | 1676 ++++ userland/brush/source/xtask/src/generate.rs | 361 + userland/brush/source/xtask/src/main.rs | 62 + userland/brush/source/xtask/src/test.rs | 491 ++ userland/brush/source/zizmor.yml | 4 + userland/bundle.nu | 16 +- 863 files changed, 141121 insertions(+), 14090 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 host/tests/packaged-builder.nu delete mode 100644 userland/.cargo/config.toml delete mode 100644 userland/Cargo.lock delete mode 100644 userland/Cargo.toml delete mode 100644 userland/brush/.gitignore delete mode 100644 userland/brush/Cargo.lock create mode 100644 userland/brush/README.md create mode 100644 userland/brush/source/.cargo/config.toml create mode 100644 userland/brush/source/.cliffignore create mode 100644 userland/brush/source/.config/ast-grep/rule-tests/platform-cfg-outside-sys-test.yml create mode 100644 userland/brush/source/.config/ast-grep/rules/platform-cfg-outside-sys.yml create mode 100644 userland/brush/source/.config/nextest.toml create mode 100644 userland/brush/source/.devcontainer/Dockerfile create mode 100644 userland/brush/source/.devcontainer/devcontainer.json create mode 100755 userland/brush/source/.devcontainer/install-rust-tools.sh create mode 100644 userland/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml create mode 100644 userland/brush/source/.github/ISSUE_TEMPLATE/config.yml create mode 100644 userland/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 userland/brush/source/.github/ISSUE_TEMPLATE/other.yml create mode 100644 userland/brush/source/.github/copilot-instructions.md create mode 100644 userland/brush/source/.github/dependabot.yml create mode 100644 userland/brush/source/.github/workflows/cd.yaml create mode 100644 userland/brush/source/.github/workflows/ci-reports.yaml create mode 100644 userland/brush/source/.github/workflows/ci.yaml create mode 100644 userland/brush/source/.github/workflows/codeql.yml create mode 100644 userland/brush/source/.github/workflows/devcontainer.yaml create mode 100644 userland/brush/source/.github/workflows/docs.yaml create mode 100644 userland/brush/source/.github/workflows/hooks.yaml create mode 100644 userland/brush/source/.github/workflows/install-script.yaml create mode 100644 userland/brush/source/.github/workflows/shellcheck.yaml create mode 100644 userland/brush/source/.github/workflows/workflow-checks.yaml create mode 100644 userland/brush/source/.gitignore create mode 100644 userland/brush/source/.pre-commit-config.yaml create mode 100644 userland/brush/source/.rgignore create mode 100644 userland/brush/source/.vscode/extensions.json create mode 100644 userland/brush/source/.vscode/launch.json create mode 100644 userland/brush/source/.vscode/settings.json create mode 100644 userland/brush/source/AGENTS.md create mode 100644 userland/brush/source/CHANGELOG.md create mode 100644 userland/brush/source/CODE_OF_CONDUCT.md create mode 100644 userland/brush/source/CONTRIBUTING.md create mode 100644 userland/brush/source/LICENSE create mode 100644 userland/brush/source/README.md create mode 100644 userland/brush/source/benchmarks/.gitignore create mode 100755 userland/brush/source/benchmarks/bench.py create mode 100644 userland/brush/source/brush-builtins/Cargo.toml create mode 120000 userland/brush/source/brush-builtins/LICENSE create mode 100644 userland/brush/source/brush-builtins/src/alias.rs create mode 100644 userland/brush/source/brush-builtins/src/bg.rs create mode 100644 userland/brush/source/brush-builtins/src/bind.rs create mode 100644 userland/brush/source/brush-builtins/src/break_.rs create mode 100644 userland/brush/source/brush-builtins/src/builder.rs create mode 100644 userland/brush/source/brush-builtins/src/builtin_.rs create mode 100644 userland/brush/source/brush-builtins/src/caller.rs create mode 100644 userland/brush/source/brush-builtins/src/cd.rs create mode 100644 userland/brush/source/brush-builtins/src/colon.rs create mode 100644 userland/brush/source/brush-builtins/src/command.rs create mode 100644 userland/brush/source/brush-builtins/src/complete.rs create mode 100644 userland/brush/source/brush-builtins/src/continue_.rs create mode 100644 userland/brush/source/brush-builtins/src/declare.rs create mode 100644 userland/brush/source/brush-builtins/src/dirs.rs create mode 100644 userland/brush/source/brush-builtins/src/dot.rs create mode 100644 userland/brush/source/brush-builtins/src/echo.rs create mode 100644 userland/brush/source/brush-builtins/src/enable.rs create mode 100644 userland/brush/source/brush-builtins/src/eval.rs create mode 100644 userland/brush/source/brush-builtins/src/exec.rs create mode 100644 userland/brush/source/brush-builtins/src/exit.rs create mode 100644 userland/brush/source/brush-builtins/src/export.rs create mode 100644 userland/brush/source/brush-builtins/src/factory.rs create mode 100644 userland/brush/source/brush-builtins/src/false_.rs create mode 100644 userland/brush/source/brush-builtins/src/fc.rs create mode 100644 userland/brush/source/brush-builtins/src/fg.rs create mode 100644 userland/brush/source/brush-builtins/src/getopts.rs create mode 100644 userland/brush/source/brush-builtins/src/hash.rs create mode 100644 userland/brush/source/brush-builtins/src/help.rs create mode 100644 userland/brush/source/brush-builtins/src/history.rs create mode 100644 userland/brush/source/brush-builtins/src/jobs.rs create mode 100644 userland/brush/source/brush-builtins/src/kill.rs create mode 100644 userland/brush/source/brush-builtins/src/let_.rs create mode 100644 userland/brush/source/brush-builtins/src/lib.rs create mode 100644 userland/brush/source/brush-builtins/src/lookup.rs create mode 100644 userland/brush/source/brush-builtins/src/mapfile.rs create mode 100644 userland/brush/source/brush-builtins/src/popd.rs create mode 100644 userland/brush/source/brush-builtins/src/printf.rs create mode 100644 userland/brush/source/brush-builtins/src/pushd.rs create mode 100644 userland/brush/source/brush-builtins/src/pwd.rs create mode 100644 userland/brush/source/brush-builtins/src/read.rs create mode 100644 userland/brush/source/brush-builtins/src/return_.rs create mode 100644 userland/brush/source/brush-builtins/src/set.rs create mode 100644 userland/brush/source/brush-builtins/src/shift.rs create mode 100644 userland/brush/source/brush-builtins/src/shopt.rs create mode 100644 userland/brush/source/brush-builtins/src/suspend.rs create mode 100644 userland/brush/source/brush-builtins/src/test.rs create mode 100644 userland/brush/source/brush-builtins/src/times.rs create mode 100644 userland/brush/source/brush-builtins/src/trap.rs create mode 100644 userland/brush/source/brush-builtins/src/true_.rs create mode 100644 userland/brush/source/brush-builtins/src/type_.rs create mode 100644 userland/brush/source/brush-builtins/src/ulimit.rs create mode 100644 userland/brush/source/brush-builtins/src/umask.rs create mode 100644 userland/brush/source/brush-builtins/src/unalias.rs create mode 100644 userland/brush/source/brush-builtins/src/unimp.rs create mode 100644 userland/brush/source/brush-builtins/src/unset.rs create mode 100644 userland/brush/source/brush-builtins/src/wait.rs create mode 100644 userland/brush/source/brush-builtins/tests/process_launcher_tests.rs create mode 100644 userland/brush/source/brush-builtins/tests/registration_tests.rs create mode 100644 userland/brush/source/brush-builtins/tests/source_program_tests.rs create mode 100644 userland/brush/source/brush-builtins/tests/task_spawner_tests.rs create mode 100644 userland/brush/source/brush-bundled-tests/Cargo.toml create mode 100644 userland/brush/source/brush-bundled-tests/src/main.rs create mode 100644 userland/brush/source/brush-bundled-tests/tests/shim.rs create mode 100644 userland/brush/source/brush-clap-adapter/Cargo.toml create mode 120000 userland/brush/source/brush-clap-adapter/LICENSE create mode 100644 userland/brush/source/brush-clap-adapter/examples/custom-builtin.rs create mode 100644 userland/brush/source/brush-clap-adapter/src/adapter.rs create mode 100644 userland/brush/source/brush-clap-adapter/src/lib.rs create mode 100644 userland/brush/source/brush-clap-adapter/tests/impl_parse_args_tests.rs create mode 100644 userland/brush/source/brush-core/Cargo.toml create mode 120000 userland/brush/source/brush-core/LICENSE create mode 100644 userland/brush/source/brush-core/examples/call-func.rs create mode 100644 userland/brush/source/brush-core/examples/readme.rs create mode 100644 userland/brush/source/brush-core/sausage-process/deps/io.wit create mode 100644 userland/brush/source/brush-core/sausage-process/process.wit create mode 100644 userland/brush/source/brush-core/src/arithmetic.rs create mode 100644 userland/brush/source/brush-core/src/braceexpansion.rs create mode 100644 userland/brush/source/brush-core/src/builtins.rs create mode 100644 userland/brush/source/brush-core/src/builtins/args.rs create mode 100644 userland/brush/source/brush-core/src/builtins/registry.rs create mode 100644 userland/brush/source/brush-core/src/builtins/state.rs create mode 100644 userland/brush/source/brush-core/src/callstack.rs create mode 100644 userland/brush/source/brush-core/src/commands.rs create mode 100644 userland/brush/source/brush-core/src/completion.rs create mode 100644 userland/brush/source/brush-core/src/completion/quoting.rs create mode 100644 userland/brush/source/brush-core/src/completion/words.rs create mode 100644 userland/brush/source/brush-core/src/env.rs create mode 100644 userland/brush/source/brush-core/src/error.rs create mode 100644 userland/brush/source/brush-core/src/escape.rs create mode 100644 userland/brush/source/brush-core/src/expansion.rs create mode 100644 userland/brush/source/brush-core/src/expansion/fieldsplit.rs create mode 100644 userland/brush/source/brush-core/src/extendedtests.rs create mode 100644 userland/brush/source/brush-core/src/extensions.rs create mode 100644 userland/brush/source/brush-core/src/functions.rs create mode 100644 userland/brush/source/brush-core/src/history.rs create mode 100644 userland/brush/source/brush-core/src/int_utils.rs create mode 100644 userland/brush/source/brush-core/src/interfaces.rs create mode 100644 userland/brush/source/brush-core/src/interfaces/keybindings.rs create mode 100644 userland/brush/source/brush-core/src/interp.rs create mode 100644 userland/brush/source/brush-core/src/ioutils.rs create mode 100644 userland/brush/source/brush-core/src/jobs.rs create mode 100644 userland/brush/source/brush-core/src/keywords.rs create mode 100644 userland/brush/source/brush-core/src/lib.rs create mode 100644 userland/brush/source/brush-core/src/namedoptions.rs create mode 100644 userland/brush/source/brush-core/src/openfiles.rs create mode 100644 userland/brush/source/brush-core/src/options.rs create mode 100644 userland/brush/source/brush-core/src/pathcache.rs create mode 100644 userland/brush/source/brush-core/src/pathsearch.rs create mode 100644 userland/brush/source/brush-core/src/patterns.rs create mode 100644 userland/brush/source/brush-core/src/processes.rs create mode 100644 userland/brush/source/brush-core/src/prompt.rs create mode 100644 userland/brush/source/brush-core/src/regex.rs create mode 100644 userland/brush/source/brush-core/src/results.rs create mode 100644 userland/brush/source/brush-core/src/shell.rs create mode 100644 userland/brush/source/brush-core/src/shell/builder.rs create mode 100644 userland/brush/source/brush-core/src/shell/builtin_registry.rs create mode 100644 userland/brush/source/brush-core/src/shell/callstack.rs create mode 100644 userland/brush/source/brush-core/src/shell/completion.rs create mode 100644 userland/brush/source/brush-core/src/shell/env.rs create mode 100644 userland/brush/source/brush-core/src/shell/execution.rs create mode 100644 userland/brush/source/brush-core/src/shell/expansion.rs create mode 100644 userland/brush/source/brush-core/src/shell/fs.rs create mode 100644 userland/brush/source/brush-core/src/shell/funcs.rs create mode 100644 userland/brush/source/brush-core/src/shell/history.rs create mode 100644 userland/brush/source/brush-core/src/shell/initscripts.rs create mode 100644 userland/brush/source/brush-core/src/shell/io.rs create mode 100644 userland/brush/source/brush-core/src/shell/job_control.rs create mode 100644 userland/brush/source/brush-core/src/shell/parsing.rs create mode 100644 userland/brush/source/brush-core/src/shell/prompts.rs create mode 100644 userland/brush/source/brush-core/src/shell/readline.rs create mode 100644 userland/brush/source/brush-core/src/shell/state.rs create mode 100644 userland/brush/source/brush-core/src/shell/traps.rs create mode 100644 userland/brush/source/brush-core/src/sourceinfo.rs create mode 100644 userland/brush/source/brush-core/src/sys.rs create mode 100644 userland/brush/source/brush-core/src/sys/fs.rs create mode 100644 userland/brush/source/brush-core/src/sys/hostname.rs create mode 100644 userland/brush/source/brush-core/src/sys/os_pipes.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/async_pipe.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/commands.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/fd.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/fs.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/input.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/network.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/poll.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/resource.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/signal.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/terminal.rs create mode 100644 userland/brush/source/brush-core/src/sys/stubs/users.rs create mode 100644 userland/brush/source/brush-core/src/sys/tokio_process.rs create mode 100644 userland/brush/source/brush-core/src/sys/tokio_tasks.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/async_pipe.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/commands.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/env.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/fd.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/fs.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/input.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/network.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/poll.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/resource.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/signal.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/terminal.rs create mode 100644 userland/brush/source/brush-core/src/sys/unix/users.rs create mode 100644 userland/brush/source/brush-core/src/sys/wasm/async_pipe.rs create mode 100644 userland/brush/source/brush-core/src/sys/wasm/env.rs create mode 100644 userland/brush/source/brush-core/src/sys/wasm/fs.rs create mode 100644 userland/brush/source/brush-core/src/sys/wasm/mod.rs create mode 100644 userland/brush/source/brush-core/src/sys/wasm/pipes.rs rename userland/brush/{ => source/brush-core/src/sys/wasm}/process.rs (100%) rename userland/brush/{lookup.rs => source/brush-core/src/sys/wasm/sausage-lookup.rs} (100%) create mode 100644 userland/brush/source/brush-core/src/sys/wasm/tasks.rs create mode 100644 userland/brush/source/brush-core/src/sys/windows.rs create mode 100644 userland/brush/source/brush-core/src/sys/windows/env.rs create mode 100644 userland/brush/source/brush-core/src/sys/windows/fs.rs create mode 100644 userland/brush/source/brush-core/src/sys/windows/network.rs create mode 100644 userland/brush/source/brush-core/src/sys/windows/users.rs create mode 100644 userland/brush/source/brush-core/src/tasks.rs create mode 100644 userland/brush/source/brush-core/src/terminal.rs create mode 100644 userland/brush/source/brush-core/src/tests.rs create mode 100644 userland/brush/source/brush-core/src/timing.rs create mode 100644 userland/brush/source/brush-core/src/trace_categories.rs create mode 100644 userland/brush/source/brush-core/src/traps.rs create mode 100644 userland/brush/source/brush-core/src/variables.rs create mode 100644 userland/brush/source/brush-core/src/wellknownvars.rs create mode 100644 userland/brush/source/brush-core/tests/builtin_contract_tests.rs create mode 100644 userland/brush/source/brush-core/tests/builtin_state_tests.rs create mode 100644 userland/brush/source/brush-core/tests/cancellation_tests.rs create mode 100644 userland/brush/source/brush-core/tests/history_tests.rs create mode 100644 userland/brush/source/brush-core/tests/kill_on_drop_tests.rs create mode 100644 userland/brush/source/brush-core/tests/readme_snippet.rs create mode 100644 userland/brush/source/brush-coreutils-builtins/Cargo.toml create mode 120000 userland/brush/source/brush-coreutils-builtins/LICENSE create mode 100644 userland/brush/source/brush-coreutils-builtins/src/lib.rs create mode 100644 userland/brush/source/brush-experimental-builtins/Cargo.toml create mode 120000 userland/brush/source/brush-experimental-builtins/LICENSE create mode 100644 userland/brush/source/brush-experimental-builtins/src/lib.rs create mode 100644 userland/brush/source/brush-experimental-builtins/src/save.rs create mode 100644 userland/brush/source/brush-interactive/Cargo.toml create mode 120000 userland/brush/source/brush-interactive/LICENSE create mode 100644 userland/brush/source/brush-interactive/build.rs create mode 100644 userland/brush/source/brush-interactive/src/completeness.rs create mode 100644 userland/brush/source/brush-interactive/src/completion.rs create mode 100644 userland/brush/source/brush-interactive/src/error.rs create mode 100644 userland/brush/source/brush-interactive/src/highlighting.rs create mode 100644 userland/brush/source/brush-interactive/src/input_backend.rs create mode 100644 userland/brush/source/brush-interactive/src/interactive_shell.rs create mode 100644 userland/brush/source/brush-interactive/src/lib.rs create mode 100644 userland/brush/source/brush-interactive/src/minimal/input_backend.rs create mode 100644 userland/brush/source/brush-interactive/src/minimal/mod.rs create mode 100644 userland/brush/source/brush-interactive/src/options.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/completer.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/edit_mode.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/highlighter.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/history.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/input_backend.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/mod.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/prompt.rs create mode 100644 userland/brush/source/brush-interactive/src/reedline/validator.rs create mode 100644 userland/brush/source/brush-interactive/src/refs.rs create mode 100644 userland/brush/source/brush-interactive/src/styles.rs create mode 100644 userland/brush/source/brush-interactive/src/term_detection.rs create mode 100644 userland/brush/source/brush-interactive/src/term_integration.rs create mode 100644 userland/brush/source/brush-interactive/src/trace_categories.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/completion_list.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/input_backend.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/keymap.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/keys.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/line_buffer.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/line_editor.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/line_reader.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/mod.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/render.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/style.rs create mode 100644 userland/brush/source/brush-interactive/src/vt/terminal.rs create mode 100644 userland/brush/source/brush-interactive/src/zsh_hooks.rs create mode 100644 userland/brush/source/brush-parser/Cargo.toml create mode 120000 userland/brush/source/brush-parser/LICENSE create mode 100644 userland/brush/source/brush-parser/benches/parser.rs create mode 100644 userland/brush/source/brush-parser/examples/miette.rs create mode 100644 userland/brush/source/brush-parser/examples/serde.rs create mode 100644 userland/brush/source/brush-parser/src/arithmetic.rs create mode 100644 userland/brush/source/brush-parser/src/ast.rs create mode 100644 userland/brush/source/brush-parser/src/error.rs create mode 100644 userland/brush/source/brush-parser/src/lib.rs create mode 100644 userland/brush/source/brush-parser/src/parser/mod.rs create mode 100644 userland/brush/source/brush-parser/src/parser/peg.rs create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_program.snap create mode 100644 userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/and_or_lists.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/assignments.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/complex.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/compound_commands.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/extended_test.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/functions.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/here_docs.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/mod.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/pipelines.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/redirections.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/simple_commands.rs create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_nested_if_while.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_parameter_expansion_complex.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_pipeline_with_redirections.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_adjacent_semicolons.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_condition.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_open_with_line_continuations.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_close.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_open.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body_and_redirect.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_double_quoted_delimiter.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_empty.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_left_open_in_nested_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_no_trailing_newline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_quoted_delimiter.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_tab_removal.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_with_command_after.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_with_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_with_fd.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_pathological_here_docs.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__parse_program.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_in_range_still_recognized.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_out_of_range_does_not_panic.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap create mode 100644 userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap create mode 100644 userland/brush/source/brush-parser/src/parser/winnow_str.rs create mode 100644 userland/brush/source/brush-parser/src/pattern.rs create mode 100644 userland/brush/source/brush-parser/src/prompt.rs create mode 100644 userland/brush/source/brush-parser/src/readline_binding.rs create mode 100644 userland/brush/source/brush-parser/src/snapshot_tests.rs create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_double_quote.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_double_quoted_arithmetic_expression.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_double_quoted_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_empty_here_doc.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_escaped_whitespace.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc-2.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc-3.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc-4.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc_in_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc_in_double_quoted_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc_in_double_quoted_command_substitution_with_space.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc_with_other_tokens.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_here_doc_with_tab_removal.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_line_continuation.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_multiple_here_docs.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_operator_followed_by_backslash.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_operators.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_multibyte_chars.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap create mode 100644 userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap create mode 100644 userland/brush/source/brush-parser/src/source.rs create mode 100644 userland/brush/source/brush-parser/src/test_command.rs create mode 100644 userland/brush/source/brush-parser/src/tokenizer.rs create mode 100644 userland/brush/source/brush-parser/src/tokenizer/char_reader.rs create mode 100644 userland/brush/source/brush-parser/src/word.rs create mode 100644 userland/brush/source/brush-shell/Cargo.toml create mode 120000 userland/brush/source/brush-shell/LICENSE create mode 100644 userland/brush/source/brush-shell/about.hbs create mode 100644 userland/brush/source/brush-shell/about.toml create mode 100644 userland/brush/source/brush-shell/benches/shell.rs create mode 100644 userland/brush/source/brush-shell/build.rs create mode 100644 userland/brush/source/brush-shell/examples/gen.rs create mode 100644 userland/brush/source/brush-shell/src/args.rs create mode 100644 userland/brush/source/brush-shell/src/brushctl.rs create mode 100644 userland/brush/source/brush-shell/src/bundled.rs create mode 100644 userland/brush/source/brush-shell/src/config.rs create mode 100644 userland/brush/source/brush-shell/src/entry.rs create mode 100644 userland/brush/source/brush-shell/src/error_formatter.rs create mode 100644 userland/brush/source/brush-shell/src/events.rs create mode 100644 userland/brush/source/brush-shell/src/lib.rs create mode 100644 userland/brush/source/brush-shell/src/main.rs create mode 100644 userland/brush/source/brush-shell/src/productinfo.rs create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/brushctl.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/cli.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/command_substitution.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/completion.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/external_commands.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/help.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/here_documents.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/pipelines.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/redirection.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/scripting.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/tracing.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/brush/zsh_hooks.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/and_or.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/arguments.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/arrays.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/assignments.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/basic.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/hash.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/kill.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/local.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/mapfile.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/shift.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/suspend.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/umask.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/builtins/wait.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/callstack.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/completion.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/errors.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/fd_management.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/functions.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/here.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/ifs.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/interactive.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/line_continuation.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/list.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/nameref.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/default.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/set-x.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/shopt-failglob.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/path_search.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/piped_script.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/pipeline.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/process.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/prompt.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/redirection.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/signals.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/status.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/variables.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/param_transformation.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/params.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml create mode 100644 userland/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml create mode 100644 userland/brush/source/brush-shell/tests/compat_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/completion_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/images/.gitignore create mode 100644 userland/brush/source/brush-shell/tests/images/README.md create mode 100644 userland/brush/source/brush-shell/tests/images/mkosi.conf create mode 100755 userland/brush/source/brush-shell/tests/images/mkosi.postinst create mode 100644 userland/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf create mode 100644 userland/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf create mode 100644 userland/brush/source/brush-shell/tests/integration_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/interactive_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/pty_help_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/pty_startup_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/reedline_interactive_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/utils/process-helpers.sh create mode 100644 userland/brush/source/brush-shell/tests/version_tests.rs create mode 100644 userland/brush/source/brush-shell/tests/vt_interactive_tests.rs create mode 100644 userland/brush/source/brush-test-harness/Cargo.toml create mode 120000 userland/brush/source/brush-test-harness/LICENSE create mode 100644 userland/brush/source/brush-test-harness/build.rs create mode 100644 userland/brush/source/brush-test-harness/src/comparison.rs create mode 100644 userland/brush/source/brush-test-harness/src/config.rs create mode 100644 userland/brush/source/brush-test-harness/src/execution.rs create mode 100644 userland/brush/source/brush-test-harness/src/lib.rs create mode 100644 userland/brush/source/brush-test-harness/src/reporting.rs create mode 100644 userland/brush/source/brush-test-harness/src/runner.rs create mode 100644 userland/brush/source/brush-test-harness/src/testcase.rs create mode 100644 userland/brush/source/brush-test-harness/src/util.rs create mode 100644 userland/brush/source/brush-winnow-args-adapter/Cargo.toml create mode 120000 userland/brush/source/brush-winnow-args-adapter/LICENSE create mode 100644 userland/brush/source/brush-winnow-args-adapter/src/adapter.rs create mode 100644 userland/brush/source/brush-winnow-args-adapter/src/lib.rs create mode 100644 userland/brush/source/brush/Cargo.toml create mode 120000 userland/brush/source/brush/LICENSE create mode 100644 userland/brush/source/brush/src/main.rs create mode 100644 userland/brush/source/cliff.toml create mode 100644 userland/brush/source/clippy.toml create mode 100644 userland/brush/source/deny.toml create mode 100644 userland/brush/source/docs/README.md create mode 100644 userland/brush/source/docs/demos/demo.tape create mode 100644 userland/brush/source/docs/demos/fzf.tape create mode 100644 userland/brush/source/docs/explanation/README.md create mode 100644 userland/brush/source/docs/explanation/architecture.md create mode 100644 userland/brush/source/docs/extras/brand/brush-mark-b.svg create mode 100644 userland/brush/source/docs/extras/brand/brush-wordmark-black.svg create mode 100644 userland/brush/source/docs/extras/brand/brush-wordmark-white.svg create mode 100644 userland/brush/source/docs/extras/brand/brush-wordmark.svg create mode 100644 userland/brush/source/docs/extras/brand/mascot/turtle-dark.png create mode 100644 userland/brush/source/docs/extras/brand/mascot/turtle-light.png create mode 100644 userland/brush/source/docs/extras/brush-screenshot.png create mode 100644 userland/brush/source/docs/how-to/README.md create mode 100644 userland/brush/source/docs/how-to/build.md create mode 100644 userland/brush/source/docs/how-to/record-tapes.md create mode 100644 userland/brush/source/docs/how-to/release.md create mode 100644 userland/brush/source/docs/how-to/run-benchmarks.md create mode 100644 userland/brush/source/docs/how-to/run-tests.md create mode 100644 userland/brush/source/docs/how-to/upgrade-msrv.md create mode 100644 userland/brush/source/docs/reference/README.md create mode 100644 userland/brush/source/docs/reference/compatibility.md create mode 100644 userland/brush/source/docs/reference/configuration.md create mode 100644 userland/brush/source/docs/reference/examples.md create mode 100644 userland/brush/source/docs/reference/experimental.md create mode 100644 userland/brush/source/docs/reference/integration-testing.md create mode 100644 userland/brush/source/docs/reference/msrv-policy.md create mode 100644 userland/brush/source/docs/reference/related-projects.md create mode 100644 userland/brush/source/docs/reference/zsh-hooks.md create mode 100644 userland/brush/source/e2e/README.md create mode 100644 userland/brush/source/e2e/atuin/Dockerfile create mode 100755 userland/brush/source/e2e/atuin/entrypoint.sh create mode 100644 userland/brush/source/e2e/atuin/skip-list.txt create mode 100644 userland/brush/source/e2e/atuin/tests/README.md create mode 100644 userland/brush/source/e2e/atuin/tests/test_atuin.py create mode 100644 userland/brush/source/e2e/atuin/xfail-list.txt create mode 100644 userland/brush/source/e2e/bash-completion/Dockerfile create mode 100755 userland/brush/source/e2e/bash-completion/entrypoint.sh create mode 100644 userland/brush/source/e2e/bash-completion/report-only create mode 100644 userland/brush/source/e2e/bash-tests/Dockerfile create mode 100755 userland/brush/source/e2e/bash-tests/entrypoint.sh create mode 100644 userland/brush/source/e2e/bash-tests/skip-list.txt create mode 100644 userland/brush/source/e2e/bash-tests/tests/conftest.py create mode 100644 userland/brush/source/e2e/bash-tests/tests/test_bash.py create mode 100644 userland/brush/source/e2e/bash-tests/xfail-list.txt create mode 100644 userland/brush/source/e2e/blesh/Dockerfile create mode 100755 userland/brush/source/e2e/blesh/entrypoint.sh create mode 100644 userland/brush/source/e2e/blesh/select-args.txt create mode 100644 userland/brush/source/e2e/blesh/skip-list.txt create mode 100644 userland/brush/source/e2e/blesh/tests/test_blesh.py create mode 100644 userland/brush/source/e2e/blesh/xfail-list.txt create mode 100644 userland/brush/source/e2e/fzf/Dockerfile create mode 100644 userland/brush/source/e2e/fzf/Gemfile create mode 100755 userland/brush/source/e2e/fzf/entrypoint.sh create mode 100644 userland/brush/source/e2e/fzf/select-args.txt create mode 100644 userland/brush/source/e2e/fzf/skip-list.txt create mode 100644 userland/brush/source/e2e/fzf/xfail-list.txt create mode 100644 userland/brush/source/e2e/lib/e2e_skip.py create mode 100644 userland/brush/source/e2e/lib/pty_shell.py create mode 100644 userland/brush/source/e2e/lib/rcfile.py create mode 100644 userland/brush/source/e2e/lib/run_pytest.sh create mode 100644 userland/brush/source/e2e/lib/tmux_shell.py create mode 100644 userland/brush/source/e2e/mise/Dockerfile create mode 100755 userland/brush/source/e2e/mise/entrypoint.sh create mode 100644 userland/brush/source/e2e/mise/skip-list.txt create mode 100644 userland/brush/source/e2e/mise/tests/test_mise.py create mode 100644 userland/brush/source/e2e/mise/xfail-list.txt create mode 100644 userland/brush/source/e2e/nvm/Dockerfile create mode 100755 userland/brush/source/e2e/nvm/entrypoint.sh create mode 100644 userland/brush/source/e2e/nvm/skip-list.txt create mode 100644 userland/brush/source/e2e/nvm/tests/test_nvm.py create mode 100644 userland/brush/source/e2e/nvm/xfail-list.txt create mode 100644 userland/brush/source/e2e/oh-my-git/Dockerfile create mode 100755 userland/brush/source/e2e/oh-my-git/entrypoint.sh create mode 100644 userland/brush/source/e2e/oh-my-git/skip-list.txt create mode 100644 userland/brush/source/e2e/oh-my-git/tests/test_oh_my_git.py create mode 100644 userland/brush/source/e2e/oh-my-git/xfail-list.txt create mode 100755 userland/brush/source/e2e/shim/bash create mode 100644 userland/brush/source/e2e/starship/Dockerfile create mode 100755 userland/brush/source/e2e/starship/entrypoint.sh create mode 100644 userland/brush/source/e2e/starship/skip-list.txt create mode 100644 userland/brush/source/e2e/starship/tests/test_starship.py create mode 100644 userland/brush/source/e2e/starship/xfail-list.txt create mode 100644 userland/brush/source/e2e/zoxide/Dockerfile create mode 100755 userland/brush/source/e2e/zoxide/entrypoint.sh create mode 100644 userland/brush/source/e2e/zoxide/skip-list.txt create mode 100644 userland/brush/source/e2e/zoxide/tests/test_zoxide.py create mode 100644 userland/brush/source/e2e/zoxide/xfail-list.txt create mode 100644 userland/brush/source/fuzz/.gitignore create mode 100644 userland/brush/source/fuzz/Cargo.toml create mode 100644 userland/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs create mode 100644 userland/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs create mode 100644 userland/brush/source/fuzz/fuzz_targets/fuzz_parse.rs create mode 100644 userland/brush/source/lychee.toml create mode 100644 userland/brush/source/release-plz.toml create mode 100644 userland/brush/source/rust-toolchain.toml create mode 100644 userland/brush/source/rustfmt.toml create mode 100644 userland/brush/source/samples/config.toml create mode 100644 userland/brush/source/schemas/config.schema.json create mode 100755 userland/brush/source/scripts/compare-benchmark-results.py create mode 100755 userland/brush/source/scripts/enum-lib-crates.sh create mode 100755 userland/brush/source/scripts/format-api-diff-report.py create mode 100755 userland/brush/source/scripts/install/install.sh create mode 100644 userland/brush/source/scripts/install/tests/test_install.py create mode 100755 userland/brush/source/scripts/start-dev-container.py create mode 100755 userland/brush/source/scripts/test-across-shells.sh create mode 100755 userland/brush/source/scripts/test-code-coverage.sh create mode 100644 userland/brush/source/sgconfig.yml create mode 100644 userland/brush/source/xtask/Cargo.toml create mode 100644 userland/brush/source/xtask/src/analyze.rs create mode 100644 userland/brush/source/xtask/src/check.rs create mode 100644 userland/brush/source/xtask/src/check/crates.rs create mode 100644 userland/brush/source/xtask/src/ci.rs create mode 100644 userland/brush/source/xtask/src/common.rs create mode 100644 userland/brush/source/xtask/src/e2e.rs create mode 100644 userland/brush/source/xtask/src/generate.rs create mode 100644 userland/brush/source/xtask/src/main.rs create mode 100644 userland/brush/source/xtask/src/test.rs create mode 100644 userland/brush/source/zizmor.yml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..a3a2515 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# WASI tools share the startup object built by userland/cwd-init/build.nu. +[env] +SAUSAGE_CWD_INIT = { value = "userland/cwd-init/init.o", relative = true } + +[target.wasm32-wasip2] +linker = "userland/cwd-init/link.nu" +rustflags = ["-C", "link-arg=sausage-cwd-init"] diff --git a/.gitignore b/.gitignore index cab49e2..7ee2448 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ target/ /world /result -# Lock files of test crates resolve against the workspace lock. +# The root lock covers the tools. Isolated test guests keep disposable locks. **/tests/**/Cargo.lock +/userland/**/Cargo.lock # Cargo homes that the devshell creates where it is entered. .cargo-home/ diff --git a/AGENTS.md b/AGENTS.md index 2f88aa8..bc45186 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,8 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar `host/src/exec.rs` is the contract between the harness and the process table. - `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests and for host code. It knows nothing about Sausage. Host code reads and writes the world only through it. -- `userland/`: the default CLIs of a world, a separate workspace built for `wasm32-wasip2`. +- `userland/`: the default CLIs of a world, built for `wasm32-wasip2` in the root Cargo workspace. + The patched Brush crates live in `userland/brush/source/` and share the root lock file. - `spikes/`: accepted prototypes. Read them for evidence, copy from them, do not build on them. ## Programs in the world @@ -47,9 +48,11 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar ## Checks ```sh -nix develop -c cargo test --workspace # host and vfs -nix develop -c cargo clippy --workspace --all-targets -- -D warnings -nix develop -c cargo build --manifest-path userland/Cargo.toml --release +nix develop -c cargo test -p sausage -p sausage-vfs +nix develop -c cargo clippy -p sausage -p sausage-vfs --all-targets -- -D warnings +nix develop -c nu userland/bundle.nu +# For one tool, select its package and WASI target explicitly: +nix develop -c cargo build -p git --target wasm32-wasip2 --release ``` `nix flake check` checks the packaged host, its bundle, and sandbox-compatible tests. diff --git a/Cargo.lock b/Cargo.lock index c2547d1..cce7c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + [[package]] name = "addr2line" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "gimli", + "gimli 0.33.0", ] [[package]] @@ -112,6 +121,15 @@ dependencies = [ "alloc-no-stdlib 3.0.0", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -124,6 +142,30 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "ansi-width" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90759a65f5178dbbd1b0eecc97b187996401bced0eb4a943d9ac6d7a6304d111" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "anstream" version = "1.0.0" @@ -160,7 +202,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -171,7 +213,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -180,6 +222,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "ar_archive_writer" version = "0.5.3" @@ -194,6 +245,38 @@ name = "arbitrary" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "archery" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f" +dependencies = [ + "triomphe", +] + +[[package]] +name = "argmax" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0144c58b55af0133ec3963ce5e4d07aad866e3bbcfdcddbf4590dbd7ad6ff557" +dependencies = [ + "libc", + "nix 0.30.1", + "once_cell", +] [[package]] name = "argon2" @@ -207,6 +290,22 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ariadne" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5" +dependencies = [ + "unicode-width 0.2.2", + "yansi", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ascii" version = "1.1.0" @@ -222,7 +321,7 @@ dependencies = [ "asn1-rs-derive 0.4.0", "asn1-rs-impl 0.1.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -238,7 +337,7 @@ dependencies = [ "asn1-rs-derive 0.5.1", "asn1-rs-impl 0.2.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -291,6 +390,35 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecf5c70ca07b7f80220bce936f0556a960ca6fb00fc2bd4125b5e581b218137" +dependencies = [ + "anstyle", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "ast_node" version = "5.0.0" @@ -314,6 +442,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -395,6 +534,30 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line 0.25.1", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -454,7 +617,7 @@ checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" dependencies = [ "blowfish", "pbkdf2", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -466,6 +629,19 @@ dependencies = [ "scoped-tls", ] +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bindgen" version = "0.70.1" @@ -551,6 +727,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -573,6 +752,29 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -600,6 +802,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -610,6 +821,31 @@ dependencies = [ "cipher", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling 0.21.3", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "borsh" version = "1.8.1" @@ -634,6 +870,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "box-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tar", + "url", + "wasi 0.14.4+wasi-0.2.4", +] + [[package]] name = "boxed_error" version = "0.2.3" @@ -686,6 +934,358 @@ dependencies = [ "alloc-stdlib 0.3.0", ] +[[package]] +name = "brush" +version = "0.4.0" +dependencies = [ + "brush-shell", +] + +[[package]] +name = "brush-builtins" +version = "0.2.0" +dependencies = [ + "anyhow", + "brush-core", + "brush-parser", + "brush-winnow-args-adapter", + "cfg-if", + "chrono", + "fancy-regex 0.19.2", + "itertools 0.15.0", + "nix 0.31.3", + "pretty_assertions", + "procfs", + "rlimit", + "strum 0.28.0", + "thiserror 2.0.21", + "tokio", + "tracing", + "utf8-chars", + "uucore", + "winnow-args", +] + +[[package]] +name = "brush-bundled-tests" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "brush-shell", + "tempfile", +] + +[[package]] +name = "brush-clap-adapter" +version = "0.1.0" +dependencies = [ + "anyhow", + "brush-core", + "clap", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "brush-core" +version = "0.5.0" +dependencies = [ + "anyhow", + "async-recursion", + "async-trait", + "bon", + "brush-parser", + "cached", + "cfg-if", + "check_elevation", + "chrono", + "color-print", + "command-fds", + "fancy-regex 0.19.2", + "futures", + "getrandom 0.4.3", + "hostname", + "inherent", + "itertools 0.15.0", + "libc", + "nix 0.31.3", + "normalize-path", + "pretty_assertions", + "rand 0.10.3", + "rpds", + "serde", + "strum 0.28.0", + "strum_macros 0.28.0", + "tempfile", + "terminal_size 0.4.4", + "terminfo", + "thiserror 2.0.21", + "tokio", + "tracing", + "uuid", + "uzers", + "whoami", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "brush-coreutils-builtins" +version = "0.1.0" +dependencies = [ + "uu_arch", + "uu_b2sum", + "uu_base32", + "uu_base64", + "uu_basename", + "uu_basenc", + "uu_cat", + "uu_cksum", + "uu_comm", + "uu_cp", + "uu_csplit", + "uu_cut", + "uu_date", + "uu_dd", + "uu_df", + "uu_dir", + "uu_dircolors", + "uu_dirname", + "uu_du", + "uu_echo", + "uu_env", + "uu_expand", + "uu_expr", + "uu_factor", + "uu_false", + "uu_fmt", + "uu_fold", + "uu_head", + "uu_hostname", + "uu_join", + "uu_link", + "uu_ln", + "uu_ls", + "uu_md5sum", + "uu_mkdir", + "uu_mktemp", + "uu_more", + "uu_mv", + "uu_nl", + "uu_nproc", + "uu_numfmt", + "uu_od", + "uu_paste", + "uu_pr", + "uu_printenv", + "uu_printf", + "uu_ptx", + "uu_pwd", + "uu_readlink", + "uu_realpath", + "uu_rm", + "uu_rmdir", + "uu_seq", + "uu_sha1sum", + "uu_sha224sum", + "uu_sha256sum", + "uu_sha384sum", + "uu_sha512sum", + "uu_shred", + "uu_shuf", + "uu_sleep", + "uu_sort", + "uu_split", + "uu_sum", + "uu_sync", + "uu_tac", + "uu_tail 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uu_tee", + "uu_test", + "uu_touch", + "uu_tr", + "uu_true", + "uu_truncate", + "uu_tsort", + "uu_uname", + "uu_unexpand", + "uu_uniq", + "uu_unlink", + "uu_vdir", + "uu_wc", + "uu_whoami", + "uu_yes", + "uucore", +] + +[[package]] +name = "brush-experimental-builtins" +version = "0.1.0" +dependencies = [ + "brush-core", + "brush-winnow-args-adapter", + "serde_json", + "winnow-args", +] + +[[package]] +name = "brush-fuzz" +version = "0.2.2" +dependencies = [ + "anyhow", + "assert_cmd", + "brush-core", + "brush-interactive", + "brush-parser", + "libfuzzer-sys", + "tokio", +] + +[[package]] +name = "brush-interactive" +version = "0.4.0" +dependencies = [ + "bon", + "brush-core", + "brush-parser", + "indexmap", + "nu-ansi-term", + "pretty_assertions", + "radix_trie", + "reedline", + "thiserror 2.0.21", + "tokio", + "tracing", + "unicode-width 0.2.2", + "vt100", +] + +[[package]] +name = "brush-parser" +version = "0.4.0" +dependencies = [ + "anyhow", + "arbitrary", + "bon", + "cached", + "criterion", + "getrandom 0.4.3", + "indenter", + "insta", + "miette", + "peg", + "pretty_assertions", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", + "utf8-chars", + "uuid", + "winnow", + "yaml_serde", +] + +[[package]] +name = "brush-shell" +version = "0.4.0" +dependencies = [ + "anyhow", + "assert_cmd", + "assert_fs", + "brush-builtins", + "brush-core", + "brush-coreutils-builtins", + "brush-experimental-builtins", + "brush-interactive", + "brush-parser", + "brush-test-harness", + "brush-winnow-args-adapter", + "clap", + "color-print", + "colored", + "const_format", + "criterion", + "crossterm", + "descape", + "diff", + "etcetera", + "expectrl", + "getrandom 0.4.3", + "glob", + "human-panic", + "indent", + "indexmap", + "junit-report", + "nix 0.31.3", + "os-release", + "predicates", + "pretty_assertions", + "regex", + "schemars", + "serde", + "serde_json", + "strip-ansi-escapes", + "tempfile", + "test-with", + "thiserror 2.0.21", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", + "version-compare", + "walkdir", + "which 8.0.6", + "winnow-args", + "yaml_serde", +] + +[[package]] +name = "brush-test-harness" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "assert_fs", + "cfg_aliases", + "clap", + "colored", + "descape", + "diff", + "expectrl", + "glob", + "indent", + "insta", + "junit-report", + "nix 0.31.3", + "os-release", + "regex", + "serde", + "strip-ansi-escapes", + "tokio", + "version-compare", + "walkdir", + "yaml_serde", +] + +[[package]] +name = "brush-winnow-args-adapter" +version = "0.1.0" +dependencies = [ + "brush-core", + "winnow-args", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -695,6 +1295,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -717,6 +1323,39 @@ dependencies = [ "serde", ] +[[package]] +name = "cached" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0df7748fe2f601e376916ab19e7bfc2c74461b8abe3bce2ce20036ad8de38f" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.16.1", + "parking_lot", + "thiserror 2.0.21", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e734c52502e6cf54dce2ba07108906b04b8fe57f4f5e3ef7d58267b4abf060" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26cf465651fa6ad902a2d327ba60c3a6bc61c6a2f4ad70d091cf20dfda0074ef" + [[package]] name = "calendrical_calculations" version = "0.2.4" @@ -736,7 +1375,7 @@ dependencies = [ "cap-primitives", "cap-std", "io-lifetimes 3.0.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -751,9 +1390,9 @@ dependencies = [ "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix 1.1.5", + "rustix 1.1.6", "rustix-linux-procfs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "winx", ] @@ -766,7 +1405,7 @@ dependencies = [ "cap-primitives", "io-extras", "io-lifetimes 3.0.1", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -789,6 +1428,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -825,7 +1470,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -875,14 +1520,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "check_elevation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bd5a44e782b910d9336320487a74e7a8256741c1485332608d014d3c2864e7" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "chrono" version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -891,6 +1549,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -933,6 +1618,16 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size 0.4.4", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", ] [[package]] @@ -953,6 +1648,34 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "cmake" version = "0.1.58" @@ -977,12 +1700,117 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "codesnake" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2205f7f6d3de68ecf4c291c789b3edf07b6569268abd0188819086f71ae42225" + +[[package]] +name = "codspeed" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7c21807b937b8e535f2745cd83c525fc250524021cd662b9ba4d3e399bb504" +dependencies = [ + "anyhow", + "cc", + "colored", + "getrandom 0.4.3", + "glob", + "nix 0.31.3", + "serde", + "serde_json", + "statrs", +] + +[[package]] +name = "codspeed-divan-compat" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7e48c4e555e60b894144b14df1e893a7317b9f4000b1492ace3041479b12cc" +dependencies = [ + "clap", + "codspeed", + "codspeed-divan-compat-macros", + "codspeed-divan-compat-walltime", + "regex", +] + +[[package]] +name = "codspeed-divan-compat-macros" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb62a7a8d4cc6b79b18dcbac670b3202f4c8c901ca8ea701ab42190f0b14799" +dependencies = [ + "divan-macros", + "itertools 0.14.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "codspeed-divan-compat-walltime" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c4e77a97571b6214b976cee7e2a564215d0a5bee9520a3cdab86106d0b31a3" +dependencies = [ + "cfg-if", + "clap", + "codspeed", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "color-print" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4" +dependencies = [ + "color-print-proc-macro", +] + +[[package]] +name = "color-print-proc-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" +dependencies = [ + "nom 7.1.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "command-fds" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" +dependencies = [ + "nix 0.31.3", + "thiserror 2.0.21", +] + [[package]] name = "compact_str" version = "0.7.1" @@ -1014,6 +1842,34 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + +[[package]] +name = "conpty" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72b06487a0d4683349ad74d62e87ad639b09667082b3c495c5b6bab7d84b3da" +dependencies = [ + "windows 0.44.0", +] + +[[package]] +name = "console" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.60.2", +] + [[package]] name = "const-hex" version = "1.19.3" @@ -1038,6 +1894,62 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cooked-waker" version = "5.0.0" @@ -1162,7 +2074,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.33.0", "hashbrown 0.17.1", "libm", "log", @@ -1172,7 +2084,7 @@ dependencies = [ "rustc-hash 2.1.3", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasmtime-internal-core", @@ -1254,6 +2166,33 @@ version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc173168f291ff3b95b5d0769d6a05b1eaef5e6d74c2600affab8396b4e7b420" +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest 0.10.7", + "rustversion", + "spin 0.10.1", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1263,6 +2202,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1303,6 +2278,35 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "filedescriptor", + "mio", + "parking_lot", + "rustix 1.1.6", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1391,6 +2395,17 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "ctutils" version = "0.4.8" @@ -1401,6 +2416,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "curl" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "clap", + "url", + "wasi 0.14.4+wasi-0.2.4", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1428,12 +2453,116 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + [[package]] name = "data-url" version = "0.3.1" @@ -1450,6 +2579,46 @@ dependencies = [ "uuid", ] +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.1", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "deno_ast" version = "0.53.3" @@ -1488,7 +2657,7 @@ dependencies = [ "swc_visit", "text_lines", "thiserror 2.0.21", - "unicode-width", + "unicode-width 0.2.2", "url", ] @@ -1567,9 +2736,9 @@ dependencies = [ "rsa", "serde", "serde_bytes", - "sha1", - "sha2", - "sha3", + "sha1 0.10.7", + "sha2 0.10.9", + "sha3 0.10.9", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -1768,7 +2937,7 @@ dependencies = [ "quinn", "rustls-tokio-stream", "serde", - "sha2", + "sha2 0.10.9", "socket2 0.5.10", "thiserror 2.0.21", "tokio", @@ -1788,8 +2957,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum", - "strum_macros", + "strum 0.27.2", + "strum_macros 0.27.2", "syn 2.0.119", "syn-match", "thiserror 2.0.21", @@ -2027,7 +3196,7 @@ checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" dependencies = [ "asn1-rs 0.5.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2041,7 +3210,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs 0.6.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2053,6 +3222,17 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive-io" version = "0.4.1" @@ -2072,6 +3252,75 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "descape" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700357d9fb22b5232a85e34a413c1a76ae649f532c31107e402dc84e9c03bfb2" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "diffutils" +version = "0.6.0" +dependencies = [ + "assert_cmd", + "chrono", + "codspeed-divan-compat", + "diff", + "itoa", + "predicates", + "pretty_assertions", + "rand 0.10.3", + "regex", + "same-file", + "tempfile", + "unicode-width 0.2.2", +] + [[package]] name = "digest" version = "0.10.7" @@ -2137,6 +3386,27 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.3", + "windows-sys 0.60.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -2144,10 +3414,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -2159,6 +3441,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "divan-macros" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dlopen2" version = "0.7.0" @@ -2182,6 +3475,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dprint-swc-ext" version = "0.26.2" @@ -2219,6 +3530,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -2252,7 +3569,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2344,6 +3661,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2353,6 +3676,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -2365,6 +3703,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -2378,15 +3725,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "error_reporter" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31ae425815400e5ed474178a7a22e275a9687086a12ca63ec793ff292d8fdae8" +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "expectrl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0df3044b2257277f573d1e40912ebd4d5891f7588640e3125cbbbb24ff7be3" +dependencies = [ + "conpty", + "nix 0.26.4", + "ptyprocess", + "regex", +] + +[[package]] +name = "faccess" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5" +dependencies = [ + "bitflags 1.3.2", + "libc", + "winapi", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2399,24 +3785,100 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastbloom" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher 1.0.4", ] +[[package]] +name = "faster-hex" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" +dependencies = [ + "autocfg", + "defmt 0.3.100", + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fd-find" +version = "10.5.1" +dependencies = [ + "aho-corasick", + "anyhow", + "argmax", + "clap", + "clap_complete", + "crossbeam-channel", + "ctrlc", + "diff", + "etcetera", + "faccess", + "filetime", + "globset", + "ignore", + "jiff", + "libc", + "lscolors", + "memchr", + "nix 0.31.3", + "normpath", + "nu-ansi-term", + "regex", + "regex-syntax", + "tempfile", + "test-case", + "tikv-jemallocator", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.6", + "windows-sys 0.59.0", +] + [[package]] name = "ff" version = "0.13.1" @@ -2443,11 +3905,20 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", @@ -2466,7 +3937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8bdb6454f692ca2a2b45cd554c6828c639d7f9c968cf83a678899ec4443a280" dependencies = [ "rand_core 0.6.4", - "sha3", + "sha3 0.10.9", "subtle", "zeroize", ] @@ -2478,11 +3949,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" dependencies = [ "rand_core 0.6.4", - "sha2", - "sha3", + "sha2 0.10.9", + "sha3 0.10.9", "zeroize", ] +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -2500,12 +3982,72 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.3", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.21", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2559,7 +4101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.59.0", ] @@ -2633,6 +4175,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -2656,6 +4211,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -2687,6 +4248,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + [[package]] name = "generic-array" version = "0.14.7" @@ -2707,7 +4274,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2747,6 +4314,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gimli" version = "0.33.0" @@ -2759,12 +4332,1054 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "git" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "clap", + "gix", + "gix-error", + "gix-pack", + "gix-transport", + "gix-zlib", + "sausage-ssh", + "serde", + "serde_json", + "tar", + "url", + "wasi 0.14.4+wasi-0.2.4", +] + +[[package]] +name = "gix" +version = "0.90.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92b20c1560511a9014b16e29db2f3c6f0580d4fe289b70f41131277b065a3b0" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-merge", + "gix-negotiate", + "gix-note", + "gix-object", + "gix-odb", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-quote", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-transport", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", +] + +[[package]] +name = "gix-actor" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78243d3371019288a40a75ba1a58de2815c32591fc87b9f7cb01918bce364ca8" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9947d93a94d41ddde66ac655674269121cbebaf6cdee30d8d0e7dbe85edb4c" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422cd23418fd80e9e965595323975d72abd6364a66ff8f0daade5c105a7f35d2" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6a775aba54f0d65698126cc4214b6bf8ddb208312e4ef4240dfe34b0697b87" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df63215f103299169e239dfe76f5cfa4a3d6d4adf69a01774085e3e0423cba34" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-quote", + "gix-trace", +] + +[[package]] +name = "gix-commitgraph" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b53b700d2a506cf98075099df1fcc0558265cbb47fd86bc6d03c231e4bd72a" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e11ccf82e9630c4e7befbae07cbe90e79de61dc73a34448c2dec70a13da9ebf" +dependencies = [ + "bstr", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-parallel", + "gix-path", + "gix-ref", + "gix-sec", + "gix-trace", + "gix-utils", + "smallvec", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdada8bd9c97dc6522505c08226d9c88f0a1286f2c06b9e1bf51c3bf7ad7b2b" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "libc", +] + +[[package]] +name = "gix-credentials" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8487f709886267337ee39231a5e68f3c6a4e28fd59e056fc9e66e9638941c633" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-error", + "gix-path", + "gix-prompt", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "percent-encoding", +] + +[[package]] +name = "gix-date" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c538190012dbb536df54734ec38cc1109270c73f2f76585c011dd5b0c9f087c" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c794988335792f85c3ca79c385c950b3a921a71ba3df361cb1f9e5b6bd4a6e4" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", +] + +[[package]] +name = "gix-dir" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af939f21c65d4f15457c72d08c874d454893185c51a3596e3c2bd3d9c567618d" +dependencies = [ + "bstr", + "gix-discover", + "gix-error", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", +] + +[[package]] +name = "gix-discover" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0875f4e5408713fcf92265973cc38bf9dc5a93384ce218beba0572b7785a03" +dependencies = [ + "bstr", + "dunce", + "gix-error", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", +] + +[[package]] +name = "gix-error" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87ff4b3f3daf01234d38d176b0ec00e92de514cc0e3fb8a8898c5cb7ccd22bb" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-filter" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8df8d50fda4684c6ec366f75e4082ab6fc3d437d3deec30ff88462710b68d17" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-error", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", +] + +[[package]] +name = "gix-fs" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808f23d897ddfe847d9b7206bda947901885be623bdf96b226a2dccf87f018c7" +dependencies = [ + "bstr", + "gix-error", + "gix-parallel", + "gix-path", + "gix-utils", + "libc", + "walkdir", +] + +[[package]] +name = "gix-glob" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a12789fa68a150293ad8834d267b31044240532b5ccf2734d228fe985cba2be" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-fs", + "gix-path", + "gix-trace", + "gix-utils", +] + +[[package]] +name = "gix-hash" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4802ffb87e051795822bfdaa8ab9075b23e316684f7054e6ce4a246b2b76f4e" +dependencies = [ + "faster-hex", + "gix-error", + "gix-utils", + "sha1dc", +] + +[[package]] +name = "gix-hashtable" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f66822aee11ccb28d6a2e63b587254e578fb52a138bd5806b58d8560125592b" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "968f12fe42896873504f46127cd5bf0f9a34fc1479a781f43cf94b5d8d760a15" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7518b930f6aeb75e871d32ce873c2ae46e35cef19b5a0b039d4337f651d897cd" +dependencies = [ + "bstr", + "hashbrown 0.16.1", +] + +[[package]] +name = "gix-index" +version = "0.58.0" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix 1.1.6", + "smallvec", +] + +[[package]] +name = "gix-lock" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64783de116c477c5ea4a7f3b56e18ab363dabfdae2724291ea0587b642cadb3d" +dependencies = [ + "gix-error", + "gix-fs", + "gix-tempfile", + "gix-utils", +] + +[[package]] +name = "gix-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d02e51a52e15255757c06a6873529ae01205711aa9e8f9a1d46b556f1a14a96e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-merge" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf6d8a0724d529adb7f4b1db8cb8408b829298a1e5f7ea167be4e36d113059b" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "nonempty", +] + +[[package]] +name = "gix-negotiate" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "148bee10394df68d44c245e05b4c0747edf19144405c6c36a9eee29db9bb250d" +dependencies = [ + "bitflags 2.13.2", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-note" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8473688f2262f4364f521b39ce2a5dbe94494f327fc19c945318d7aff580952e" +dependencies = [ + "gix-error", + "gix-hash", + "gix-object", +] + +[[package]] +name = "gix-object" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beaf9086dd00b9d2a177ca3ae83210ce926cee7c0faa112e053851221cd8f94d" +dependencies = [ + "bstr", + "gix-actor", + "gix-command", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", +] + +[[package]] +name = "gix-odb" +version = "0.87.0" +dependencies = [ + "arc-swap", + "crc32fast", + "gix-error", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-parallel", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-pack" +version = "0.77.0" +dependencies = [ + "clru", + "crc32fast", + "gix-chunk", + "gix-diff", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-parallel", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-utils", + "gix-zlib", + "memmap2", + "parking_lot", + "smallvec", +] + +[[package]] +name = "gix-packetline" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ebb59ef7aff9d435946a5e5c3d9cfb673be7665839fd72961c08c733a934fb7" +dependencies = [ + "bstr", + "faster-hex", + "gix-error", + "gix-trace", +] + +[[package]] +name = "gix-parallel" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e7e10f8343e05f6ad8651f97dffa1caca1531bb274bfc8e3050e80e3ed5946" +dependencies = [ + "crossbeam-channel", + "gix-trace", + "once_cell", + "parking_lot", +] + +[[package]] +name = "gix-path" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83976e926a485e6fb4b8d9a0d7a30c53c1f607b81b5d310f06f92fd8385bf16a" +dependencies = [ + "bstr", + "gix-error", + "gix-trace", + "gix-validate", +] + +[[package]] +name = "gix-pathspec" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490aa03a11e0cbb998a0a593b75e27ea2e30df4ebd8f84f60e0641b9b72270e8" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-error", + "gix-glob", + "gix-path", +] + +[[package]] +name = "gix-prompt" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9650eedebb85e55577831db217403333416573356de722a040415c416f1cec" +dependencies = [ + "gix-command", + "gix-config-value", + "gix-error", + "parking_lot", + "rustix 1.1.6", +] + +[[package]] +name = "gix-protocol" +version = "0.68.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83677ebae6a1a09dc07f2435b5ebe68470dc51afb74052135e98fd7c11f85ba9" +dependencies = [ + "bstr", + "gix-credentials", + "gix-date", + "gix-error", + "gix-hash", + "gix-lock", + "gix-macros", + "gix-negotiate", + "gix-object", + "gix-ref", + "gix-refspec", + "gix-revwalk", + "gix-shallow", + "gix-trace", + "gix-transport", + "gix-utils", + "nonempty", +] + +[[package]] +name = "gix-quote" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2413e65271303e9b674ec4785d469df4c3c36e888dbd165e3035de0405c6d9d1" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.69.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68686e4697b9cd15d1b152ef51df870513210ed22938eacc13b3672b76035f13" +dependencies = [ + "gix-actor", + "gix-error", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-parallel", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", +] + +[[package]] +name = "gix-refspec" +version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2569309da6fdc230973cf5b846508154c0e813fda60f2b2da768cc5655ed312c" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-validate", + "smallvec", +] + +[[package]] +name = "gix-revision" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241465fc73530f41d0a1c18320c5e291365b7c2c2a3dfd07afe2a5d6265085c8" +dependencies = [ + "bitflags 2.13.2", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b46edeacd25d9d3131941ae6bc91002bf7ba10e130393b7e9bc23d528625447" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", +] + +[[package]] +name = "gix-sec" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9598d1f35baec7038c2d278bfcc18f1d8951f41dcc3f51ab6175661dfe5a6137" +dependencies = [ + "bitflags 2.13.2", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d805949578724b5164c90ec6d19567832ccaa5ba8a3fe26f7befc52007e2407" +dependencies = [ + "bstr", + "gix-error", + "gix-hash", + "gix-lock", + "nonempty", +] + +[[package]] +name = "gix-status" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b00c359278210e3cc0803d057db1d61c5d6694c21ed9ad0b8f8f862157247a" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-parallel", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", +] + +[[package]] +name = "gix-submodule" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d558a1307b6f3de2a92bd91d0570c53952d4f2329e6860813007508c8ed413dc" +dependencies = [ + "bstr", + "gix-config", + "gix-error", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", +] + +[[package]] +name = "gix-tempfile" +version = "26.0.0" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-trace" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c477c029518d3f235c26db21ff7cccc1fa13c0c31b05c005a55b7affd4ffbd81" + +[[package]] +name = "gix-transport" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcd9cdfd8745aa38f926a4a380c13d76b200d7338de57fc492982cd5a6652ba" +dependencies = [ + "base64 0.22.1", + "bstr", + "gix-command", + "gix-credentials", + "gix-error", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "gix-utils", + "parking_lot", +] + +[[package]] +name = "gix-traverse" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00171e00e91508300d0361072c4eb110ec0f1322d8b95ed36887231702a38ebc" +dependencies = [ + "bitflags 2.13.2", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", +] + +[[package]] +name = "gix-url" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def12e1016b872f1094442882f627905c59bd73480435291a4958cf373a8f28d" +dependencies = [ + "bstr", + "gix-error", + "gix-path", + "gix-utils", + "percent-encoding", +] + +[[package]] +name = "gix-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a9446060447e9ec58f5d333b98bed53389dfa10de5d8dd46fc76d01dfc2954" +dependencies = [ + "bstr", + "bytes", + "fastrand", + "getrandom 0.4.3", + "gix-error", + "prodash", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b617c17754bf37ee7a9b48e33ffb806abecbbed779ba415199ac1480acf699" +dependencies = [ + "bstr", + "gix-error", +] + +[[package]] +name = "gix-worktree" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe92aec0a25cf073394492a6ee2aa4d27632acde91a653d34de0e4da6afb485d" +dependencies = [ + "bstr", + "gix-attributes", + "gix-error", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe5d65793848ffb596f6c5596fd120b132038736525628227e2388ca454f64d" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-parallel", + "gix-path", + "gix-trace", + "gix-traverse", + "gix-utils", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c5b29808570149d86f417184605111ede9dda5aa06015e9ea6da07f0c3ca41e" +dependencies = [ + "gix-error", + "zlib-rs", +] + [[package]] name = "glob" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.13.2", + "ignore", + "walkdir", +] + +[[package]] +name = "grep" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "309217bc53e2c691c314389c7fa91f9cd1a998cda19e25544ea47d94103880c3" +dependencies = [ + "grep-cli", + "grep-matcher", + "grep-pcre2", + "grep-printer", + "grep-regex", + "grep-searcher", +] + +[[package]] +name = "grep-cli" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" +dependencies = [ + "bstr", + "globset", + "libc", + "log", + "termcolor", + "winapi-util", +] + +[[package]] +name = "grep-matcher" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-pcre2" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b752bb6f57dd3716e55b63a33ca05b5825c3d66cd427468874a5564af6ca365c" +dependencies = [ + "grep-matcher", + "log", + "pcre2", +] + +[[package]] +name = "grep-printer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd76035e87871f51c1ee5b793e32122b3ccf9c692662d9622ef1686ff5321acb" +dependencies = [ + "bstr", + "grep-matcher", + "grep-searcher", + "log", + "serde", + "serde_json", + "termcolor", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + [[package]] name = "group" version = "0.13.0" @@ -2836,6 +5451,15 @@ dependencies = [ "elliptic-curve 0.14.3", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2846,13 +5470,24 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -2861,7 +5496,9 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -2875,12 +5512,28 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2941,6 +5594,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hifijson" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d" + [[package]] name = "hkdf" version = "0.12.4" @@ -2968,6 +5627,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "hstr" version = "3.0.6" @@ -3027,6 +5697,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "human-panic" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2815d0c49773c6e0a9753960b3d0e50b822e48e08c77bcb4780be8474c9cc3d" +dependencies = [ + "anstream", + "anstyle", + "backtrace", + "serde", + "serde_derive", + "sysinfo", + "toml", + "uuid", +] + [[package]] name = "hybrid-array" version = "0.4.15" @@ -3096,6 +5782,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.56.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_calendar" version = "2.4.0" @@ -3108,6 +5818,7 @@ dependencies = [ "icu_locale_core", "icu_locale_fallback", "icu_provider", + "serde", "tinystr", "zerovec", ] @@ -3119,10 +5830,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514da54e79efa36b5976232d5b3d1d8ee1b47b19ab658f1b1ebe39221696c2d8" [[package]] -name = "icu_collections" -version = "2.3.0" +name = "icu_collator" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "24cb0205c419b2036da3f9082964905d6849b3f2130dd4a517940c45899a544b" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183f47e821a327661c1ebe152f61d5b6c2000010d228cadbbf226e0e2b8c2634" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -3132,6 +5868,73 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_datetime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c338aa7ebff734470e216a4196fa84b123a366cc786c445741b6f5647412c3c" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_calendar", + "icu_datetime_data", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_pattern", + "icu_plurals", + "icu_provider", + "icu_time", + "potential_utf", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_datetime_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bddb757c2df1635453c4e8cb2d4ba7ad026e904893f29699315826c0951bbd9" + +[[package]] +name = "icu_decimal" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" + +[[package]] +name = "icu_locale" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe8fa57bcddc15cb921582ee5259c5b77da39f946f188ca5213fad24d9241236" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.4.0" @@ -3146,6 +5949,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64014dac577a11d8d2cee48517c73c1984bd72ab11031e934269bd3cefb2ad9" + [[package]] name = "icu_locale_fallback" version = "2.4.0" @@ -3168,29 +5977,64 @@ checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -3203,9 +6047,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" @@ -3224,12 +6068,42 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_time" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d45ee651f21433b1c6352311f2ad9b5253c26be0e25d9a0869cb8247b8cfde" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar", + "icu_locale_core", + "icu_provider", + "icu_time_data", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_time_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d7d711f44237fa9300406ab6e521148594056862343370b5df265a0d15c088" + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3267,6 +6141,34 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" +[[package]] +name = "ignore" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indent" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.14.2" @@ -3279,6 +6181,50 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3289,6 +6235,43 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd" +dependencies = [ + "console", + "globset", + "once_cell", + "pest", + "pest_derive", + "ron", + "serde", + "similar", + "tempfile", + "walkdir", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.25" @@ -3308,6 +6291,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "io-lifetimes" version = "2.0.4" @@ -3329,7 +6323,7 @@ dependencies = [ "socket2 0.6.5", "widestring", "windows-registry", - "windows-result", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -3351,6 +6345,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.3", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3375,6 +6386,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3407,6 +6427,130 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4754a1731458af0443709c9667302793d3f63cc90ec4ba4c8259f2b9515478e5" +[[package]] +name = "jaq" +version = "2.3.0" +dependencies = [ + "codesnake", + "dirs", + "env_logger", + "hifijson", + "is-terminal", + "jaq-core", + "jaq-json", + "jaq-std", + "log", + "memmap2", + "mimalloc", + "rustyline", + "tempfile", + "unicode-width 0.1.14", + "yansi", +] + +[[package]] +name = "jaq-core" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77526a72eb79412c29fd141767a6549bbfcb1cb40e00556fe16532d5e878e098" +dependencies = [ + "dyn-clone", + "once_cell", + "typed-arena", +] + +[[package]] +name = "jaq-json" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01dbdbd07b076e8403abac68ce7744d93e2ecd953bbc44bf77bf00e1e81172bc" +dependencies = [ + "foldhash 0.1.5", + "hifijson", + "indexmap", + "jaq-core", + "jaq-std", +] + +[[package]] +name = "jaq-std" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c264fe397c981705976c71f1bfe020382b9eda52ae950e57fe885e147bdd67d" +dependencies = [ + "aho-corasick", + "base64 0.22.1", + "chrono", + "jaq-core", + "libm", + "log", + "regex-lite", + "urlencoding", +] + +[[package]] +name = "jiff" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26699905cc795a23e3949585cc7ba3ee8ade090872a27e4316304a777297894a" +dependencies = [ + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe6854081f5ac0a3046a9b223729ccd7a20b1e0e8277e44dc0660eedf67dfe2" +dependencies = [ + "log", +] + +[[package]] +name = "jiff-icu" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fe268c5e5b3c96397a29f209fe662576f25bf537584274b1aaf201477ad499" +dependencies = [ + "icu_calendar", + "icu_time", + "jiff", +] + +[[package]] +name = "jiff-static" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4ff49b2887190c760b86207b55cdcaabbdecaf7a0660d68aa2ed6f652abf0a" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -3419,9 +6563,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -3438,6 +6582,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "junit-parser" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81427d0aab8e54e19351be5c088187e964ddccad4938b48204eb1e9932d972d6" +dependencies = [ + "quick-xml 0.42.0", + "thiserror 2.0.21", +] + +[[package]] +name = "junit-report" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb201fc2964416320139f702d888df6a39685e648f68e3c910a77cfda99ce44d" +dependencies = [ + "derive-getters", + "quick-xml 0.41.0", + "time", +] + [[package]] name = "keccak" version = "0.1.6" @@ -3457,13 +6622,48 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ - "spin", + "spin 0.9.9", ] [[package]] @@ -3478,12 +6678,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3500,6 +6716,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.25" @@ -3520,6 +6745,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3538,6 +6775,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "lock_api" version = "0.4.14" @@ -3553,12 +6796,31 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lscolors" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60e266dfb1426eb2d24792602e041131fdc0236bb7007abc0e589acafd60929" +dependencies = [ + "aho-corasick", + "nu-ansi-term", +] + [[package]] name = "mach2" version = "0.6.0" @@ -3571,6 +6833,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3583,7 +6855,25 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", +] + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", ] [[package]] @@ -3595,6 +6885,45 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size 0.4.4", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3627,7 +6956,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", - "wasi", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3654,6 +6984,39 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -3664,7 +7027,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3677,7 +7040,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3690,6 +7053,88 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "normalize-path" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0769f77950d14beb4fe8e0707b7c7cc6ea39110e9289e2c2346a7bb4ee5af74" + +[[package]] +name = "normpath" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" +dependencies = [ + "bitflags 2.13.2", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3698,6 +7143,7 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", "serde", ] @@ -3753,6 +7199,33 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-prime" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b285c575532a33ef6fdd3a57640d0b1c70e6ca48644d6df7bbd4b7a0cfbbb12d" +dependencies = [ + "bitvec", + "either", + "lru", + "num-bigint", + "num-integer", + "num-modular", + "num-traits", + "rand 0.8.5", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3769,10 +7242,72 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.3", "libc", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -3840,6 +7375,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3979,6 +7520,40 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "os-release" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f29ae2f71b53ec19cc23385f8e4f3d90975195aa3d09171ba3bef7159bec27" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "os_display" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "os_pipe" version = "1.2.1" @@ -3995,6 +7570,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f" + [[package]] name = "p256" version = "0.13.2" @@ -4004,7 +7585,7 @@ dependencies = [ "ecdsa", "elliptic-curve 0.13.8", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4016,7 +7597,7 @@ dependencies = [ "ecdsa", "elliptic-curve 0.13.8", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4030,7 +7611,17 @@ dependencies = [ "elliptic-curve 0.13.8", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -4042,6 +7633,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -4065,6 +7662,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_datetime" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503fd4c8b0685e5eb32a8276533611f63e3b44768e9e8724e73f6852c2542f82" +dependencies = [ + "jiff", + "num-traits", + "winnow", +] + [[package]] name = "password-hash" version = "0.6.1" @@ -4101,6 +7709,55 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "pcre2" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e970b0fcce0c7ee6ef662744ff711f21ccd6f11b7cf03cd187a80e89797fc67" +dependencies = [ + "libc", + "log", + "pcre2-sys", +] + +[[package]] +name = "pcre2-sys" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b9073c1a2549bd409bf4a32c94d903bb1a09bf845bc306ae148897fa0760a4" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "peg" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4116,6 +7773,51 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.21", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2 0.10.9", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -4157,6 +7859,36 @@ dependencies = [ "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -4177,6 +7909,16 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.11.3" @@ -4221,6 +7963,15 @@ dependencies = [ "siphasher 1.0.4", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher 1.0.4", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4247,6 +7998,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + [[package]] name = "pkcs1" version = "0.7.5" @@ -4284,6 +8041,44 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "platform-info" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa5c6ef5da99ec17edc36267860ddb03504a916e0cc74c0c4dd1c5cdb19637f8" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -4313,6 +8108,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4351,6 +8155,46 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -4388,6 +8232,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" +dependencies = [ + "bitflags 2.13.2", + "chrono", + "flate2", + "procfs-core", + "rustix 1.1.6", +] + +[[package]] +name = "procfs-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" +dependencies = [ + "bitflags 2.13.2", + "chrono", + "hex", +] + +[[package]] +name = "prodash" +version = "31.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf52d87428247c5aa3760ac8e7d8b13ec7b3579b955a8c3f311668b6860acba5" +dependencies = [ + "parking_lot", +] + [[package]] name = "proptest" version = "1.12.0" @@ -4441,6 +8318,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ptyprocess" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101be273c0b1680d7056afddbaa88f02b6e9f2dc161165c30bee9914b6025a79" +dependencies = [ + "nix 0.26.4", +] + [[package]] name = "pulley-interpreter" version = "49.0.3" @@ -4470,6 +8356,24 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -4483,7 +8387,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.21", "tokio", "tracing", @@ -4523,9 +8427,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4555,6 +8459,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -4607,6 +8521,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -4689,6 +8613,53 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "reedline" +version = "0.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc0282eb061a7d039435f10075f22988e0dd7468901ee247a803021786afc662" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum 0.28.0", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "regalloc2" version = "0.15.2" @@ -4727,12 +8698,24 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "resb" version = "0.1.2" @@ -4773,6 +8756,64 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripgrep" +version = "15.2.0" +dependencies = [ + "anyhow", + "bstr", + "grep", + "ignore", + "lexopt", + "log", + "serde", + "serde_derive", + "serde_json", + "termcolor", + "textwrap", + "tikv-jemallocator", + "walkdir", +] + +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "ron" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf" +dependencies = [ + "bitflags 2.13.2", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rpds" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e025feb26210bc196b908e72deb063b1b4000754304341cbc168a1e72c857ebc" +dependencies = [ + "archery", + "serde", + "smallvec", +] + [[package]] name = "rsa" version = "0.9.10" @@ -4787,7 +8828,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -4804,6 +8845,35 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "rstest" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rusqlite" version = "0.38.0" @@ -4819,6 +8889,16 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -4852,7 +8932,21 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes 1.0.11", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", ] [[package]] @@ -4870,15 +8964,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4888,7 +8982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -4982,6 +9076,26 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustyline" +version = "13.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "clipboard-win", + "fd-lock", + "libc", + "log", + "memchr", + "nix 0.27.1", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "winapi", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5045,7 +9159,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.10.9", "ssh-key", "sys_traits", "tempfile", @@ -5066,6 +9180,203 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sausage-archive-tools" +version = "0.1.0" +dependencies = [ + "flate2", + "tar", +] + +[[package]] +name = "sausage-chat" +version = "0.1.0" +dependencies = [ + "clap", + "wasi 0.14.4+wasi-0.2.4", +] + +[[package]] +name = "sausage-coreutils" +version = "0.1.0" +dependencies = [ + "rustix 1.1.6", + "uu_arch", + "uu_b2sum", + "uu_base32", + "uu_base64", + "uu_basename", + "uu_basenc", + "uu_cat", + "uu_cksum", + "uu_comm", + "uu_cp", + "uu_csplit", + "uu_cut", + "uu_date", + "uu_dd", + "uu_dir", + "uu_dircolors", + "uu_dirname", + "uu_echo", + "uu_expand", + "uu_expr", + "uu_factor", + "uu_false", + "uu_fmt", + "uu_fold", + "uu_head", + "uu_hostid", + "uu_join", + "uu_link", + "uu_ln", + "uu_ls", + "uu_md5sum", + "uu_mkdir", + "uu_mktemp", + "uu_mv", + "uu_nice", + "uu_nl", + "uu_nproc", + "uu_numfmt", + "uu_od", + "uu_paste", + "uu_pathchk", + "uu_pr", + "uu_printenv", + "uu_printf", + "uu_ptx", + "uu_pwd", + "uu_readlink", + "uu_realpath", + "uu_rm", + "uu_rmdir", + "uu_seq", + "uu_sha1sum", + "uu_sha224sum", + "uu_sha256sum", + "uu_sha384sum", + "uu_sha512sum", + "uu_shred", + "uu_shuf", + "uu_sleep", + "uu_sort", + "uu_split", + "uu_sum", + "uu_tail 0.12.0", + "uu_tee", + "uu_test", + "uu_touch", + "uu_tr", + "uu_true", + "uu_truncate", + "uu_tsort", + "uu_tty", + "uu_uname", + "uu_unexpand", + "uu_uniq", + "uu_unlink", + "uu_vdir", + "uu_wc", + "uu_yes", + "uucore", +] + +[[package]] +name = "sausage-cwd-probe" +version = "0.1.0" + +[[package]] +name = "sausage-exec-tools" +version = "0.1.0" +dependencies = [ + "sausage-spawn", +] + +[[package]] +name = "sausage-findutils" +version = "0.1.0" +dependencies = [ + "globset", + "sausage-spawn", + "shlex 1.3.0", + "walkdir", +] + +[[package]] +name = "sausage-fs-tools" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "sausage-spawn", +] + +[[package]] +name = "sausage-grep" +version = "0.1.0" +dependencies = [ + "globset", + "grep-matcher", + "grep-regex", + "lexopt", + "walkdir", +] + +[[package]] +name = "sausage-init" +version = "0.1.0" +dependencies = [ + "sausage-spawn", + "serde", + "serde_json", + "shlex 1.3.0", + "wasi 0.14.4+wasi-0.2.4", +] + +[[package]] +name = "sausage-procps" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "sausage-sed" +version = "0.1.0" +dependencies = [ + "sed", +] + +[[package]] +name = "sausage-spawn" +version = "0.1.0" +dependencies = [ + "wasi 0.14.4+wasi-0.2.4", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "sausage-ssh" +version = "0.1.0" +dependencies = [ + "aes", + "base64 0.22.1", + "ctr", + "futures-lite", + "hmac", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha1 0.10.7", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-key", + "url", + "wasi 0.14.4+wasi-0.2.4", + "x25519-dalek", +] + [[package]] name = "sausage-vfs" version = "0.1.0" @@ -5079,7 +9390,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "wasip2", @@ -5097,6 +9408,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5161,6 +9497,36 @@ dependencies = [ "libc", ] +[[package]] +name = "sed" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b908cd347ea83fee7c9fdb621e4dccdd6612d9962da2be03a846be2e6d83f758" +dependencies = [ + "clap", + "clap_complete", + "clap_mangen", + "fancy-regex 0.18.0", + "memchr", + "memmap2", + "phf 0.14.0", + "phf_codegen 0.14.0", + "predicates", + "regex", + "rustix 1.1.6", + "tempfile", + "terminal_size 0.4.4", + "textwrap", + "unicode-width 0.2.2", + "uucore", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -5227,13 +9593,24 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_json" version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ - "foldhash", + "foldhash 0.2.0", "indexmap", "itoa", "memchr", @@ -5309,6 +9686,23 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha1dc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" + [[package]] name = "sha2" version = "0.10.9" @@ -5320,6 +9714,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -5330,6 +9735,17 @@ dependencies = [ "keccak 0.1.6", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", +] + [[package]] name = "shake" version = "0.1.0" @@ -5341,6 +9757,15 @@ dependencies = [ "sponge-cursor", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5363,6 +9788,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5399,6 +9835,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -5417,6 +9859,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sm3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da6a89ba31723d185fd7413b98c576a575f356d9b84729d8ecb6ead60000a5b6" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "smallvec" version = "1.16.3" @@ -5437,6 +9888,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.5.10" @@ -5454,7 +9911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5481,6 +9938,12 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -5519,6 +9982,14 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh" +version = "0.1.0" +dependencies = [ + "clap", + "sausage-ssh", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -5544,7 +10015,7 @@ checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", "pem-rfc7468", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5562,7 +10033,7 @@ dependencies = [ "rand_core 0.6.4", "rsa", "sec1 0.7.3", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "ssh-cipher", "ssh-encoding", @@ -5586,7 +10057,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5595,6 +10066,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "strck" version = "1.0.0" @@ -5621,6 +10102,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72abeda133c49d7bddece6c154728f83eec8172380c80ab7096da9487e20d27c" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte 0.14.1", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5633,7 +10123,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5648,12 +10147,45 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "swc_allocator" version = "4.0.1" @@ -5700,7 +10232,7 @@ dependencies = [ "swc_sourcemap", "swc_visit", "tracing", - "unicode-width", + "unicode-width 0.2.2", "url", ] @@ -5920,7 +10452,7 @@ dependencies = [ "once_cell", "rustc-hash 2.1.3", "serde", - "sha1", + "sha1 0.10.7", "string_enum", "swc_atoms", "swc_common", @@ -6140,6 +10672,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -6152,6 +10698,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -6165,10 +10721,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", + "rustix 1.1.6", + "windows-sys 0.60.2", ] [[package]] @@ -6214,6 +10770,100 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" +dependencies = [ + "rustix 0.37.28", + "windows-sys 0.48.0", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.6", + "windows-sys 0.60.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b8e1f65819001927b8cdcbb5560f2428212f2e5ab5684e3c6815a4d80e72db" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "test-case-macros" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d6165a14df1598b7290a8c546df402940d3cfbe82d15c0fd35de176771693" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "test-case-core", +] + +[[package]] +name = "test-with" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c5885e41a979ecda466cfed636b80467de49e1727fc9755d221a61be95240f1" +dependencies = [ + "test-with-derive", + "which 8.0.6", +] + +[[package]] +name = "test-with-derive" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7801350f687fb6747e957c82cc7c57c1f7a798eaebc95f6c2ff1bbeb0b9c1c37" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 3.0.9", + "which 8.0.6", +] + [[package]] name = "text_lines" version = "0.6.0" @@ -6223,6 +10873,18 @@ dependencies = [ "serde", ] +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "smawk", + "terminal_size 0.2.6", + "unicode-linebreak", + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6263,6 +10925,35 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" version = "0.3.55" @@ -6337,6 +11028,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -6540,6 +11241,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -6558,18 +11285,79 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "undo" +version = "0.1.0" +dependencies = [ + "clap", + "curl", + "serde_json", + "url", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-id-start" version = "1.5.0" @@ -6582,6 +11370,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.26" @@ -6591,6 +11385,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -6603,6 +11409,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" @@ -6644,6 +11456,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.4.2" @@ -6656,6 +11474,21 @@ dependencies = [ "url", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-chars" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" +dependencies = [ + "arrayvec", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6668,6 +11501,1234 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uu_arch" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce13fa692b9a935828053cf02cceed52a4bfe68ac6a7f0273fc98d94a62a1cc" +dependencies = [ + "clap", + "fluent", + "platform-info", + "uucore", +] + +[[package]] +name = "uu_b2sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa4061f010d64b18f046f6aba14cac3d2dfe4a824816e5d1d30d554c7001c6e" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_base32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4472f433014202b99debd2907cda4650aafbb6881b8a5dca139b255dceed892" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_base64" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6b72f857b343f12c7f4be39917343a00d214a8250eaf49509cf9df6f6c8ddc" +dependencies = [ + "clap", + "fluent", + "uu_base32", + "uucore", +] + +[[package]] +name = "uu_basename" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29c8c798465d8001a90b6231554f8e00e9c4cf781a9f7d0b96224dcaac77ecd" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_basenc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c9841519e45b7dd994f5d8d40dc31164759e1476f52935f5d5fca8bd2fd7b79" +dependencies = [ + "clap", + "fluent", + "uu_base32", + "uucore", +] + +[[package]] +name = "uu_cat" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75143ac91809c365ca76bb141ea2a7a35ffdf821046f52e14bd83b710bb5ce34" +dependencies = [ + "clap", + "fluent", + "memchr", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_checksum_common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475c8f9e5873fb5fc87ff796e07b39b309f64a553232172242014d2208bfe8d" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_cksum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db6b4b87b7d6c92add8ba1a4cfae7355734bc9afbf74d6904e1bc6b05ffcef5" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_comm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce0974ee091b6dce8b4db465da4869c80dd1208cd38e9ab9f567c03929a25283" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_cp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4c15f0ba298a490b66abac0fefc3a44ff1b8004c38cf140d27c027f1f80784" +dependencies = [ + "clap", + "filetime", + "fluent", + "indicatif", + "libc", + "nix 0.31.3", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "walkdir", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_csplit" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55aa4ee843fea3f6ec8ab55c69c39f5758e52f1f00c8b5a6f941001b66d11acb" +dependencies = [ + "clap", + "fluent", + "regex", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_cut" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2087dececd3be61b5671c0978605e0fa9ecbe707294137896efdb2848094326" +dependencies = [ + "bstr", + "clap", + "fluent", + "memchr", + "uucore", +] + +[[package]] +name = "uu_date" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "949f0a6c6449604f71c42915ac5ad8ffa87644d403c028e97023bb775483d789" +dependencies = [ + "clap", + "fluent", + "icu_calendar", + "icu_locale", + "jiff", + "jiff-icu", + "jiff-tzdb", + "libc", + "parse_datetime", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_dd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82610ff943844c3ba8be998f3e964cc75a23cedd42be4956a70bd05ec1d0238c" +dependencies = [ + "clap", + "fluent", + "gcd", + "libc", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_df" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b4991a0996888e1c64872f2fd2eea978102418e0576a58c2e4a0e8c0e716e" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "thiserror 2.0.21", + "unicode-width 0.2.2", + "uucore", +] + +[[package]] +name = "uu_dir" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53753588e40c6e17b817b7dd012bd1d68ca372cf35a50d8dbef8441bd1b27625" +dependencies = [ + "clap", + "uu_ls", + "uucore", +] + +[[package]] +name = "uu_dircolors" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a029149ed7941dffc2e25c2ab56747873e1d1a01b5c8b5bfe7a15c56dc950665" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_dirname" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2affe4ee8747234885830bd8269d747bab7e78d730c0e360920663e5c72fe75" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_du" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae0fca6efbb42021688f47ce7c1ef88059452c6b73b744c0a99304a38973363" +dependencies = [ + "clap", + "fluent", + "glob", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_echo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f9afb484c165b55a3fee0d17e18921865dc2914feccd5eaed9298810e3e446" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_env" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63d245c1d0f7c48c076bc820491a32020e4a8bcd31e3dcbc35513be279b7ea" +dependencies = [ + "clap", + "fluent", + "nix 0.31.3", + "rust-ini", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_expand" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb12c2abb138ad08d01557ab6fdf2d201e06a0f88122d7d5a283fe2d8273151c" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_expr" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601663e752b83657be04ec8c54838310a516dd66d30affc36f02f3cc71f26199" +dependencies = [ + "clap", + "fancy-regex 0.19.2", + "fluent", + "num-bigint", + "num-traits", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_factor" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debab9d4be53ba727176e9ecd9bc669b5ec801549544fb51beffc69247fc27f4" +dependencies = [ + "clap", + "fluent", + "memchr", + "num-bigint", + "num-prime", + "uucore", +] + +[[package]] +name = "uu_false" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dee4cf0e44a93aceab88533e274fa3fac69ed9608a6be564066f298483187b9" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_fmt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9180eae586d5947c9c85915dfeb14bfdef2316c143c1b713b85a3dd630a3be77" +dependencies = [ + "clap", + "fluent", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_fold" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28755578b0b98318371e055fcc70b94c9198bacfce0d125d3049ab1c2d2beb2" +dependencies = [ + "clap", + "fluent", + "unicode-width 0.2.2", + "uucore", +] + +[[package]] +name = "uu_head" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d8bd166467c73ad000c303a0d8c3c3fa03160000bb4493cfd40b8f7816fb95" +dependencies = [ + "clap", + "fluent", + "memchr", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_hostid" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea4a9be6ed493989e4f9f33b04c0e22c0eb1220634229f2c4b2673b5bf7a4a2" +dependencies = [ + "clap", + "fluent", + "hostname", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_hostname" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c160a28e366d2c1b63b75ea837ebc9be5d3e3901f1a1bb2d7ea44ec9c92d40b3" +dependencies = [ + "clap", + "fluent", + "hostname", + "uucore", +] + +[[package]] +name = "uu_join" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864d33141e4b8684a6af365202f8ac0dc73a2f08ea26a3e2929de66ffbe5dcb2" +dependencies = [ + "clap", + "fluent", + "memchr", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_link" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50af1219ea0db13447ef438d3ab47ad1bbe9823286076b58149b8c06ef9d43d6" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_ln" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90a942fbe008800998bdde259ae54c73b61febce9f7a35b7cf734bdeae40d3f6" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_ls" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8f956231b30edd22f1c0ef803d6928da0d7cb5f07add01a4d15e5b2d4a2540" +dependencies = [ + "ansi-width", + "clap", + "fluent", + "glob", + "hostname", + "lscolors", + "rustc-hash 2.1.3", + "terminal_size 0.4.4", + "thiserror 2.0.21", + "uucore", + "uutils_term_grid", +] + +[[package]] +name = "uu_md5sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18aab2c28bf8a1d596a08ee9ad94ab9f56d1df7acae43bb8cadec09e09f7b4e4" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_mkdir" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665e031110e8de62ebe483eb50b21c19f57c6f02c571e3c57cfb6cf344209370" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_mktemp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71146f15c55f02214362b2357630e0b8078e249657b234b52f25469dbbead79b" +dependencies = [ + "clap", + "fluent", + "rand 0.10.3", + "tempfile", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_more" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf56076f34c0d91d2821986c5393d31d57542f944f93c466530b62dd8909c134" +dependencies = [ + "clap", + "crossterm", + "fluent", + "uucore", +] + +[[package]] +name = "uu_mv" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5108b8ea076d2d45f2aa4e546c0fa2cecd25fb2f5b44aa56a95c15d59c9da7c8" +dependencies = [ + "clap", + "fluent", + "indicatif", + "libc", + "nix 0.31.3", + "rustc-hash 2.1.3", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_nice" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fea55de8d55f50231286a9be4c84eaacc9eadf8b8d1d0f031c483c5f1cc047" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_nl" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab04808b361b764c96380882db6e593d12c7ae5d62582fcee0ca670ba356349" +dependencies = [ + "clap", + "fluent", + "itoa", + "regex", + "uucore", +] + +[[package]] +name = "uu_nproc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aeceedaa99805ec023954728d321fa85229df46943af2a9cb0bc60dedb3a7f7" +dependencies = [ + "clap", + "fluent", + "libc", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_numfmt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c14711615381c8311fa70a69e8a96175748ea97a8c0972be7efb5fe9323c58b1" +dependencies = [ + "clap", + "fluent", + "memchr", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_od" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f163305dfac8638f14e110666bd632f12e99c637382bbcbeeee63c771476c79e" +dependencies = [ + "clap", + "fluent", + "half", + "libc", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_paste" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edddacb62a1b7058ec9f33974cb4dbf677171b83d710e153764e6e5995fffaeb" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_pathchk" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9bc47993de7229717a577c7c4fe1f3919d8acc6d1dd5cb216a8a7eeebb8222" +dependencies = [ + "clap", + "fluent", + "libc", + "uucore", +] + +[[package]] +name = "uu_pr" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86126634ad64f323a8e2d487fc7fa3b92b1b251a08eef631c113ae57d112678" +dependencies = [ + "clap", + "fluent", + "itertools 0.15.0", + "memchr", + "regex", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_printenv" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7f1090fe0d0ac3e8b7a79cd48830f2f93577329d2ee739d84bb0d893889ccc" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_printf" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad2cacf36fe7936f5bda5a4c259f6961e3e5f77cf04db26e1922ea7dffad195" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_ptx" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a31e18a9e4519f59376c0fe3667bdaaf2ce3cb0fb100256e3968fe4ac9296ea" +dependencies = [ + "clap", + "fluent", + "regex", + "rustc-hash 2.1.3", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_pwd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905aa087f76dcfbd22cf9549f56d2713be97f80d68a7638334c8afc91ae528f9" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_readlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4390796d1f70cfd2393ce3d6c8ba855352d5057e57f6527bca80a8638dd37d07" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_realpath" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf967086871590e1d92d32e402b152045cd2c09156cbd877c97b6a7d82aabf0" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_rm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16cd2b5f4e7647a10a316c001b804e688a80aba77685578cdf11bdf4b31f780" +dependencies = [ + "clap", + "fluent", + "indicatif", + "libc", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_rmdir" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5df882bcefc05b9e4d915a51ffe8475b3b2baabb07d04c9708c5b8b6fbf5d" +dependencies = [ + "clap", + "fluent", + "libc", + "uucore", +] + +[[package]] +name = "uu_seq" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad149ba156667c2ce8d1d87411e091632db5608d5c8777fbe1a134856e89eae0" +dependencies = [ + "bigdecimal", + "clap", + "fluent", + "num-bigint", + "num-traits", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_sha1sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08cf227c5ea9b50b7995ffae6103e575d8ce15744a37a5849adf319573eaa86b" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_sha224sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961716db2eefd48b450fd837881b110568de7761acbfe4a92abe49b3f5899253" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_sha256sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c065f8ea49bf27afcdbb9d5c28d94c5690955e89f2b9076c88e03faa3c84d183" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_sha384sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5a28caaed1f09d6980287a77d522f8192378c36c98a23293085b3a4c597d22" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_sha512sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d820ee09c795721569ef812b53a4701cc44c3466c16adc0261d4aff3f1bd99d" +dependencies = [ + "clap", + "fluent", + "uu_checksum_common", + "uucore", +] + +[[package]] +name = "uu_shred" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9a6aa2c9854085579e44f5c5955f23c2d2a0630b8b633585ca61248ed68a6c" +dependencies = [ + "clap", + "fluent", + "libc", + "rand 0.10.3", + "uucore", +] + +[[package]] +name = "uu_shuf" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05bbc6c1d00e2c28003b1d4d3aebd28faeb5eb7028458704ce07de80dff6a64a" +dependencies = [ + "clap", + "fluent", + "itoa", + "rand 0.10.3", + "rand_chacha 0.10.0", + "rustc-hash 2.1.3", + "sha3 0.12.0", + "uucore", +] + +[[package]] +name = "uu_sleep" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "015cf50857143f9a946e9194960cdba7da0b4a17cdfae0bec1128b84e6cdb552" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_sort" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45084032c01910cb5d0515acbcea8ee6377d07e46e6fc84afde79fbd9dc6c6f8" +dependencies = [ + "bigdecimal", + "clap", + "ctrlc", + "fluent", + "foldhash 0.2.0", + "itertools 0.15.0", + "libc", + "memchr", + "rand 0.10.3", + "rayon", + "rustix 1.1.6", + "self_cell", + "tempfile", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_split" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8b146825e117f1b5574250f5a60590a4e02885a425aad65fcea557da301e3e8" +dependencies = [ + "clap", + "fluent", + "memchr", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_sum" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4053123109cfaa474967027a3ab74e0c1e37177db80f278960c80c2adf3a58cf" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_sync" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b7e40a16222e557795012311211f161390babaf7fe6bb945748b6456a6aaf39" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_tac" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ec0679c585e00258cd3c70fca14ad1d5220c02c7fdb56aa513003f6fdd73ba" +dependencies = [ + "clap", + "fluent", + "libc", + "memchr", + "memmap2", + "regex", + "tempfile", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_tail" +version = "0.12.0" +dependencies = [ + "clap", + "fluent", + "libc", + "memchr", + "notify", + "rstest", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_tail" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ff141dd3fab47b991b2063fb8c1c9ff5ca4f9f23f5bcba75c691a247e1aca4" +dependencies = [ + "clap", + "fluent", + "libc", + "memchr", + "notify", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_tee" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e59ca8e741e2e8e095898ec01795674a907ee0991883bd5f6313fd688b9d66" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uu_test" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9007d3a30901a0dba2e28c017aba9edb2aa26c919946ae84c770446067e98304" +dependencies = [ + "clap", + "fluent", + "libc", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_touch" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1784987c9ae4beeb3153c173e5866e1907752180289f6d219cc6c1223ae7a5f0" +dependencies = [ + "clap", + "filetime", + "fluent", + "jiff", + "libc", + "parse_datetime", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_tr" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc8f860fa347eaf1505a3da8ac292559980c1103b5978ba4f1142aff0755b00" +dependencies = [ + "bytecount", + "clap", + "fluent", + "nom 8.0.0", + "uucore", +] + +[[package]] +name = "uu_true" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7a03ae2661faa57115019fe88ce91b6d8143c6b76930460b06a949de8f8aaa" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_truncate" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991acfea078be5657c18bab731d77d083bb7facf5157c2567e53f2a6d0b9727" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_tsort" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f1a4fbe486b87a15e06e8b00230b6c47eaa15d095bc3fc4cc122d116aae62e0" +dependencies = [ + "clap", + "fluent", + "hashbrown 0.17.1", + "memchr", + "rustc-hash 2.1.3", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_tty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e27119b92f4ac319f68ad9cb9902fb392283c15aaee01e3841b21b37a5011da" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_uname" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53566dcd5e385c71e1fb277f1ebd5db0d3f04795fa07b58ccb038a440e28c906" +dependencies = [ + "clap", + "fluent", + "platform-info", + "uucore", +] + +[[package]] +name = "uu_unexpand" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4bd141cb78bd0e7611ff4b3040901d3bafbe45621bdd2372484848e5a2b1f34" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "thiserror 2.0.21", + "uucore", +] + +[[package]] +name = "uu_uniq" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c59563ff6419f674e2cd5b50cef9f3a0f0e73a06291dd635c006fc253ad268" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_unlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ca22bbb2d23bdd136ec8a6260c12c2550f9ae44c679323bee246bf460494b" +dependencies = [ + "clap", + "fluent", + "uucore", +] + +[[package]] +name = "uu_vdir" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec245d6aaaae422622318b1a2e98e05709d346e5d7f2bf98f4f49329fb6f4418" +dependencies = [ + "clap", + "uu_ls", + "uucore", +] + +[[package]] +name = "uu_wc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45457ac8613955d62a62e2cea12aab7023a4d015c24a30b4719ee9138d8598d" +dependencies = [ + "bytecount", + "clap", + "fluent", + "libc", + "rustix 1.1.6", + "thiserror 2.0.21", + "unicode-width 0.2.2", + "uucore", +] + +[[package]] +name = "uu_whoami" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1267f81972136719195877c21cefd2fb1bc821c8fb692d16f8b0ce4b717108c" +dependencies = [ + "clap", + "fluent", + "rustix 1.1.6", + "uucore", + "windows-sys 0.61.2", +] + +[[package]] +name = "uu_yes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a00721c606207353591d7d5a4fe6c89d94073decccd061392f3f0dea1c37af4" +dependencies = [ + "clap", + "fluent", + "itertools 0.15.0", + "rustix 1.1.6", + "uucore", +] + +[[package]] +name = "uucore" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24a5910ebbf2c0baf486a9125705f8e026a8f7741a3ea8cf5eca3c9257c9bdf" +dependencies = [ + "ariadne", + "base64-simd", + "bigdecimal", + "blake2b_simd", + "blake3", + "bstr", + "clap", + "crc-fast", + "data-encoding", + "data-encoding-macro", + "digest 0.11.3", + "dunce", + "fluent", + "fluent-syntax", + "glob", + "hex", + "icu_calendar", + "icu_collator", + "icu_datetime", + "icu_decimal", + "icu_locale", + "icu_provider", + "itertools 0.15.0", + "jiff", + "jiff-icu", + "libc", + "md-5", + "memchr", + "nix 0.31.3", + "num-traits", + "os_display", + "procfs", + "rustc-hash 2.1.3", + "rustix 1.1.6", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "shake", + "sm3", + "thiserror 2.0.21", + "unic-langid", + "unicode-width 0.2.2", + "unit-prefix", + "uucore_procs", + "walkdir", + "wild", + "windows-sys 0.61.2", + "xattr", + "z85", +] + +[[package]] +name = "uucore_procs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50aa1f3bba55168888ae6f1f2714ce58897d8020d742d21f474f01e86468fedd" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "uuid" version = "1.29.0" @@ -6680,6 +12741,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uutils_term_grid" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70bdc0434ef5f30856fbe0908a7d96568f8b212a28c20b16d44851f753e465f" +dependencies = [ + "ansi-width", +] + +[[package]] +name = "uzers" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" +dependencies = [ + "libc", + "log", +] + [[package]] name = "v8" version = "150.4.0" @@ -6712,12 +12792,24 @@ dependencies = [ "timezone_provider", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -6740,6 +12832,36 @@ dependencies = [ "nix 0.31.3", ] +[[package]] +name = "vt100" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb05cdd19ae8707bf6a0a269972c07e6358103a7079faec749dec318a5b3dc23" +dependencies = [ + "itoa", + "unicode-width 0.2.2", + "vte 0.15.0", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -6749,6 +12871,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.2" @@ -6764,23 +12896,40 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.4+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +dependencies = [ + "wit-bindgen 0.45.1", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.4+wasi-0.2.4", ] [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -6788,9 +12937,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6798,9 +12947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", @@ -6811,9 +12960,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] @@ -6835,6 +12984,16 @@ dependencies = [ "wat", ] +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser 0.239.0", +] + [[package]] name = "wasm-encoder" version = "0.258.3" @@ -6855,6 +13014,18 @@ dependencies = [ "wasmparser 0.262.0", ] +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.239.0", + "wasmparser 0.239.0", +] + [[package]] name = "wasm-metadata" version = "0.258.3" @@ -6877,6 +13048,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wasmparser" version = "0.258.3" @@ -6918,7 +13101,7 @@ version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" dependencies = [ - "addr2line", + "addr2line 0.26.1", "async-trait", "bitflags 2.13.2", "bumpalo", @@ -6927,7 +13110,7 @@ dependencies = [ "encoding_rs", "futures", "fxprof-processed-profile", - "gimli", + "gimli 0.33.0", "ittapi", "libc", "log", @@ -6938,7 +13121,7 @@ dependencies = [ "postcard", "pulley-interpreter", "rayon", - "rustix 1.1.5", + "rustix 1.1.6", "semver", "serde", "serde_derive", @@ -6962,7 +13145,7 @@ dependencies = [ "wasmtime-internal-versioned-export-macros", "wat", "windows-sys 0.61.2", - "wit-parser", + "wit-parser 0.258.3", ] [[package]] @@ -6976,7 +13159,7 @@ dependencies = [ "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", + "gimli 0.33.0", "hashbrown 0.17.1", "indexmap", "log", @@ -6986,7 +13169,7 @@ dependencies = [ "semver", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasm-encoder 0.258.3", @@ -7006,10 +13189,10 @@ dependencies = [ "directories-next", "log", "postcard", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "toml", "wasmtime-environ", "windows-sys 0.61.2", @@ -7028,7 +13211,7 @@ dependencies = [ "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser", + "wit-parser 0.258.3", ] [[package]] @@ -7060,7 +13243,7 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.33.0", "itertools 0.14.0", "log", "object 0.40.0", @@ -7083,7 +13266,7 @@ checksum = "3eb3f21bc32b5382ea95f83ce9a5f2f62b68f1b25a37d0802a41a65236eae5ae" dependencies = [ "cc", "libc", - "rustix 1.1.5", + "rustix 1.1.6", "wasmtime-environ", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", @@ -7097,7 +13280,7 @@ checksum = "e786f9925126e0d2e466c5308e572e8b6226f4047596228a3d63d99528a78949" dependencies = [ "cc", "object 0.40.0", - "rustix 1.1.5", + "rustix 1.1.6", "wasmtime-internal-versioned-export-macros", ] @@ -7145,8 +13328,8 @@ dependencies = [ "bitflags 2.13.2", "heck", "indexmap", - "wit-component", - "wit-parser", + "wit-component 0.258.3", + "wit-parser 0.258.3", ] [[package]] @@ -7161,7 +13344,7 @@ dependencies = [ "cap-primitives", "futures", "rand 0.10.3", - "rustix 1.1.5", + "rustix 1.1.6", "rustix-linux-procfs", "thiserror 2.0.21", "tokio", @@ -7230,7 +13413,7 @@ dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width", + "unicode-width 0.2.2", "wasm-encoder 0.262.0", ] @@ -7243,6 +13426,16 @@ dependencies = [ "wast 262.0.0", ] +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -7292,7 +13485,7 @@ dependencies = [ "bitflags 1.3.2", "futures", "hex", - "nom", + "nom 7.1.3", "num-derive", "num-traits", "openssl", @@ -7336,7 +13529,7 @@ dependencies = [ "base64urlsafedata", "der-parser 9.0.0", "hex", - "nom", + "nom 7.1.3", "openssl", "openssl-sys", "rand 0.9.5", @@ -7392,6 +13585,22 @@ name = "which" version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] [[package]] name = "widestring" @@ -7440,6 +13649,15 @@ dependencies = [ "wiggle-generate", ] +[[package]] +name = "wild" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" +dependencies = [ + "glob", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7462,7 +13680,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7471,12 +13689,132 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -7484,10 +13822,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -7506,6 +13853,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7542,6 +13898,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7575,6 +13961,27 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7587,6 +13994,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7599,6 +14018,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7623,6 +14054,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7635,6 +14078,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7647,6 +14102,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7659,6 +14126,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7680,6 +14159,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow-args" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d83d54311d2e07890c5d453596eba319c366065630d8800cb6ecaa47a271e3" +dependencies = [ + "terminal_size 0.4.4", + "winnow", + "winnow-args-derive", +] + +[[package]] +name = "winnow-args-derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f6c0e613c8d3c4719038e35c8103294838e1521833642933ddf0b44be708dc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "winnow-args-man" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c855eea9e65ded55649420c8b1991a93772ebb7c3d15b776b7c3fce7c8ccbee" +dependencies = [ + "roff", + "winnow-args-spec", +] + +[[package]] +name = "winnow-args-markdown" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e08a0b8f574b1bc6c4d73182779c5b157426ba3fe52a15f25341701aced5687" +dependencies = [ + "winnow-args-spec", +] + +[[package]] +name = "winnow-args-spec" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b340c310a126707d5c9af0aaf4921f8cb89224ac6c953dafc9ef9915c50c7b8a" +dependencies = [ + "toml", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -7696,6 +14225,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags 2.13.2", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7705,6 +14255,67 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.239.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.239.0", + "wit-bindgen-core", + "wit-component 0.239.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.239.0", + "wasm-metadata 0.239.0", + "wasmparser 0.239.0", + "wit-parser 0.239.0", +] + [[package]] name = "wit-component" version = "0.258.3" @@ -7719,9 +14330,27 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.258.3", - "wasm-metadata", + "wasm-metadata 0.258.3", "wasmparser 0.258.3", - "wit-parser", + "wit-parser 0.258.3", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.239.0", ] [[package]] @@ -7755,11 +14384,30 @@ dependencies = [ "wast 35.0.2", ] +[[package]] +name = "world-cargo" +version = "0.1.0" +dependencies = [ + "sausage-spawn", + "serde", + "serde_json", + "wasi 0.14.4+wasi-0.2.4", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "wyz" @@ -7792,7 +14440,7 @@ dependencies = [ "data-encoding", "der-parser 8.2.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.6.1", "rusticata-macros", "thiserror 1.0.69", @@ -7809,13 +14457,75 @@ dependencies = [ "data-encoding", "der-parser 9.0.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", "time", ] +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix 1.1.6", +] + +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "junit-parser", + "libc", + "serde", + "serde_json", + "tempfile", + "winnow-args", + "winnow-args-man", + "winnow-args-markdown", + "winnow-args-spec", + "xshell", +] + +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.4" @@ -7839,6 +14549,12 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "z85" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675" + [[package]] name = "zerocopy" version = "0.8.63" diff --git a/Cargo.toml b/Cargo.toml index 5c8454e..770ea15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,167 @@ [workspace] resolver = "3" -members = ["host", "vfs"] +members = [ + "host", + "vfs", + "userland/git", + "userland/coreutils", + "userland/procps", + "userland/cwd-init/probe", + "userland/ripgrep", + "userland/diffutils", + "userland/jaq", + "userland/sed", + "userland/grep", + "userland/fd", + "userland/spawn", + "userland/findutils", + "userland/exec-tools", + "userland/ssh-client", + "userland/ssh", + "userland/curl", + "userland/init", + "userland/box", + "userland/archive-tools", + "userland/fs-tools", + "userland/undo", + "userland/cargo", + "userland/chat", + "userland/brush/source/brush", + "userland/brush/source/brush-shell", + "userland/brush/source/brush-parser", + "userland/brush/source/brush-core", + "userland/brush/source/brush-clap-adapter", + "userland/brush/source/brush-winnow-args-adapter", + "userland/brush/source/brush-builtins", + "userland/brush/source/brush-coreutils-builtins", + "userland/brush/source/brush-experimental-builtins", + "userland/brush/source/brush-interactive", + "userland/brush/source/brush-test-harness", + "userland/brush/source/brush-bundled-tests", + "userland/brush/source/fuzz", + "userland/brush/source/xtask", +] default-members = ["host"] -# Reference spikes and the separate wasm32-wasip2 userland workspace. -exclude = ["spikes", "userland"] +exclude = ["spikes"] + +[workspace.package] +authors = ["reuben olinsky"] +categories = [ + "command-line-utilities", + "development-tools", +] +edition = "2024" +keywords = [ + "cli", + "shell", + "sh", + "bash", + "script", +] +license = "MIT" +readme = "userland/brush/source/README.md" +repository = "https://github.com/reubeno/brush" +rust-version = "1.88.0" + +[workspace.lints.rust] +unnameable_types = "deny" +unsafe_op_in_unsafe_fn = "deny" +unused_attributes = "deny" +unused_lifetimes = "deny" +unused_macro_rules = "deny" + +[workspace.lints.rust.warnings] +level = "deny" + +[workspace.lints.rust.future_incompatible] +level = "deny" + +[workspace.lints.rust.missing_docs] +level = "deny" + +[workspace.lints.rust.nonstandard_style] +level = "deny" + +[workspace.lints.rust.rust_2018_idioms] +level = "deny" +priority = -1 + +[workspace.lints.rust.unknown_lints] +level = "allow" +priority = -100 + +[workspace.lints.rustdoc.all] +level = "deny" +priority = -1 + +[workspace.lints.clippy] +expect_used = "deny" +format_push_string = "deny" +multiple_unsafe_ops_per_block = "deny" +panic = "deny" +panic_in_result_fn = "deny" +string_lit_chars_any = "deny" +string_slice = "deny" +tests_outside_test_module = "deny" +todo = "deny" +unwrap_in_result = "deny" +unwrap_used = "deny" +undocumented_unsafe_blocks = "deny" +bool_to_int_with_if = "allow" +cognitive_complexity = "allow" +collapsible_else_if = "allow" +collapsible_if = "allow" +if_not_else = "allow" +if_same_then_else = "allow" +match_same_arms = "allow" +missing_errors_doc = "allow" +must_use_candidate = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_closure_for_method_calls = "allow" +redundant_else = "allow" +redundant_pub_crate = "allow" +result_large_err = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" + +[workspace.lints.clippy.all] +level = "deny" +priority = -1 + +[workspace.lints.clippy.pedantic] +level = "deny" +priority = -1 + +[workspace.lints.clippy.cargo] +level = "deny" +priority = -1 + +[workspace.lints.clippy.nursery] +level = "deny" +priority = -1 + +[workspace.lints.clippy.perf] +level = "deny" +priority = -1 -# Line tables only: backtraces keep file and line, binaries stay small (each test -# binary links V8, Wasmtime and deno_core). Set `debug = true` when you need a debugger. [profile.dev] debug = "line-tables-only" -# Wasmtime compiles components with Cranelift. Optimize dependencies in dev -# builds, so that tests and debug runs start guests quickly. [profile.dev.package."*"] opt-level = 2 + +[patch.crates-io.gix-tempfile] +path = "userland/git/vendor/gix-tempfile" + +[patch.crates-io.gix-pack] +path = "userland/git/vendor/gix-pack" + +[patch.crates-io.gix-index] +path = "userland/git/vendor/gix-index" + +[patch.crates-io.gix-odb] +path = "userland/git/vendor/gix-odb" + +[patch.crates-io.filetime] +path = "userland/git/vendor/filetime" diff --git a/README.md b/README.md index e7b8302..e9f809c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Services come from `/config/services.yaml` in the world. - `host/`: the Rust host, process table, and durable harness. Its checked-in JavaScript bundles need no npm during a Nix build. - `vfs/`: the shared virtual filesystem and WASI adapters. -- `userland/`: the default tools, in a separate `wasm32-wasip2` workspace. `bundle.nu` builds and assembles them. +- `userland/`: the default tools and patched Brush source, in the root Cargo workspace. `bundle.nu` builds them for `wasm32-wasip2`. - `spikes/`: reference prototypes, not production dependencies. The design and decisions live in `~/research/sausage-v3/PLAN.md`. @@ -34,7 +34,7 @@ The design and decisions live in `~/research/sausage-v3/PLAN.md`. ```sh nix develop -cargo test --workspace +cargo test -p sausage -p sausage-vfs nu userland/bundle.nu nix flake check ``` diff --git a/flake.nix b/flake.nix index 0c7e5dd..af1996a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,5 @@ { description = "Sausage: a world of WASI programs and durable agents"; - inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; @@ -12,51 +11,28 @@ inputs.nixpkgs.follows = "nixpkgs"; }; }; - - outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, kache, ... }: + outputs = { nixpkgs, flake-utils, rust-overlay, crane, kache, ... }: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: let - pkgs = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; + pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; rust = pkgs.rust-bin.stable.latest.default.override { targets = [ "wasm32-wasip2" ]; extensions = [ "rust-src" ]; }; + craneLib = (crane.mkLib pkgs).overrideToolchain rust; + src = pkgs.nix-gitignore.gitignoreSource [] ./.; mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ]; - hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] - pkgs.stdenv.hostPlatform.rust.rustcTarget); + hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.hostPlatform.rust.rustcTarget); moldVar = "CARGO_TARGET_${hostTarget}_RUSTFLAGS"; - moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { - ${moldVar} = "-C link-arg=-fuse-ld=mold"; - }; + moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; }; builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools pkgs.bash pkgs.coreutils ]; runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ builderTools ++ mold); - # A WASI clang with wasi-libc, so that build scripts (the `cc` crate) - # can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code - # that needs sockets does not build. wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc; wasiCcEnv = { CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc"; AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar"; }; - craneLib = (crane.mkLib pkgs).overrideToolchain rust; - # Preserve the embedded JavaScript and WIT alongside Cargo's sources. - hostSrc = pkgs.lib.cleanSourceWith { - src = ./.; - filter = path: type: - let relative = pkgs.lib.removePrefix (toString ./. + "/") path; - in !((pkgs.lib.hasPrefix "userland/" relative && !(pkgs.lib.hasPrefix "userland/cwd-init" relative)) - || pkgs.lib.hasPrefix "spikes/" relative) - && (craneLib.filterCargoSources path type - || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".witx" path - || pkgs.lib.hasSuffix ".js" path || pkgs.lib.hasSuffix ".html" path - || pkgs.lib.hasSuffix ".wasm" path || pkgs.lib.hasSuffix ".nu" path - || pkgs.lib.hasSuffix ".c" path || pkgs.lib.hasSuffix ".patch" path); - }; v8Archive = pkgs.fetchurl { - # deno_core enables rusty_v8's simdutf feature. url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_simdutf_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz"; hash = { x86_64-linux = "sha256-9IdiyhDR8fxgWkQcWuQw7Izh6egPFNePvELLh4wwtHY="; @@ -65,92 +41,69 @@ aarch64-darwin = "sha256-Wu/9jVoMG3msHXCvg9WxkJllX9nGRaeU3EPxAfd5g4w="; }.${system}; }; + # rusty_v8's documented archive hook prevents downloads inside the Nix sandbox. + v8Env = { RUSTY_V8_ARCHIVE = v8Archive; }; + cwdInit = pkgs.runCommand "sausage-cwd-init" { nativeBuildInputs = [ rust pkgs.nushell pkgs.wasm-tools ]; } '' + cp -r ${./userland/cwd-init} cwd-init + chmod -R u+w cwd-init + nu cwd-init/build.nu + mkdir $out + cp cwd-init/init.o $out/ + ''; + linker = pkgs.writeScript "sausage-wasi-link.nu" ("#!${pkgs.nushell}/bin/nu\n" + builtins.readFile ./userland/cwd-init/link.nu); + members = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.members; + commands = builtins.filter (p: pkgs.lib.hasPrefix "userland/" p && !(pkgs.lib.hasPrefix "userland/brush/" p)) members; + commandArgs = pkgs.lib.concatMapStringsSep " " (p: "-p " + (builtins.fromTOML (builtins.readFile (./. + "/${p}/Cargo.toml"))).package.name) commands; common = { pname = "sausage"; version = "0.3.0"; - src = hostSrc; + inherit src; strictDeps = true; - RUSTY_V8_ARCHIVE = v8Archive; buildInputs = [ pkgs.openssl ]; - nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.python3 pkgs.perl ] ++ mold; - } // moldEnv; + nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.python3 pkgs.perl pkgs.nushell pkgs.wasm-tools pkgs.go ] ++ mold; + SAUSAGE_CWD_INIT = "${cwdInit}/init.o"; + CARGO_TARGET_WASM32_WASIP2_LINKER = linker; + CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = "-C link-arg=sausage-cwd-init"; + GOTOOLCHAIN = "local"; + } // v8Env // wasiCcEnv // moldEnv; cargoArtifacts = craneLib.buildDepsOnly (common // { + # Registry gix crates need the real APIs of the local WASI patches. + extraDummyScript = '' + chmod -R u+w $out/userland/git/vendor + cp -r ${./userland/git/vendor}/. $out/userland/git/vendor/ + ''; + buildPhaseCargoCommand = '' + cargoWithProfile build --locked -p sausage -p sausage-vfs + cargoWithProfile build --locked --target wasm32-wasip2 ${commandArgs} + cargoWithProfile build --locked --target wasm32-wasip2 -p brush-shell --no-default-features --features minimal,tokio/time + ''; doCheck = false; }); - brushSource = pkgs.fetchFromGitHub { - owner = "reubeno"; - repo = "brush"; - rev = "943553433ae04f5b3bd55d2cf207e39eb9ef2af7"; - hash = "sha256-a1eMkqx4nBqJ8R+h+rxtU35pAungcEnXoxwZnb1poH8="; - }; - # bundle.nu archives these sources, not the sandbox's development patches. - userlandSrc = pkgs.lib.cleanSource ./.; - revision = self.rev or self.dirtyRev or "unknown"; - userlandCommon = { + userland = craneLib.mkCargoDerivation (common // { + inherit cargoArtifacts; pname = "sausage-userland"; - version = "0.3.0"; - src = userlandSrc; - strictDeps = true; - cargoVendorDir = craneLib.vendorMultipleCargoDeps { - cargoLockList = [ ./userland/Cargo.lock ./userland/brush/Cargo.lock ]; - }; - nativeBuildInputs = [ pkgs.nushell pkgs.wasm-tools pkgs.git pkgs.pkg-config pkgs.go ] ++ mold; - GOTOOLCHAIN = "local"; - GIT_AUTHOR_NAME = "Sausage"; - GIT_AUTHOR_EMAIL = "sausage@localhost"; - GIT_COMMITTER_NAME = "Sausage"; - GIT_COMMITTER_EMAIL = "sausage@localhost"; - GIT_AUTHOR_DATE = "2000-01-01T00:00:00+0000"; - GIT_COMMITTER_DATE = "2000-01-01T00:00:00+0000"; - postPatch = '' - git init --quiet --initial-branch=main - git add host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore - git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}' - cp -r ${brushSource} userland/brush/source - chmod -R u+w userland/brush/source - # The fixed-output source fetch replaces the development checkout's Git check. - substituteInPlace userland/brush/build.nu --replace-fail \ - ${pkgs.lib.escapeShellArg "if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }"} \ - '# Revision verified by the fixed-output Nix fetch.' - patchShebangs userland/cwd-init/link.nu - ''; preBuild = '' export CARGO_TARGET_DIR="$PWD/target" export GOCACHE="$TMPDIR/go-cache" ''; - buildPhaseCargoCommand = '' - nu userland/bundle.nu - # bundle.nu sees the reconstructed repository's HEAD. Restore the flake revision. - git -C target/userland/bundle/src/sausage commit --amend --no-gpg-sign --quiet \ - -m 'Bundle source ${revision}' - ''; + buildPhaseCargoCommand = "nu userland/bundle.nu"; doCheck = false; - } // wasiCcEnv // moldEnv; - # Keep the real local patch crates: registry dependencies use their full APIs. - userlandArtifacts = craneLib.buildDepsOnly (userlandCommon // { - dummySrc = userlandSrc; - }); - userland = craneLib.mkCargoDerivation (userlandCommon // { - cargoArtifacts = userlandArtifacts; installPhaseCommand = '' mkdir -p $out cp -a target/userland/bundle/. $out/ test -f $out/bin/chat.wasm test -f $out/etc/systemd/system/chat.service - test -f $out/etc/systemd/system/multi-user.target.wants/chat.service ''; doInstallCargoArtifacts = false; - # The upstream browser fixture links to an optional, unbuilt artifact. dontCheckForBrokenSymlinks = true; }); sausageUnwrapped = craneLib.buildPackage (common // { inherit cargoArtifacts; - # Deno loads extension TypeScript from the vendored sources at runtime. doNotRemoveReferencesToVendorDir = true; cargoExtraArgs = "--locked -p sausage"; doCheck = false; }); - # A separate wrapper, so that crane's toolchain reference removal cannot rewrite its PATH. + # Keep the wrapper separate from crane's removal of toolchain references. sausage = pkgs.symlinkJoin { name = "sausage-${sausageUnwrapped.version}"; paths = [ sausageUnwrapped ]; @@ -167,32 +120,21 @@ in { packages.default = sausage; checks.package = sausage; - checks.tests = craneLib.mkCargoDerivation (common // wasiCcEnv // { + checks.tests = craneLib.mkCargoDerivation (common // { inherit cargoArtifacts; pnameSuffix = "-test"; nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.git ]; doCheck = true; buildPhaseCargoCommand = ""; - # Keep the same sandbox-compatible modules in the shared host test executable. checkPhaseCargoCommand = '' - cargoWithProfile test --locked --workspace --lib - cargoWithProfile test --locked -p sausage --test it -- \ - harness:: p1:: durable:: cred:: cred_ --skip undo_harness:: - cargoWithProfile test --locked -p sausage-vfs \ - --test layers --test regressions --test streams --test wasi + cargoWithProfile test --locked -p sausage -p sausage-vfs --lib + cargoWithProfile test --locked -p sausage --test it -- harness:: p1:: durable:: cred:: cred_ --skip undo_harness:: + cargoWithProfile test --locked -p sausage-vfs --test layers --test regressions --test streams --test wasi ''; preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" export CARGO_TARGET_DIR="$PWD/target" export RUST_BACKTRACE=1 - # Flake sources omit Git history. Map the recorded revision to independently - # staged source trees for P1's directory-hash comparison. - cp -r ${userlandSrc}/agents ${userlandSrc}/skills ${userlandSrc}/extensions . - git init --quiet - git add agents skills extensions - git -c user.name=Sausage -c user.email=sausage@localhost \ - commit --no-gpg-sign --quiet -m 'Flake source trees' - git update-ref refs/replace/${revision} HEAD mkdir -p target/userland ln -s ${userland} target/bundle ln -s ${userland} target/userland/bundle @@ -202,23 +144,18 @@ checks.shell = pkgs.runCommand "sausage-shell" { } '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" ${sausage}/bin/sausage sh "$TMPDIR/world" -c \ - 'ls /bin | wc -l; echo hi | tr a-z A-Z; git --version; rg --version' > output + 'set -e; echo hi | tr a-z A-Z; git --version; rg --version; awk "BEGIN {print 42}"; echo payload > /payload; tar -cf /test.tar /payload; tar -tf /test.tar; qjs -e "console.log(42)"; box --help; cargo --help; undo list' > output cat output grep -qx HI output grep -q 'git version' output grep -q ripgrep output - ${sausage}/bin/sausage sh "$TMPDIR/world" -c \ - 'set -e; awk "BEGIN {print 42}"; echo payload > /payload; tar -cf /test.tar /payload; tar -tf /test.tar; qjs -e "console.log(42)"; box --help; cargo --help; undo list' > tools - cat tools - grep -qx 42 tools - grep -q payload tools + grep -qx 42 output + grep -q payload output test -d "$TMPDIR/world/world/src/sausage/extensions" - test -d "$TMPDIR/world/world/src/sausage/agents" - test -d "$TMPDIR/world/world/src/sausage/skills" touch $out ''; apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; - devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { + devShells.default = pkgs.mkShell (v8Env // wasiCcEnv // moldEnv // { packages = [ rust pkgs.wasmtime pkgs.openssl pkgs.pkg-config ] ++ builderTools ++ mold; }); }); diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs index d84ff92..1d11577 100644 --- a/host/tests/it/common.rs +++ b/host/tests/it/common.rs @@ -1,4 +1,4 @@ -//! Shared fixture copies and builds. A mutex protects cwd-init and the brush checkout. +//! Shared fixture copies and builds. A mutex protects cwd-init and the shell build. use std::{ hash::{Hash, Hasher}, os::unix::{ffi::OsStrExt, fs::PermissionsExt}, @@ -150,7 +150,7 @@ pub fn build_git() -> &'static Path { "sausage-archive-tools", "--manifest-path" ]) - .arg(userland.join("Cargo.toml")) + .arg(userland.join("../Cargo.toml")) .args(["--target", "wasm32-wasip2", "--target-dir"]) .arg(&target) .args(["-j", "2", "--locked"]) diff --git a/host/tests/it/harness_shutdown.rs b/host/tests/it/harness_shutdown.rs index 7796d50..769126e 100644 --- a/host/tests/it/harness_shutdown.rs +++ b/host/tests/it/harness_shutdown.rs @@ -63,7 +63,7 @@ fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> { "sleep", "--manifest-path", ]) - .arg(source.join("userland/Cargo.toml")) + .arg(source.join("Cargo.toml")) .args(["--target", "wasm32-wasip2", "--target-dir"]) .arg(target) .args(["-j", "2"]) diff --git a/host/tests/it/init.rs b/host/tests/it/init.rs index be5772e..8c24673 100644 --- a/host/tests/it/init.rs +++ b/host/tests/it/init.rs @@ -15,7 +15,7 @@ fn guests() -> &'static Path { static BUILT: OnceLock = OnceLock::new(); BUILT.get_or_init(|| { let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()); - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/Cargo.toml"); + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../Cargo.toml"); assert!( std::process::Command::new("cargo") .args([ diff --git a/host/tests/packaged-builder.nu b/host/tests/packaged-builder.nu new file mode 100644 index 0000000..5f8fa8d --- /dev/null +++ b/host/tests/packaged-builder.nu @@ -0,0 +1,30 @@ +#!/usr/bin/env nu +# Verify the packaged host's toolchain and namespace builder outside the Nix sandbox. +def main [host: path = 'result/bin/sausage'] { + let host = ($host | path expand) + let temporary = (^mktemp -d -t sausage-packaged.XXXXXX | str trim) + let world = ($temporary | path join world) + print $'Fresh world: ($world)' + let initialized = (^$host init $world | complete) + print --no-newline $initialized.stdout + print --stderr --no-newline $initialized.stderr + if $initialized.exit_code != 0 { error make {msg: 'packaged host init failed'} } + # Cargo new is not available until T86. This dependency-free crate tests the same builder. + let script = r#'set -e +mkdir -p /src/packaged-probe/src +printf '[package]\nname = "packaged-probe"\nversion = "0.1.0"\nedition = "2024"\n' > /src/packaged-probe/Cargo.toml +printf 'fn main() { println!("packaged builder OK"); }\n' > /src/packaged-probe/src/main.rs +cd /src/packaged-probe +cargo build --release +./target/wasm32-wasip2/release/packaged-probe.wasm +'# + let result = (^$host sh $world -c $script | complete) + print --no-newline $result.stdout + print --stderr --no-newline $result.stderr + if $result.exit_code != 0 { error make {msg: 'packaged builder failed'} } + if not ($result.stdout | lines | any {|line| $line == 'packaged builder OK' }) { + error make {msg: 'compiled program did not produce its expected output'} + } + rm --recursive --force $temporary + print 'Packaged builder check passed.' +} diff --git a/userland/.cargo/config.toml b/userland/.cargo/config.toml deleted file mode 100644 index 8921ede..0000000 --- a/userland/.cargo/config.toml +++ /dev/null @@ -1,9 +0,0 @@ -[build] -target = "wasm32-wasip2" - -[env] -SAUSAGE_CWD_INIT = { value = "cwd-init/init.o", relative = true } - -[target.wasm32-wasip2] -linker = "cwd-init/link.nu" -rustflags = ["-C", "link-arg=sausage-cwd-init"] diff --git a/userland/Cargo.lock b/userland/Cargo.lock deleted file mode 100644 index cd32fac..0000000 --- a/userland/Cargo.lock +++ /dev/null @@ -1,7040 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "ansi-width" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90759a65f5178dbbd1b0eecc97b187996401bced0eb4a943d9ac6d7a6304d111" -dependencies = [ - "unicode-width 0.2.3", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arc-swap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argmax" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0144c58b55af0133ec3963ce5e4d07aad866e3bbcfdcddbf4590dbd7ad6ff557" -dependencies = [ - "libc", - "nix 0.30.1", - "once_cell", -] - -[[package]] -name = "ariadne" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5" -dependencies = [ - "unicode-width 0.2.3", - "yansi", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "assert_cmd" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" -dependencies = [ - "anstyle", - "bstr", - "libc", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" - -[[package]] -name = "bigdecimal" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "blake2b_simd" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" -dependencies = [ - "arrayvec", - "constant_time_eq", -] - -[[package]] -name = "blake3" -version = "1.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" -dependencies = [ - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.1", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "box-cli" -version = "0.1.0" -dependencies = [ - "clap", - "serde", - "serde_json", - "tar", - "url", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "bstr" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "calendrical_calculations" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" -dependencies = [ - "core_maths", - "displaydoc", -] - -[[package]] -name = "cc" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_complete" -version = "4.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_derive" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "clap_mangen" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" -dependencies = [ - "clap", - "roff", -] - -[[package]] -name = "clipboard-win" -version = "5.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" -dependencies = [ - "error-code", -] - -[[package]] -name = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "codesnake" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2205f7f6d3de68ecf4c291c789b3edf07b6569268abd0188819086f71ae42225" - -[[package]] -name = "codspeed" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7c21807b937b8e535f2745cd83c525fc250524021cd662b9ba4d3e399bb504" -dependencies = [ - "anyhow", - "cc", - "colored", - "getrandom 0.4.3", - "glob", - "nix 0.31.3", - "serde", - "serde_json", - "statrs", -] - -[[package]] -name = "codspeed-divan-compat" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7e48c4e555e60b894144b14df1e893a7317b9f4000b1492ace3041479b12cc" -dependencies = [ - "clap", - "codspeed", - "codspeed-divan-compat-macros", - "codspeed-divan-compat-walltime", - "regex", -] - -[[package]] -name = "codspeed-divan-compat-macros" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eb62a7a8d4cc6b79b18dcbac670b3202f4c8c901ca8ea701ab42190f0b14799" -dependencies = [ - "divan-macros", - "itertools 0.14.0", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "codspeed-divan-compat-walltime" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c4e77a97571b6214b976cee7e2a564215d0a5bee9520a3cdab86106d0b31a3" -dependencies = [ - "cfg-if", - "clap", - "codspeed", - "condtype", - "divan-macros", - "libc", - "regex-lite", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "condtype" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" - -[[package]] -name = "console" -version = "0.16.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width 0.2.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crc-fast" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" -dependencies = [ - "digest 0.10.7", - "spin 0.10.1", -] - -[[package]] -name = "crc32fast" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix 0.31.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "curl" -version = "0.1.0" -dependencies = [ - "base64", - "clap", - "url", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "data-encoding-macro" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" -dependencies = [ - "data-encoding", - "syn 3.0.9", -] - -[[package]] -name = "defmt" -version = "0.3.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" -dependencies = [ - "defmt 1.2.0", -] - -[[package]] -name = "defmt" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "defmt-parser" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" -dependencies = [ - "thiserror", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "diffutils" -version = "0.6.0" -dependencies = [ - "assert_cmd", - "chrono", - "codspeed-divan-compat", - "diff", - "itoa", - "predicates", - "pretty_assertions", - "rand 0.10.3", - "regex", - "same-file", - "tempfile", - "unicode-width 0.2.3", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.59.0", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.2", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "divan-macros" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" -dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2 0.10.9", - "subtle", -] - -[[package]] -name = "either" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" -dependencies = [ - "cfg-if", - "core_detect", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", -] - -[[package]] -name = "encoding_rs_io" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "env_logger" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" -dependencies = [ - "log", -] - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "error-code" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" - -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - -[[package]] -name = "faccess" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5" -dependencies = [ - "bitflags 1.3.2", - "libc", - "winapi", -] - -[[package]] -name = "fancy-regex" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fancy-regex" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "faster-hex" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" -dependencies = [ - "autocfg", - "defmt 0.3.101", - "heapless", - "serde", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fd-find" -version = "10.5.1" -dependencies = [ - "aho-corasick", - "anyhow", - "argmax", - "clap", - "clap_complete", - "crossbeam-channel", - "ctrlc", - "diff", - "etcetera", - "faccess", - "filetime", - "globset", - "ignore", - "jiff", - "libc", - "lscolors", - "memchr", - "nix 0.31.3", - "normpath", - "nu-ansi-term", - "regex", - "regex-syntax", - "tempfile", - "test-case", - "tikv-jemallocator", -] - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "filetime" -version = "0.2.29" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" - -[[package]] -name = "fixed_decimal" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - -[[package]] -name = "flate2" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fluent" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" -dependencies = [ - "fluent-bundle", - "unic-langid", -] - -[[package]] -name = "fluent-bundle" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" -dependencies = [ - "fluent-langneg", - "fluent-syntax", - "intl-memoizer", - "intl_pluralrules", - "rustc-hash", - "self_cell", - "smallvec", - "unic-langid", -] - -[[package]] -name = "fluent-langneg" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "fluent-syntax" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" -dependencies = [ - "memchr", - "thiserror", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-timer" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core 0.10.1", -] - -[[package]] -name = "git" -version = "0.1.0" -dependencies = [ - "base64", - "clap", - "gix", - "gix-error", - "gix-pack", - "gix-transport", - "gix-zlib", - "sausage-ssh", - "serde", - "serde_json", - "tar", - "url", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "gix" -version = "0.90.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92b20c1560511a9014b16e29db2f3c6f0580d4fe289b70f41131277b065a3b0" -dependencies = [ - "gix-actor", - "gix-attributes", - "gix-command", - "gix-commitgraph", - "gix-config", - "gix-credentials", - "gix-date", - "gix-diff", - "gix-dir", - "gix-discover", - "gix-error", - "gix-filter", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-merge", - "gix-negotiate", - "gix-note", - "gix-object", - "gix-odb", - "gix-pack", - "gix-parallel", - "gix-path", - "gix-pathspec", - "gix-prompt", - "gix-protocol", - "gix-quote", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-status", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-transport", - "gix-traverse", - "gix-url", - "gix-utils", - "gix-validate", - "gix-worktree", - "gix-worktree-stream", - "gix-zlib", - "nonempty", - "smallvec", -] - -[[package]] -name = "gix-actor" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78243d3371019288a40a75ba1a58de2815c32591fc87b9f7cb01918bce364ca8" -dependencies = [ - "bstr", - "gix-date", - "gix-error", -] - -[[package]] -name = "gix-attributes" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9947d93a94d41ddde66ac655674269121cbebaf6cdee30d8d0e7dbe85edb4c" -dependencies = [ - "bstr", - "gix-error", - "gix-glob", - "gix-parallel", - "gix-path", - "gix-quote", - "gix-trace", - "smallvec", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "422cd23418fd80e9e965595323975d72abd6364a66ff8f0daade5c105a7f35d2" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-chunk" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6a775aba54f0d65698126cc4214b6bf8ddb208312e4ef4240dfe34b0697b87" -dependencies = [ - "gix-error", -] - -[[package]] -name = "gix-command" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df63215f103299169e239dfe76f5cfa4a3d6d4adf69a01774085e3e0423cba34" -dependencies = [ - "bstr", - "gix-error", - "gix-path", - "gix-quote", - "gix-trace", -] - -[[package]] -name = "gix-commitgraph" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b53b700d2a506cf98075099df1fcc0558265cbb47fd86bc6d03c231e4bd72a" -dependencies = [ - "bstr", - "gix-chunk", - "gix-error", - "gix-hash", - "memmap2", - "nonempty", -] - -[[package]] -name = "gix-config" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e11ccf82e9630c4e7befbae07cbe90e79de61dc73a34448c2dec70a13da9ebf" -dependencies = [ - "bstr", - "gix-config-value", - "gix-error", - "gix-glob", - "gix-parallel", - "gix-path", - "gix-ref", - "gix-sec", - "gix-trace", - "gix-utils", - "smallvec", - "unicode-bom", -] - -[[package]] -name = "gix-config-value" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cdada8bd9c97dc6522505c08226d9c88f0a1286f2c06b9e1bf51c3bf7ad7b2b" -dependencies = [ - "bitflags 2.13.2", - "bstr", - "gix-error", - "gix-path", - "gix-utils", - "libc", -] - -[[package]] -name = "gix-credentials" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8487f709886267337ee39231a5e68f3c6a4e28fd59e056fc9e66e9638941c633" -dependencies = [ - "bstr", - "gix-command", - "gix-config-value", - "gix-date", - "gix-error", - "gix-path", - "gix-prompt", - "gix-quote", - "gix-sec", - "gix-trace", - "gix-url", - "percent-encoding", -] - -[[package]] -name = "gix-date" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c538190012dbb536df54734ec38cc1109270c73f2f76585c011dd5b0c9f087c" -dependencies = [ - "bstr", - "gix-error", - "itoa", - "jiff", -] - -[[package]] -name = "gix-diff" -version = "0.70.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c794988335792f85c3ca79c385c950b3a921a71ba3df361cb1f9e5b6bd4a6e4" -dependencies = [ - "bstr", - "gix-attributes", - "gix-command", - "gix-error", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", -] - -[[package]] -name = "gix-dir" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af939f21c65d4f15457c72d08c874d454893185c51a3596e3c2bd3d9c567618d" -dependencies = [ - "bstr", - "gix-discover", - "gix-error", - "gix-fs", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", -] - -[[package]] -name = "gix-discover" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0875f4e5408713fcf92265973cc38bf9dc5a93384ce218beba0572b7785a03" -dependencies = [ - "bstr", - "dunce", - "gix-error", - "gix-fs", - "gix-path", - "gix-ref", - "gix-sec", -] - -[[package]] -name = "gix-error" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87ff4b3f3daf01234d38d176b0ec00e92de514cc0e3fb8a8898c5cb7ccd22bb" -dependencies = [ - "bstr", -] - -[[package]] -name = "gix-filter" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8df8d50fda4684c6ec366f75e4082ab6fc3d437d3deec30ff88462710b68d17" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-error", - "gix-hash", - "gix-object", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "smallvec", -] - -[[package]] -name = "gix-fs" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808f23d897ddfe847d9b7206bda947901885be623bdf96b226a2dccf87f018c7" -dependencies = [ - "bstr", - "gix-error", - "gix-parallel", - "gix-path", - "gix-utils", - "libc", - "walkdir", -] - -[[package]] -name = "gix-glob" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a12789fa68a150293ad8834d267b31044240532b5ccf2734d228fe985cba2be" -dependencies = [ - "bitflags 2.13.2", - "bstr", - "gix-fs", - "gix-path", - "gix-trace", - "gix-utils", -] - -[[package]] -name = "gix-hash" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4802ffb87e051795822bfdaa8ab9075b23e316684f7054e6ce4a246b2b76f4e" -dependencies = [ - "faster-hex", - "gix-error", - "gix-utils", - "sha1dc", -] - -[[package]] -name = "gix-hashtable" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f66822aee11ccb28d6a2e63b587254e578fb52a138bd5806b58d8560125592b" -dependencies = [ - "gix-hash", - "hashbrown 0.17.1", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "968f12fe42896873504f46127cd5bf0f9a34fc1479a781f43cf94b5d8d760a15" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-imara-diff" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7518b930f6aeb75e871d32ce873c2ae46e35cef19b5a0b039d4337f651d897cd" -dependencies = [ - "bstr", - "hashbrown 0.16.1", -] - -[[package]] -name = "gix-index" -version = "0.58.0" -dependencies = [ - "bitflags 2.13.2", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-error", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-parallel", - "gix-trace", - "gix-traverse", - "gix-utils", - "gix-validate", - "hashbrown 0.17.1", - "itoa", - "libc", - "memmap2", - "rustix", - "smallvec", -] - -[[package]] -name = "gix-lock" -version = "26.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64783de116c477c5ea4a7f3b56e18ab363dabfdae2724291ea0587b642cadb3d" -dependencies = [ - "gix-error", - "gix-fs", - "gix-tempfile", - "gix-utils", -] - -[[package]] -name = "gix-macros" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d02e51a52e15255757c06a6873529ae01205711aa9e8f9a1d46b556f1a14a96e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "gix-merge" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf6d8a0724d529adb7f4b1db8cb8408b829298a1e5f7ea167be4e36d113059b" -dependencies = [ - "bstr", - "gix-command", - "gix-diff", - "gix-error", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-quote", - "gix-revision", - "gix-revwalk", - "gix-tempfile", - "gix-trace", - "gix-worktree", - "nonempty", -] - -[[package]] -name = "gix-negotiate" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "148bee10394df68d44c245e05b4c0747edf19144405c6c36a9eee29db9bb250d" -dependencies = [ - "bitflags 2.13.2", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", -] - -[[package]] -name = "gix-note" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8473688f2262f4364f521b39ce2a5dbe94494f327fc19c945318d7aff580952e" -dependencies = [ - "gix-error", - "gix-hash", - "gix-object", -] - -[[package]] -name = "gix-object" -version = "0.66.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beaf9086dd00b9d2a177ca3ae83210ce926cee7c0faa112e053851221cd8f94d" -dependencies = [ - "bstr", - "gix-actor", - "gix-command", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "itoa", - "smallvec", -] - -[[package]] -name = "gix-odb" -version = "0.87.0" -dependencies = [ - "arc-swap", - "crc32fast", - "gix-error", - "gix-fs", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-parallel", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils", - "gix-zlib", - "memmap2", - "parking_lot", - "tempfile", -] - -[[package]] -name = "gix-pack" -version = "0.77.0" -dependencies = [ - "clru", - "crc32fast", - "gix-chunk", - "gix-diff", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-parallel", - "gix-path", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-utils", - "gix-zlib", - "memmap2", - "parking_lot", - "smallvec", -] - -[[package]] -name = "gix-packetline" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ebb59ef7aff9d435946a5e5c3d9cfb673be7665839fd72961c08c733a934fb7" -dependencies = [ - "bstr", - "faster-hex", - "gix-error", - "gix-trace", -] - -[[package]] -name = "gix-parallel" -version = "0.50.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58e7e10f8343e05f6ad8651f97dffa1caca1531bb274bfc8e3050e80e3ed5946" -dependencies = [ - "crossbeam-channel", - "gix-trace", - "once_cell", - "parking_lot", -] - -[[package]] -name = "gix-path" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83976e926a485e6fb4b8d9a0d7a30c53c1f607b81b5d310f06f92fd8385bf16a" -dependencies = [ - "bstr", - "gix-error", - "gix-trace", - "gix-validate", -] - -[[package]] -name = "gix-pathspec" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490aa03a11e0cbb998a0a593b75e27ea2e30df4ebd8f84f60e0641b9b72270e8" -dependencies = [ - "bitflags 2.13.2", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-error", - "gix-glob", - "gix-path", -] - -[[package]] -name = "gix-prompt" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9650eedebb85e55577831db217403333416573356de722a040415c416f1cec" -dependencies = [ - "gix-command", - "gix-config-value", - "gix-error", - "parking_lot", - "rustix", -] - -[[package]] -name = "gix-protocol" -version = "0.68.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83677ebae6a1a09dc07f2435b5ebe68470dc51afb74052135e98fd7c11f85ba9" -dependencies = [ - "bstr", - "gix-credentials", - "gix-date", - "gix-error", - "gix-hash", - "gix-lock", - "gix-macros", - "gix-negotiate", - "gix-object", - "gix-ref", - "gix-refspec", - "gix-revwalk", - "gix-shallow", - "gix-trace", - "gix-transport", - "gix-utils", - "nonempty", -] - -[[package]] -name = "gix-quote" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2413e65271303e9b674ec4785d469df4c3c36e888dbd165e3035de0405c6d9d1" -dependencies = [ - "bstr", - "gix-error", - "gix-utils", -] - -[[package]] -name = "gix-ref" -version = "0.69.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68686e4697b9cd15d1b152ef51df870513210ed22938eacc13b3672b76035f13" -dependencies = [ - "gix-actor", - "gix-error", - "gix-fs", - "gix-hash", - "gix-lock", - "gix-object", - "gix-parallel", - "gix-path", - "gix-tempfile", - "gix-utils", - "gix-validate", - "memmap2", -] - -[[package]] -name = "gix-refspec" -version = "0.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2569309da6fdc230973cf5b846508154c0e813fda60f2b2da768cc5655ed312c" -dependencies = [ - "bstr", - "gix-error", - "gix-hash", - "gix-validate", - "smallvec", -] - -[[package]] -name = "gix-revision" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241465fc73530f41d0a1c18320c5e291365b7c2c2a3dfd07afe2a5d6265085c8" -dependencies = [ - "bitflags 2.13.2", - "bstr", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "gix-trace", - "nonempty", -] - -[[package]] -name = "gix-revwalk" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b46edeacd25d9d3131941ae6bc91002bf7ba10e130393b7e9bc23d528625447" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "smallvec", -] - -[[package]] -name = "gix-sec" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9598d1f35baec7038c2d278bfcc18f1d8951f41dcc3f51ab6175661dfe5a6137" -dependencies = [ - "bitflags 2.13.2", - "gix-path", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "gix-shallow" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d805949578724b5164c90ec6d19567832ccaa5ba8a3fe26f7befc52007e2407" -dependencies = [ - "bstr", - "gix-error", - "gix-hash", - "gix-lock", - "nonempty", -] - -[[package]] -name = "gix-status" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b00c359278210e3cc0803d057db1d61c5d6694c21ed9ad0b8f8f862157247a" -dependencies = [ - "bstr", - "filetime", - "gix-diff", - "gix-dir", - "gix-error", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-index", - "gix-object", - "gix-parallel", - "gix-path", - "gix-pathspec", - "gix-trace", - "gix-utils", - "gix-worktree", - "hashbrown 0.16.1", - "portable-atomic", -] - -[[package]] -name = "gix-submodule" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558a1307b6f3de2a92bd91d0570c53952d4f2329e6860813007508c8ed413dc" -dependencies = [ - "bstr", - "gix-config", - "gix-error", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", -] - -[[package]] -name = "gix-tempfile" -version = "26.0.0" -dependencies = [ - "dashmap", - "gix-fs", - "libc", - "parking_lot", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "gix-trace" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c477c029518d3f235c26db21ff7cccc1fa13c0c31b05c005a55b7affd4ffbd81" - -[[package]] -name = "gix-transport" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcd9cdfd8745aa38f926a4a380c13d76b200d7338de57fc492982cd5a6652ba" -dependencies = [ - "base64", - "bstr", - "gix-command", - "gix-credentials", - "gix-error", - "gix-packetline", - "gix-path", - "gix-quote", - "gix-sec", - "gix-trace", - "gix-url", - "gix-utils", - "parking_lot", -] - -[[package]] -name = "gix-traverse" -version = "0.64.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00171e00e91508300d0361072c4eb110ec0f1322d8b95ed36887231702a38ebc" -dependencies = [ - "bitflags 2.13.2", - "gix-commitgraph", - "gix-date", - "gix-error", - "gix-hash", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", -] - -[[package]] -name = "gix-url" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def12e1016b872f1094442882f627905c59bd73480435291a4958cf373a8f28d" -dependencies = [ - "bstr", - "gix-error", - "gix-path", - "gix-utils", - "percent-encoding", -] - -[[package]] -name = "gix-utils" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11a9446060447e9ec58f5d333b98bed53389dfa10de5d8dd46fc76d01dfc2954" -dependencies = [ - "bstr", - "bytes", - "fastrand", - "getrandom 0.4.3", - "gix-error", - "prodash", - "unicode-normalization", -] - -[[package]] -name = "gix-validate" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b617c17754bf37ee7a9b48e33ffb806abecbbed779ba415199ac1480acf699" -dependencies = [ - "bstr", - "gix-error", -] - -[[package]] -name = "gix-worktree" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe92aec0a25cf073394492a6ee2aa4d27632acde91a653d34de0e4da6afb485d" -dependencies = [ - "bstr", - "gix-attributes", - "gix-error", - "gix-fs", - "gix-glob", - "gix-hash", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate", -] - -[[package]] -name = "gix-worktree-stream" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe5d65793848ffb596f6c5596fd120b132038736525628227e2388ca454f64d" -dependencies = [ - "gix-attributes", - "gix-error", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-object", - "gix-parallel", - "gix-path", - "gix-trace", - "gix-traverse", - "gix-utils", - "parking_lot", -] - -[[package]] -name = "gix-zlib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c5b29808570149d86f417184605111ede9dda5aa06015e9ea6da07f0c3ca41e" -dependencies = [ - "gix-error", - "zlib-rs", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "grep" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "309217bc53e2c691c314389c7fa91f9cd1a998cda19e25544ea47d94103880c3" -dependencies = [ - "grep-cli", - "grep-matcher", - "grep-pcre2", - "grep-printer", - "grep-regex", - "grep-searcher", -] - -[[package]] -name = "grep-cli" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" -dependencies = [ - "bstr", - "globset", - "libc", - "log", - "termcolor", - "winapi-util", -] - -[[package]] -name = "grep-matcher" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847" -dependencies = [ - "memchr", -] - -[[package]] -name = "grep-pcre2" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b752bb6f57dd3716e55b63a33ca05b5825c3d66cd427468874a5564af6ca365c" -dependencies = [ - "grep-matcher", - "log", - "pcre2", -] - -[[package]] -name = "grep-printer" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd76035e87871f51c1ee5b793e32122b3ccf9c692662d9622ef1686ff5321acb" -dependencies = [ - "bstr", - "grep-matcher", - "grep-searcher", - "log", - "serde", - "serde_json", - "termcolor", -] - -[[package]] -name = "grep-regex" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" -dependencies = [ - "bstr", - "grep-matcher", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "grep-searcher" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6" -dependencies = [ - "bstr", - "encoding_rs", - "encoding_rs_io", - "grep-matcher", - "log", - "memchr", - "memmap2", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hifijson" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_calendar" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68ffb801cc7acb8dcae62da7c692c47934698d2dd4f479ddee1eb442763dea1" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar_data", - "icu_locale_core", - "icu_locale_fallback", - "icu_provider", - "serde", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_calendar_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514da54e79efa36b5976232d5b3d1d8ee1b47b19ab658f1b1ebe39221696c2d8" - -[[package]] -name = "icu_collator" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24cb0205c419b2036da3f9082964905d6849b3f2130dd4a517940c45899a544b" -dependencies = [ - "icu_collator_data", - "icu_collections", - "icu_locale_core", - "icu_locale_fallback", - "icu_normalizer", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_collator_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183f47e821a327661c1ebe152f61d5b6c2000010d228cadbbf226e0e2b8c2634" - -[[package]] -name = "icu_collections" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_datetime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c338aa7ebff734470e216a4196fa84b123a366cc786c445741b6f5647412c3c" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_calendar", - "icu_datetime_data", - "icu_decimal", - "icu_locale_core", - "icu_locale_fallback", - "icu_pattern", - "icu_plurals", - "icu_provider", - "icu_time", - "potential_utf", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_datetime_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bddb757c2df1635453c4e8cb2d4ba7ad026e904893f29699315826c0951bbd9" - -[[package]] -name = "icu_decimal" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_decimal_data", - "icu_locale_core", - "icu_locale_fallback", - "icu_plurals", - "icu_provider", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_decimal_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" - -[[package]] -name = "icu_locale" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8fa57bcddc15cb921582ee5259c5b77da39f946f188ca5213fad24d9241236" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_locale_fallback", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64014dac577a11d8d2cee48517c73c1984bd72ab11031e934269bd3cefb2ad9" - -[[package]] -name = "icu_locale_fallback" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" - -[[package]] -name = "icu_normalizer" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" - -[[package]] -name = "icu_pattern" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" -dependencies = [ - "displaydoc", - "either", - "serde", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_plurals" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" -dependencies = [ - "fixed_decimal", - "icu_locale_fallback", - "icu_plurals_data", - "icu_provider", - "zerovec", -] - -[[package]] -name = "icu_plurals_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" - -[[package]] -name = "icu_properties" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" - -[[package]] -name = "icu_provider" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_time" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d45ee651f21433b1c6352311f2ad9b5253c26be0e25d9a0869cb8247b8cfde" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar", - "icu_locale_core", - "icu_provider", - "icu_time_data", - "serde", - "writeable", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_time_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d7d711f44237fa9300406ab6e521148594056862343370b5df265a0d15c088" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "indicatif" -version = "0.18.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" -dependencies = [ - "console", - "portable-atomic", - "unicode-width 0.2.3", - "unit-prefix", - "web-time", -] - -[[package]] -name = "inotify" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" -dependencies = [ - "bitflags 2.13.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" -dependencies = [ - "libc", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "intl-memoizer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" -dependencies = [ - "type-map", - "unic-langid", -] - -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jaq" -version = "2.3.0" -dependencies = [ - "codesnake", - "dirs", - "env_logger", - "hifijson", - "is-terminal", - "jaq-core", - "jaq-json", - "jaq-std", - "log", - "memmap2", - "mimalloc", - "rustyline", - "tempfile", - "unicode-width 0.1.14", - "yansi", -] - -[[package]] -name = "jaq-core" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77526a72eb79412c29fd141767a6549bbfcb1cb40e00556fe16532d5e878e098" -dependencies = [ - "dyn-clone", - "once_cell", - "typed-arena", -] - -[[package]] -name = "jaq-json" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01dbdbd07b076e8403abac68ce7744d93e2ecd953bbc44bf77bf00e1e81172bc" -dependencies = [ - "foldhash 0.1.5", - "hifijson", - "indexmap", - "jaq-core", - "jaq-std", -] - -[[package]] -name = "jaq-std" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c264fe397c981705976c71f1bfe020382b9eda52ae950e57fe885e147bdd67d" -dependencies = [ - "aho-corasick", - "base64", - "chrono", - "jaq-core", - "libm", - "log", - "regex-lite", - "urlencoding", -] - -[[package]] -name = "jiff" -version = "0.2.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" -dependencies = [ - "defmt 1.2.0", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" -dependencies = [ - "defmt 1.2.0", - "log", -] - -[[package]] -name = "jiff-icu" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0635c26744fc68e8fbc6120ba5af632d1eef6f4e951a665509c0a4552caa513f" -dependencies = [ - "icu_calendar", - "icu_time", - "jiff", -] - -[[package]] -name = "jiff-static" -version = "0.2.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", -] - -[[package]] -name = "kqueue" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.13.2", - "libc", -] - -[[package]] -name = "lazy_static" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" -dependencies = [ - "spin 0.9.9", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lexopt" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" - -[[package]] -name = "libc" -version = "0.2.191" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libmimalloc-sys" -version = "0.1.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" -dependencies = [ - "cc", -] - -[[package]] -name = "libredox" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" -dependencies = [ - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "lscolors" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60e266dfb1426eb2d24792602e041131fdc0236bb7007abc0e589acafd60929" -dependencies = [ - "aho-corasick", - "nu-ansi-term", -] - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmap2" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" -dependencies = [ - "libc", -] - -[[package]] -name = "mimalloc" -version = "0.1.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" -dependencies = [ - "libmimalloc-sys", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - -[[package]] -name = "nix" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "libc", -] - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nonempty" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" - -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - -[[package]] -name = "normpath" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "notify" -version = "8.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" -dependencies = [ - "bitflags 2.13.2", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "objc2-core-foundation", - "objc2-core-services", - "walkdir", - "windows-sys 0.61.2", -] - -[[package]] -name = "notify-types" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", - "rand 0.8.8", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.8", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-modular" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-prime" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b285c575532a33ef6fdd3a57640d0b1c70e6ca48644d6df7bbd4b7a0cfbbb12d" -dependencies = [ - "bitvec", - "either", - "lru", - "num-bigint", - "num-integer", - "num-modular", - "num-traits", - "rand 0.8.8", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "objc2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "objc2-core-services" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "os_display" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" -dependencies = [ - "unicode-width 0.2.3", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p521" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" -dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", - "primeorder", - "rand_core 0.6.4", - "sha2 0.10.9", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "parse_datetime" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503fd4c8b0685e5eb32a8276533611f63e3b44768e9e8724e73f6852c2542f82" -dependencies = [ - "jiff", - "num-traits", - "winnow", -] - -[[package]] -name = "pcre2" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e970b0fcce0c7ee6ef662744ff711f21ccd6f11b7cf03cd187a80e89797fc67" -dependencies = [ - "libc", - "log", - "pcre2-sys", -] - -[[package]] -name = "pcre2-sys" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18b9073c1a2549bd409bf4a32c94d903bb1a09bf845bc306ae148897fa0760a4" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" -dependencies = [ - "phf_shared", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" -dependencies = [ - "fastrand", - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "platform-info" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa5c6ef5da99ec17edc36267860ddb03504a916e0cc74c0c4dd1c5cdb19637f8" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "difflib", - "float-cmp", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "procfs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" -dependencies = [ - "bitflags 2.13.2", - "chrono", - "flate2", - "procfs-core", - "rustix", -] - -[[package]] -name = "procfs-core" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" -dependencies = [ - "bitflags 2.13.2", - "chrono", - "hex", -] - -[[package]] -name = "prodash" -version = "31.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf52d87428247c5aa3760ac8e7d8b13ec7b3579b955a8c3f311668b6860acba5" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" -dependencies = [ - "ppv-lite86", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ripgrep" -version = "15.2.0" -dependencies = [ - "anyhow", - "bstr", - "grep", - "ignore", - "lexopt", - "log", - "serde", - "serde_derive", - "serde_json", - "termcolor", - "textwrap", - "tikv-jemallocator", - "walkdir", -] - -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha2 0.10.9", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rstest" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.119", - "unicode-ident", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" -dependencies = [ - "bitflags 2.13.2", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "rustyline" -version = "13.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "clipboard-win", - "fd-lock", - "libc", - "log", - "memchr", - "nix 0.27.1", - "unicode-segmentation", - "unicode-width 0.1.14", - "utf8parse", - "winapi", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "sausage-archive-tools" -version = "0.1.0" -dependencies = [ - "flate2", - "tar", -] - -[[package]] -name = "sausage-chat" -version = "0.1.0" -dependencies = [ - "clap", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "sausage-coreutils" -version = "0.1.0" -dependencies = [ - "rustix", - "uu_arch", - "uu_b2sum", - "uu_base32", - "uu_base64", - "uu_basename", - "uu_basenc", - "uu_cat", - "uu_cksum", - "uu_comm", - "uu_cp", - "uu_csplit", - "uu_cut", - "uu_date", - "uu_dd", - "uu_dir", - "uu_dircolors", - "uu_dirname", - "uu_echo", - "uu_expand", - "uu_expr", - "uu_factor", - "uu_false", - "uu_fmt", - "uu_fold", - "uu_head", - "uu_hostid", - "uu_join", - "uu_link", - "uu_ln", - "uu_ls", - "uu_md5sum", - "uu_mkdir", - "uu_mktemp", - "uu_mv", - "uu_nice", - "uu_nl", - "uu_nproc", - "uu_numfmt", - "uu_od", - "uu_paste", - "uu_pathchk", - "uu_pr", - "uu_printenv", - "uu_printf", - "uu_ptx", - "uu_pwd", - "uu_readlink", - "uu_realpath", - "uu_rm", - "uu_rmdir", - "uu_seq", - "uu_sha1sum", - "uu_sha224sum", - "uu_sha256sum", - "uu_sha384sum", - "uu_sha512sum", - "uu_shred", - "uu_shuf", - "uu_sleep", - "uu_sort", - "uu_split", - "uu_sum", - "uu_tail", - "uu_tee", - "uu_test", - "uu_touch", - "uu_tr", - "uu_true", - "uu_truncate", - "uu_tsort", - "uu_tty", - "uu_uname", - "uu_unexpand", - "uu_uniq", - "uu_unlink", - "uu_vdir", - "uu_wc", - "uu_yes", - "uucore", -] - -[[package]] -name = "sausage-cwd-probe" -version = "0.1.0" - -[[package]] -name = "sausage-exec-tools" -version = "0.1.0" -dependencies = [ - "sausage-spawn", -] - -[[package]] -name = "sausage-findutils" -version = "0.1.0" -dependencies = [ - "globset", - "sausage-spawn", - "shlex 1.3.0", - "walkdir", -] - -[[package]] -name = "sausage-fs-tools" -version = "0.1.0" -dependencies = [ - "chrono", - "clap", - "sausage-spawn", -] - -[[package]] -name = "sausage-grep" -version = "0.1.0" -dependencies = [ - "globset", - "grep-matcher", - "grep-regex", - "lexopt", - "walkdir", -] - -[[package]] -name = "sausage-init" -version = "0.1.0" -dependencies = [ - "sausage-spawn", - "serde", - "serde_json", - "shlex 1.3.0", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "sausage-procps" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.46.0", -] - -[[package]] -name = "sausage-sed" -version = "0.1.0" -dependencies = [ - "sed", -] - -[[package]] -name = "sausage-spawn" -version = "0.1.0" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", - "wit-bindgen 0.46.0", -] - -[[package]] -name = "sausage-ssh" -version = "0.1.0" -dependencies = [ - "aes", - "base64", - "ctr", - "futures-lite", - "hmac", - "rand_core 0.6.4", - "serde", - "serde_json", - "sha1 0.10.7", - "sha2 0.10.9", - "signature", - "ssh-key", - "url", - "wasi 0.14.7+wasi-0.2.4", - "x25519-dalek", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "sed" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b908cd347ea83fee7c9fdb621e4dccdd6612d9962da2be03a846be2e6d83f758" -dependencies = [ - "clap", - "clap_complete", - "clap_mangen", - "fancy-regex 0.18.0", - "memchr", - "memmap2", - "phf", - "phf_codegen", - "predicates", - "regex", - "rustix", - "tempfile", - "terminal_size", - "textwrap", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_json" -version = "1.0.154" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sha1dc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sha3" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" -dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", -] - -[[package]] -name = "shake" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "sm3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da6a89ba31723d185fd7413b98c576a575f356d9b84729d8ecb6ead60000a5b6" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "smallvec" -version = "1.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" - -[[package]] -name = "smawk" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sponge-cursor" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" - -[[package]] -name = "ssh" -version = "0.1.0" -dependencies = [ - "clap", - "sausage-ssh", -] - -[[package]] -name = "ssh-cipher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" -dependencies = [ - "cipher", - "ssh-encoding", -] - -[[package]] -name = "ssh-encoding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" -dependencies = [ - "base64ct", - "pem-rfc7468", - "sha2 0.10.9", -] - -[[package]] -name = "ssh-key" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" -dependencies = [ - "ed25519-dalek", - "p256", - "p384", - "p521", - "rand_core 0.6.4", - "rsa", - "sec1", - "sha2 0.10.9", - "signature", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "statrs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" -dependencies = [ - "approx", - "num-traits", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "test-case" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" -dependencies = [ - "test-case-macros", -] - -[[package]] -name = "test-case-core" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29b8e1f65819001927b8cdcbb5560f2428212f2e5ab5684e3c6815a4d80e72db" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "test-case-macros" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802d6165a14df1598b7290a8c546df402940d3cfbe82d15c0fd35de176771693" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "test-case-core", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", - "terminal_size", - "unicode-linebreak", - "unicode-width 0.2.3", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "tikv-jemalloc-sys" -version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "tikv-jemallocator" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" -dependencies = [ - "libc", - "tikv-jemalloc-sys", -] - -[[package]] -name = "tinystr" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "toml_datetime" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.19+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" -dependencies = [ - "winnow", -] - -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash", -] - -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "undo" -version = "0.1.0" -dependencies = [ - "clap", - "curl", - "serde_json", - "url", -] - -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - -[[package]] -name = "unicode-bom" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" - -[[package]] -name = "unicode-ident" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-normalization" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf16_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7de35d9d8757dbc1f5125dc8b51438c68b4430546a735c3a9b91aaaaf9a18a" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf8_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" - -[[package]] -name = "uu_arch" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce13fa692b9a935828053cf02cceed52a4bfe68ac6a7f0273fc98d94a62a1cc" -dependencies = [ - "clap", - "fluent", - "platform-info", - "uucore", -] - -[[package]] -name = "uu_b2sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa4061f010d64b18f046f6aba14cac3d2dfe4a824816e5d1d30d554c7001c6e" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_base32" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4472f433014202b99debd2907cda4650aafbb6881b8a5dca139b255dceed892" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_base64" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6b72f857b343f12c7f4be39917343a00d214a8250eaf49509cf9df6f6c8ddc" -dependencies = [ - "clap", - "fluent", - "uu_base32", - "uucore", -] - -[[package]] -name = "uu_basename" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b29c8c798465d8001a90b6231554f8e00e9c4cf781a9f7d0b96224dcaac77ecd" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_basenc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c9841519e45b7dd994f5d8d40dc31164759e1476f52935f5d5fca8bd2fd7b79" -dependencies = [ - "clap", - "fluent", - "uu_base32", - "uucore", -] - -[[package]] -name = "uu_cat" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75143ac91809c365ca76bb141ea2a7a35ffdf821046f52e14bd83b710bb5ce34" -dependencies = [ - "clap", - "fluent", - "memchr", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_checksum_common" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475c8f9e5873fb5fc87ff796e07b39b309f64a553232172242014d2208bfe8d" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_cksum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db6b4b87b7d6c92add8ba1a4cfae7355734bc9afbf74d6904e1bc6b05ffcef5" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_comm" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce0974ee091b6dce8b4db465da4869c80dd1208cd38e9ab9f567c03929a25283" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_cp" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4c15f0ba298a490b66abac0fefc3a44ff1b8004c38cf140d27c027f1f80784" -dependencies = [ - "clap", - "filetime", - "fluent", - "indicatif", - "libc", - "nix 0.31.3", - "rustix", - "thiserror", - "uucore", - "walkdir", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_csplit" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55aa4ee843fea3f6ec8ab55c69c39f5758e52f1f00c8b5a6f941001b66d11acb" -dependencies = [ - "clap", - "fluent", - "regex", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_cut" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2087dececd3be61b5671c0978605e0fa9ecbe707294137896efdb2848094326" -dependencies = [ - "bstr", - "clap", - "fluent", - "memchr", - "uucore", -] - -[[package]] -name = "uu_date" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949f0a6c6449604f71c42915ac5ad8ffa87644d403c028e97023bb775483d789" -dependencies = [ - "clap", - "fluent", - "icu_calendar", - "icu_locale", - "jiff", - "jiff-icu", - "jiff-tzdb", - "libc", - "parse_datetime", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_dd" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82610ff943844c3ba8be998f3e964cc75a23cedd42be4956a70bd05ec1d0238c" -dependencies = [ - "clap", - "fluent", - "gcd", - "libc", - "rustix", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_dir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53753588e40c6e17b817b7dd012bd1d68ca372cf35a50d8dbef8441bd1b27625" -dependencies = [ - "clap", - "uu_ls", - "uucore", -] - -[[package]] -name = "uu_dircolors" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a029149ed7941dffc2e25c2ab56747873e1d1a01b5c8b5bfe7a15c56dc950665" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_dirname" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2affe4ee8747234885830bd8269d747bab7e78d730c0e360920663e5c72fe75" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_echo" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f9afb484c165b55a3fee0d17e18921865dc2914feccd5eaed9298810e3e446" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_expand" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb12c2abb138ad08d01557ab6fdf2d201e06a0f88122d7d5a283fe2d8273151c" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_expr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601663e752b83657be04ec8c54838310a516dd66d30affc36f02f3cc71f26199" -dependencies = [ - "clap", - "fancy-regex 0.19.2", - "fluent", - "num-bigint", - "num-traits", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_factor" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debab9d4be53ba727176e9ecd9bc669b5ec801549544fb51beffc69247fc27f4" -dependencies = [ - "clap", - "fluent", - "memchr", - "num-bigint", - "num-prime", - "uucore", -] - -[[package]] -name = "uu_false" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dee4cf0e44a93aceab88533e274fa3fac69ed9608a6be564066f298483187b9" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_fmt" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9180eae586d5947c9c85915dfeb14bfdef2316c143c1b713b85a3dd630a3be77" -dependencies = [ - "clap", - "fluent", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_fold" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28755578b0b98318371e055fcc70b94c9198bacfce0d125d3049ab1c2d2beb2" -dependencies = [ - "clap", - "fluent", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "uu_head" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d8bd166467c73ad000c303a0d8c3c3fa03160000bb4493cfd40b8f7816fb95" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_hostid" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea4a9be6ed493989e4f9f33b04c0e22c0eb1220634229f2c4b2673b5bf7a4a2" -dependencies = [ - "clap", - "fluent", - "hostname", - "rustix", - "uucore", -] - -[[package]] -name = "uu_join" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864d33141e4b8684a6af365202f8ac0dc73a2f08ea26a3e2929de66ffbe5dcb2" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_link" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50af1219ea0db13447ef438d3ab47ad1bbe9823286076b58149b8c06ef9d43d6" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_ln" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a942fbe008800998bdde259ae54c73b61febce9f7a35b7cf734bdeae40d3f6" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_ls" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8f956231b30edd22f1c0ef803d6928da0d7cb5f07add01a4d15e5b2d4a2540" -dependencies = [ - "ansi-width", - "clap", - "fluent", - "glob", - "hostname", - "lscolors", - "rustc-hash", - "terminal_size", - "thiserror", - "uucore", - "uutils_term_grid", -] - -[[package]] -name = "uu_md5sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18aab2c28bf8a1d596a08ee9ad94ab9f56d1df7acae43bb8cadec09e09f7b4e4" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_mkdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "665e031110e8de62ebe483eb50b21c19f57c6f02c571e3c57cfb6cf344209370" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_mktemp" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71146f15c55f02214362b2357630e0b8078e249657b234b52f25469dbbead79b" -dependencies = [ - "clap", - "fluent", - "rand 0.10.3", - "tempfile", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_mv" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5108b8ea076d2d45f2aa4e546c0fa2cecd25fb2f5b44aa56a95c15d59c9da7c8" -dependencies = [ - "clap", - "fluent", - "indicatif", - "libc", - "nix 0.31.3", - "rustc-hash", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_nice" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11fea55de8d55f50231286a9be4c84eaacc9eadf8b8d1d0f031c483c5f1cc047" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_nl" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab04808b361b764c96380882db6e593d12c7ae5d62582fcee0ca670ba356349" -dependencies = [ - "clap", - "fluent", - "itoa", - "regex", - "uucore", -] - -[[package]] -name = "uu_nproc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aeceedaa99805ec023954728d321fa85229df46943af2a9cb0bc60dedb3a7f7" -dependencies = [ - "clap", - "fluent", - "libc", - "rustix", - "uucore", -] - -[[package]] -name = "uu_numfmt" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c14711615381c8311fa70a69e8a96175748ea97a8c0972be7efb5fe9323c58b1" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_od" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f163305dfac8638f14e110666bd632f12e99c637382bbcbeeee63c771476c79e" -dependencies = [ - "clap", - "fluent", - "half", - "libc", - "rustix", - "uucore", -] - -[[package]] -name = "uu_paste" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edddacb62a1b7058ec9f33974cb4dbf677171b83d710e153764e6e5995fffaeb" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_pathchk" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9bc47993de7229717a577c7c4fe1f3919d8acc6d1dd5cb216a8a7eeebb8222" -dependencies = [ - "clap", - "fluent", - "libc", - "uucore", -] - -[[package]] -name = "uu_pr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a86126634ad64f323a8e2d487fc7fa3b92b1b251a08eef631c113ae57d112678" -dependencies = [ - "clap", - "fluent", - "itertools 0.15.0", - "memchr", - "regex", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_printenv" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7f1090fe0d0ac3e8b7a79cd48830f2f93577329d2ee739d84bb0d893889ccc" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_printf" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ad2cacf36fe7936f5bda5a4c259f6961e3e5f77cf04db26e1922ea7dffad195" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_ptx" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a31e18a9e4519f59376c0fe3667bdaaf2ce3cb0fb100256e3968fe4ac9296ea" -dependencies = [ - "clap", - "fluent", - "regex", - "rustc-hash", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_pwd" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905aa087f76dcfbd22cf9549f56d2713be97f80d68a7638334c8afc91ae528f9" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_readlink" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4390796d1f70cfd2393ce3d6c8ba855352d5057e57f6527bca80a8638dd37d07" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_realpath" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf967086871590e1d92d32e402b152045cd2c09156cbd877c97b6a7d82aabf0" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_rm" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16cd2b5f4e7647a10a316c001b804e688a80aba77685578cdf11bdf4b31f780" -dependencies = [ - "clap", - "fluent", - "indicatif", - "libc", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_rmdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5df882bcefc05b9e4d915a51ffe8475b3b2baabb07d04c9708c5b8b6fbf5d" -dependencies = [ - "clap", - "fluent", - "libc", - "uucore", -] - -[[package]] -name = "uu_seq" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad149ba156667c2ce8d1d87411e091632db5608d5c8777fbe1a134856e89eae0" -dependencies = [ - "bigdecimal", - "clap", - "fluent", - "num-bigint", - "num-traits", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_sha1sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08cf227c5ea9b50b7995ffae6103e575d8ce15744a37a5849adf319573eaa86b" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha224sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961716db2eefd48b450fd837881b110568de7761acbfe4a92abe49b3f5899253" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha256sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c065f8ea49bf27afcdbb9d5c28d94c5690955e89f2b9076c88e03faa3c84d183" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha384sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5a28caaed1f09d6980287a77d522f8192378c36c98a23293085b3a4c597d22" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha512sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d820ee09c795721569ef812b53a4701cc44c3466c16adc0261d4aff3f1bd99d" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_shred" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9a6aa2c9854085579e44f5c5955f23c2d2a0630b8b633585ca61248ed68a6c" -dependencies = [ - "clap", - "fluent", - "libc", - "rand 0.10.3", - "uucore", -] - -[[package]] -name = "uu_shuf" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05bbc6c1d00e2c28003b1d4d3aebd28faeb5eb7028458704ce07de80dff6a64a" -dependencies = [ - "clap", - "fluent", - "itoa", - "rand 0.10.3", - "rand_chacha 0.10.0", - "rustc-hash", - "sha3", - "uucore", -] - -[[package]] -name = "uu_sleep" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "015cf50857143f9a946e9194960cdba7da0b4a17cdfae0bec1128b84e6cdb552" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_sort" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45084032c01910cb5d0515acbcea8ee6377d07e46e6fc84afde79fbd9dc6c6f8" -dependencies = [ - "bigdecimal", - "clap", - "ctrlc", - "fluent", - "foldhash 0.2.0", - "itertools 0.15.0", - "libc", - "memchr", - "rand 0.10.3", - "rayon", - "rustix", - "self_cell", - "tempfile", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_split" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8b146825e117f1b5574250f5a60590a4e02885a425aad65fcea557da301e3e8" -dependencies = [ - "clap", - "fluent", - "memchr", - "rustix", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4053123109cfaa474967027a3ab74e0c1e37177db80f278960c80c2adf3a58cf" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_tail" -version = "0.12.0" -dependencies = [ - "clap", - "fluent", - "libc", - "memchr", - "notify", - "rstest", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tee" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97e59ca8e741e2e8e095898ec01795674a907ee0991883bd5f6313fd688b9d66" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_test" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9007d3a30901a0dba2e28c017aba9edb2aa26c919946ae84c770446067e98304" -dependencies = [ - "clap", - "fluent", - "libc", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_touch" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1784987c9ae4beeb3153c173e5866e1907752180289f6d219cc6c1223ae7a5f0" -dependencies = [ - "clap", - "filetime", - "fluent", - "jiff", - "libc", - "parse_datetime", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc8f860fa347eaf1505a3da8ac292559980c1103b5978ba4f1142aff0755b00" -dependencies = [ - "bytecount", - "clap", - "fluent", - "nom", - "uucore", -] - -[[package]] -name = "uu_true" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7a03ae2661faa57115019fe88ce91b6d8143c6b76930460b06a949de8f8aaa" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_truncate" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1991acfea078be5657c18bab731d77d083bb7facf5157c2567e53f2a6d0b9727" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_tsort" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1a4fbe486b87a15e06e8b00230b6c47eaa15d095bc3fc4cc122d116aae62e0" -dependencies = [ - "clap", - "fluent", - "hashbrown 0.17.1", - "memchr", - "rustc-hash", - "rustix", - "thiserror", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tty" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e27119b92f4ac319f68ad9cb9902fb392283c15aaee01e3841b21b37a5011da" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_uname" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53566dcd5e385c71e1fb277f1ebd5db0d3f04795fa07b58ccb038a440e28c906" -dependencies = [ - "clap", - "fluent", - "platform-info", - "uucore", -] - -[[package]] -name = "uu_unexpand" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4bd141cb78bd0e7611ff4b3040901d3bafbe45621bdd2372484848e5a2b1f34" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror", - "uucore", -] - -[[package]] -name = "uu_uniq" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c59563ff6419f674e2cd5b50cef9f3a0f0e73a06291dd635c006fc253ad268" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_unlink" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3ca22bbb2d23bdd136ec8a6260c12c2550f9ae44c679323bee246bf460494b" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_vdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec245d6aaaae422622318b1a2e98e05709d346e5d7f2bf98f4f49329fb6f4418" -dependencies = [ - "clap", - "uu_ls", - "uucore", -] - -[[package]] -name = "uu_wc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45457ac8613955d62a62e2cea12aab7023a4d015c24a30b4719ee9138d8598d" -dependencies = [ - "bytecount", - "clap", - "fluent", - "libc", - "rustix", - "thiserror", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "uu_yes" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a00721c606207353591d7d5a4fe6c89d94073decccd061392f3f0dea1c37af4" -dependencies = [ - "clap", - "fluent", - "itertools 0.15.0", - "rustix", - "uucore", -] - -[[package]] -name = "uucore" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f24a5910ebbf2c0baf486a9125705f8e026a8f7741a3ea8cf5eca3c9257c9bdf" -dependencies = [ - "ariadne", - "base64-simd", - "bigdecimal", - "blake2b_simd", - "blake3", - "bstr", - "clap", - "crc-fast", - "data-encoding", - "data-encoding-macro", - "digest 0.11.3", - "dunce", - "fluent", - "fluent-syntax", - "glob", - "hex", - "icu_calendar", - "icu_collator", - "icu_datetime", - "icu_decimal", - "icu_locale", - "icu_provider", - "itertools 0.15.0", - "jiff", - "jiff-icu", - "libc", - "md-5", - "memchr", - "nix 0.31.3", - "num-traits", - "os_display", - "procfs", - "rustc-hash", - "rustix", - "sha1 0.11.0", - "sha2 0.11.1", - "sha3", - "shake", - "sm3", - "thiserror", - "unic-langid", - "unicode-width 0.2.3", - "unit-prefix", - "uucore_procs", - "walkdir", - "wild", - "windows-sys 0.61.2", - "xattr", - "z85", -] - -[[package]] -name = "uucore_procs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50aa1f3bba55168888ae6f1f2714ce58897d8020d742d21f474f01e86468fedd" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "uutils_term_grid" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70bdc0434ef5f30856fbe0908a7d96568f8b212a28c20b16d44851f753e465f" -dependencies = [ - "ansi-width", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" -dependencies = [ - "cfg-if", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" -dependencies = [ - "bitflags 2.13.2", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wild" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" -dependencies = [ - "glob", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -dependencies = [ - "bitflags 2.13.2", - "futures", - "once_cell", - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.119", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.119", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" -dependencies = [ - "anyhow", - "bitflags 2.13.2", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "world-cargo" -version = "0.1.0" -dependencies = [ - "sausage-spawn", - "serde", - "serde_json", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -dependencies = [ - "either", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "xattr" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "z85" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675" - -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/userland/Cargo.toml b/userland/Cargo.toml deleted file mode 100644 index e6df760..0000000 --- a/userland/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. -[workspace] -resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo", "chat"] - -# WASI compatibility patches: git/vendor/PATCHES.md. -[patch.crates-io] -gix-tempfile = { path = "git/vendor/gix-tempfile" } -gix-pack = { path = "git/vendor/gix-pack" } -gix-index = { path = "git/vendor/gix-index" } -gix-odb = { path = "git/vendor/gix-odb" } -filetime = { path = "git/vendor/filetime" } - -[profile.release] -opt-level = 3 diff --git a/userland/brush/.gitignore b/userland/brush/.gitignore deleted file mode 100644 index 053143a..0000000 --- a/userland/brush/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/source/ diff --git a/userland/brush/Cargo.lock b/userland/brush/Cargo.lock deleted file mode 100644 index 9dc69e8..0000000 --- a/userland/brush/Cargo.lock +++ /dev/null @@ -1,6739 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "ansi-width" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90759a65f5178dbbd1b0eecc97b187996401bced0eb4a943d9ac6d7a6304d111" -dependencies = [ - "unicode-width 0.2.3", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "archery" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f" -dependencies = [ - "triomphe", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "assert_cmd" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" -dependencies = [ - "anstyle", - "bstr", - "libc", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] - -[[package]] -name = "assert_fs" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecf5c70ca07b7f80220bce936f0556a960ca6fb00fc2bd4125b5e581b218137" -dependencies = [ - "anstyle", - "globwalk", - "predicates", - "predicates-core", - "predicates-tree", - "tempfile", -] - -[[package]] -name = "async-recursion" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide 0.8.9", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "backtrace-ext" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] - -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "bigdecimal" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "blake2b_simd" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" -dependencies = [ - "arrayvec", - "constant_time_eq", -] - -[[package]] -name = "blake3" -version = "1.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" -dependencies = [ - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "bon" -version = "3.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" -dependencies = [ - "bon-macros", -] - -[[package]] -name = "bon-macros" -version = "3.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" -dependencies = [ - "darling 0.24.1", - "ident_case", - "prettyplease 0.3.0", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "brush" -version = "0.4.0" -dependencies = [ - "brush-shell", -] - -[[package]] -name = "brush-builtins" -version = "0.2.0" -dependencies = [ - "anyhow", - "brush-core", - "brush-parser", - "brush-winnow-args-adapter", - "cfg-if", - "chrono", - "fancy-regex", - "itertools 0.15.0", - "nix 0.31.3", - "pretty_assertions", - "procfs", - "rlimit", - "strum", - "thiserror 2.0.21", - "tokio", - "tracing", - "utf8-chars", - "uucore", - "winnow-args", -] - -[[package]] -name = "brush-bundled-tests" -version = "0.1.0" -dependencies = [ - "assert_cmd", - "brush-shell", - "tempfile", -] - -[[package]] -name = "brush-clap-adapter" -version = "0.1.0" -dependencies = [ - "anyhow", - "brush-core", - "clap", - "thiserror 2.0.21", - "tokio", -] - -[[package]] -name = "brush-core" -version = "0.5.0" -dependencies = [ - "anyhow", - "async-recursion", - "async-trait", - "bon", - "brush-parser", - "cached", - "cfg-if", - "check_elevation", - "chrono", - "color-print", - "command-fds", - "fancy-regex", - "futures", - "getrandom 0.4.3", - "hostname", - "inherent", - "itertools 0.15.0", - "libc", - "nix 0.31.3", - "normalize-path", - "pretty_assertions", - "rand 0.10.3", - "rpds", - "serde", - "strum", - "strum_macros", - "tempfile", - "terminal_size", - "terminfo", - "thiserror 2.0.21", - "tokio", - "tracing", - "uuid", - "uzers", - "whoami", - "wit-bindgen 0.46.0", -] - -[[package]] -name = "brush-coreutils-builtins" -version = "0.1.0" -dependencies = [ - "uu_arch", - "uu_b2sum", - "uu_base32", - "uu_base64", - "uu_basename", - "uu_basenc", - "uu_cat", - "uu_cksum", - "uu_comm", - "uu_cp", - "uu_csplit", - "uu_cut", - "uu_date", - "uu_dd", - "uu_df", - "uu_dir", - "uu_dircolors", - "uu_dirname", - "uu_du", - "uu_echo", - "uu_env", - "uu_expand", - "uu_expr", - "uu_factor", - "uu_false", - "uu_fmt", - "uu_fold", - "uu_head", - "uu_hostname", - "uu_join", - "uu_link", - "uu_ln", - "uu_ls", - "uu_md5sum", - "uu_mkdir", - "uu_mktemp", - "uu_more", - "uu_mv", - "uu_nl", - "uu_nproc", - "uu_numfmt", - "uu_od", - "uu_paste", - "uu_pr", - "uu_printenv", - "uu_printf", - "uu_ptx", - "uu_pwd", - "uu_readlink", - "uu_realpath", - "uu_rm", - "uu_rmdir", - "uu_seq", - "uu_sha1sum", - "uu_sha224sum", - "uu_sha256sum", - "uu_sha384sum", - "uu_sha512sum", - "uu_shred", - "uu_shuf", - "uu_sleep", - "uu_sort", - "uu_split", - "uu_sum", - "uu_sync", - "uu_tac", - "uu_tail", - "uu_tee", - "uu_test", - "uu_touch", - "uu_tr", - "uu_true", - "uu_truncate", - "uu_tsort", - "uu_uname", - "uu_unexpand", - "uu_uniq", - "uu_unlink", - "uu_vdir", - "uu_wc", - "uu_whoami", - "uu_yes", - "uucore", -] - -[[package]] -name = "brush-experimental-builtins" -version = "0.1.0" -dependencies = [ - "brush-core", - "brush-winnow-args-adapter", - "serde_json", - "winnow-args", -] - -[[package]] -name = "brush-fuzz" -version = "0.2.2" -dependencies = [ - "anyhow", - "assert_cmd", - "brush-core", - "brush-interactive", - "brush-parser", - "libfuzzer-sys", - "tokio", -] - -[[package]] -name = "brush-interactive" -version = "0.4.0" -dependencies = [ - "bon", - "brush-core", - "brush-parser", - "indexmap", - "nu-ansi-term", - "pretty_assertions", - "radix_trie", - "reedline", - "thiserror 2.0.21", - "tokio", - "tracing", - "unicode-width 0.2.3", - "vt100", -] - -[[package]] -name = "brush-parser" -version = "0.4.0" -dependencies = [ - "anyhow", - "arbitrary", - "bon", - "cached", - "criterion", - "getrandom 0.4.3", - "indenter", - "insta", - "miette", - "peg", - "pretty_assertions", - "serde", - "serde_json", - "thiserror 2.0.21", - "tracing", - "utf8-chars", - "uuid", - "winnow", - "yaml_serde", -] - -[[package]] -name = "brush-shell" -version = "0.4.0" -dependencies = [ - "anyhow", - "assert_cmd", - "assert_fs", - "brush-builtins", - "brush-core", - "brush-coreutils-builtins", - "brush-experimental-builtins", - "brush-interactive", - "brush-parser", - "brush-test-harness", - "brush-winnow-args-adapter", - "clap", - "color-print", - "colored", - "const_format", - "criterion", - "crossterm", - "descape", - "diff", - "etcetera", - "expectrl", - "getrandom 0.4.3", - "git-version", - "glob", - "human-panic", - "indent", - "indexmap", - "junit-report", - "nix 0.31.3", - "os-release", - "predicates", - "pretty_assertions", - "regex", - "schemars", - "serde", - "serde_json", - "strip-ansi-escapes", - "tempfile", - "test-with", - "thiserror 2.0.21", - "tokio", - "toml", - "tracing", - "tracing-subscriber", - "uuid", - "version-compare", - "walkdir", - "which", - "winnow-args", - "yaml_serde", -] - -[[package]] -name = "brush-test-harness" -version = "0.1.0" -dependencies = [ - "anyhow", - "assert_cmd", - "assert_fs", - "cfg_aliases", - "clap", - "colored", - "descape", - "diff", - "expectrl", - "glob", - "indent", - "insta", - "junit-report", - "nix 0.31.3", - "os-release", - "regex", - "serde", - "strip-ansi-escapes", - "tokio", - "version-compare", - "walkdir", - "yaml_serde", -] - -[[package]] -name = "brush-winnow-args-adapter" -version = "0.1.0" -dependencies = [ - "brush-core", - "winnow-args", -] - -[[package]] -name = "bstr" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cached" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0df7748fe2f601e376916ab19e7bfc2c74461b8abe3bce2ce20036ad8de38f" -dependencies = [ - "ahash", - "cached_proc_macro", - "cached_proc_macro_types", - "hashbrown 0.16.1", - "parking_lot", - "thiserror 2.0.21", - "web-time", -] - -[[package]] -name = "cached_proc_macro" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e734c52502e6cf54dce2ba07108906b04b8fe57f4f5e3ef7d58267b4abf060" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "cached_proc_macro_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26cf465651fa6ad902a2d327ba60c3a6bc61c6a2f4ad70d091cf20dfda0074ef" - -[[package]] -name = "calendrical_calculations" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" -dependencies = [ - "core_maths", - "displaydoc", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - -[[package]] -name = "check_elevation" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bd5a44e782b910d9336320487a74e7a8256741c1485332608d014d3c2864e7" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "color-print" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4" -dependencies = [ - "color-print-proc-macro", -] - -[[package]] -name = "color-print-proc-macro" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" -dependencies = [ - "nom 7.1.3", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "command-fds" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" -dependencies = [ - "nix 0.31.3", - "thiserror 2.0.21", -] - -[[package]] -name = "conpty" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72b06487a0d4683349ad74d62e87ad639b09667082b3c495c5b6bab7d84b3da" -dependencies = [ - "windows 0.44.0", -] - -[[package]] -name = "console" -version = "0.16.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width 0.2.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "const_format" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" -dependencies = [ - "const_format_proc_macros", - "konst", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc-fast" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" -dependencies = [ - "crc", - "digest 0.10.7", - "rustversion", - "spin", -] - -[[package]] -name = "crc32fast" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "tokio", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools 0.13.0", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.13.2", - "crossterm_winapi", - "derive_more", - "document-features", - "filedescriptor", - "mio", - "parking_lot", - "rustix", - "serde", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix 0.31.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core 0.24.1", - "darling_macro 0.24.1", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.9", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core 0.24.1", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "data-encoding-macro" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" -dependencies = [ - "data-encoding", - "syn 3.0.9", -] - -[[package]] -name = "defmt" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "defmt-parser" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" -dependencies = [ - "thiserror 2.0.21", -] - -[[package]] -name = "deranged" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" - -[[package]] -name = "derive-getters" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive_arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - -[[package]] -name = "descape" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "700357d9fb22b5232a85e34a413c1a76ae649f532c31107e402dc84e9c03bfb2" - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "crypto-common 0.2.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.2", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - -[[package]] -name = "expectrl" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e0df3044b2257277f573d1e40912ebd4d5891f7588640e3125cbbbb24ff7be3" -dependencies = [ - "conpty", - "nix 0.26.4", - "ptyprocess", - "regex", -] - -[[package]] -name = "fancy-regex" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" - -[[package]] -name = "fixed_decimal" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - -[[package]] -name = "flate2" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" -dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", -] - -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fluent" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" -dependencies = [ - "fluent-bundle", - "unic-langid", -] - -[[package]] -name = "fluent-bundle" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" -dependencies = [ - "fluent-langneg", - "fluent-syntax", - "intl-memoizer", - "intl_pluralrules", - "rustc-hash", - "self_cell", - "smallvec", - "unic-langid", -] - -[[package]] -name = "fluent-langneg" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "fluent-syntax" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" -dependencies = [ - "memchr", - "thiserror 2.0.21", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "git-version" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" -dependencies = [ - "git-version-macro", -] - -[[package]] -name = "git-version-macro" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "globset" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags 2.13.2", - "ignore", - "walkdir", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - -[[package]] -name = "human-panic" -version = "2.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2815d0c49773c6e0a9753960b3d0e50b822e48e08c77bcb4780be8474c9cc3d" -dependencies = [ - "anstream", - "anstyle", - "backtrace", - "serde", - "serde_derive", - "sysinfo", - "toml", - "uuid", -] - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_calendar" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68ffb801cc7acb8dcae62da7c692c47934698d2dd4f479ddee1eb442763dea1" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar_data", - "icu_locale_core", - "icu_locale_fallback", - "icu_provider", - "serde", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_calendar_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514da54e79efa36b5976232d5b3d1d8ee1b47b19ab658f1b1ebe39221696c2d8" - -[[package]] -name = "icu_collator" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24cb0205c419b2036da3f9082964905d6849b3f2130dd4a517940c45899a544b" -dependencies = [ - "icu_collator_data", - "icu_collections", - "icu_locale_core", - "icu_locale_fallback", - "icu_normalizer", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_collator_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183f47e821a327661c1ebe152f61d5b6c2000010d228cadbbf226e0e2b8c2634" - -[[package]] -name = "icu_collections" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_datetime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c338aa7ebff734470e216a4196fa84b123a366cc786c445741b6f5647412c3c" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_calendar", - "icu_datetime_data", - "icu_decimal", - "icu_locale_core", - "icu_locale_fallback", - "icu_pattern", - "icu_plurals", - "icu_provider", - "icu_time", - "potential_utf", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_datetime_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bddb757c2df1635453c4e8cb2d4ba7ad026e904893f29699315826c0951bbd9" - -[[package]] -name = "icu_decimal" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_decimal_data", - "icu_locale_core", - "icu_locale_fallback", - "icu_plurals", - "icu_provider", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_decimal_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" - -[[package]] -name = "icu_locale" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8fa57bcddc15cb921582ee5259c5b77da39f946f188ca5213fad24d9241236" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_locale_fallback", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64014dac577a11d8d2cee48517c73c1984bd72ab11031e934269bd3cefb2ad9" - -[[package]] -name = "icu_locale_fallback" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" - -[[package]] -name = "icu_normalizer" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" - -[[package]] -name = "icu_pattern" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" -dependencies = [ - "displaydoc", - "either", - "serde", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_plurals" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" -dependencies = [ - "fixed_decimal", - "icu_locale_fallback", - "icu_plurals_data", - "icu_provider", - "zerovec", -] - -[[package]] -name = "icu_plurals_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" - -[[package]] -name = "icu_properties" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" - -[[package]] -name = "icu_provider" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_segmenter" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" -dependencies = [ - "icu_collections", - "icu_locale_fallback", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "smallvec", - "utf8_iter", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" - -[[package]] -name = "icu_time" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d45ee651f21433b1c6352311f2ad9b5253c26be0e25d9a0869cb8247b8cfde" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar", - "icu_locale_core", - "icu_provider", - "icu_time_data", - "serde", - "writeable", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_time_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d7d711f44237fa9300406ab6e521148594056862343370b5df265a0d15c088" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "ignore" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "indent" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6" - -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "indicatif" -version = "0.18.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" -dependencies = [ - "console", - "portable-atomic", - "unicode-width 0.2.3", - "unit-prefix", - "web-time", -] - -[[package]] -name = "inherent" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2c455ca60511a054699102d40ce7153621cb2429558f9eaa600e4499b5984" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "inotify" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" -dependencies = [ - "bitflags 2.13.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" -dependencies = [ - "libc", -] - -[[package]] -name = "insta" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd" -dependencies = [ - "console", - "globset", - "once_cell", - "pest", - "pest_derive", - "ron", - "serde", - "similar", - "tempfile", - "walkdir", -] - -[[package]] -name = "intl-memoizer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" -dependencies = [ - "type-map", - "unic-langid", -] - -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-icu" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0635c26744fc68e8fbc6120ba5af632d1eef6f4e951a665509c0a4552caa513f" -dependencies = [ - "icu_calendar", - "icu_time", - "jiff", -] - -[[package]] -name = "jiff-static" -version = "0.2.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "junit-parser" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81427d0aab8e54e19351be5c088187e964ddccad4938b48204eb1e9932d972d6" -dependencies = [ - "quick-xml 0.42.0", - "thiserror 2.0.21", -] - -[[package]] -name = "junit-report" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb201fc2964416320139f702d888df6a39685e648f68e3c910a77cfda99ce44d" -dependencies = [ - "derive-getters", - "quick-xml 0.41.0", - "time", -] - -[[package]] -name = "keccak" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" -dependencies = [ - "cfg-if", - "cpufeatures", -] - -[[package]] -name = "konst" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" -dependencies = [ - "konst_macro_rules", -] - -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - -[[package]] -name = "kqueue" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.13.2", - "libc", -] - -[[package]] -name = "lazy_static" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.191" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" -dependencies = [ - "libc", -] - -[[package]] -name = "libyaml-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "lscolors" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60e266dfb1426eb2d24792602e041131fdc0236bb7007abc0e589acafd60929" -dependencies = [ - "aho-corasick", - "nu-ansi-term", -] - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmap2" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset", - "pin-utils", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - -[[package]] -name = "normalize-path" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0769f77950d14beb4fe8e0707b7c7cc6ea39110e9289e2c2346a7bb4ee5af74" - -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.13.2", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", - "rand 0.8.8", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-modular" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-prime" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b285c575532a33ef6fdd3a57640d0b1c70e6ca48644d6df7bbd4b7a0cfbbb12d" -dependencies = [ - "bitvec", - "either", - "lru", - "num-bigint", - "num-integer", - "num-modular", - "num-traits", - "rand 0.8.8", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "objc2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[package]] -name = "os-release" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f29ae2f71b53ec19cc23385f8e4f3d90975195aa3d09171ba3bef7159bec27" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "os_display" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" -dependencies = [ - "unicode-width 0.2.3", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "owo-colors" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "parse_datetime" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503fd4c8b0685e5eb32a8276533611f63e3b44768e9e8724e73f6852c2542f82" -dependencies = [ - "jiff", - "num-traits", - "winnow", -] - -[[package]] -name = "peg" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" -dependencies = [ - "peg-macros", - "peg-runtime", -] - -[[package]] -name = "peg-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" -dependencies = [ - "peg-runtime", - "proc-macro2", - "quote", -] - -[[package]] -name = "peg-runtime" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" - -[[package]] -name = "pest" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pest_meta" -version = "2.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" -dependencies = [ - "pest", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.8", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" - -[[package]] -name = "platform-info" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa5c6ef5da99ec17edc36267860ddb03504a916e0cc74c0c4dd1c5cdb19637f8" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "difflib", - "float-cmp", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "prettyplease" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" -dependencies = [ - "proc-macro2", - "syn 3.0.9", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "procfs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" -dependencies = [ - "bitflags 2.13.2", - "chrono", - "flate2", - "procfs-core", - "rustix", -] - -[[package]] -name = "procfs-core" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" -dependencies = [ - "bitflags 2.13.2", - "chrono", - "hex", -] - -[[package]] -name = "ptyprocess" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101be273c0b1680d7056afddbaa88f02b6e9f2dc161165c30bee9914b6025a79" -dependencies = [ - "nix 0.26.4", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type", - "nibble_vec", -] - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" -dependencies = [ - "ppv-lite86", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "reedline" -version = "0.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0282eb061a7d039435f10075f22988e0dd7468901ee247a803021786afc662" -dependencies = [ - "chrono", - "crossterm", - "fd-lock", - "nu-ansi-term", - "serde", - "strip-ansi-escapes", - "strum", - "unicode-segmentation", - "unicode-width 0.2.3", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rlimit" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" -dependencies = [ - "libc", -] - -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - -[[package]] -name = "ron" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf" -dependencies = [ - "bitflags 2.13.2", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - -[[package]] -name = "rpds" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e025feb26210bc196b908e72deb063b1b4000754304341cbc168a1e72c857ebc" -dependencies = [ - "archery", - "serde", - "smallvec", -] - -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" -dependencies = [ - "bitflags 2.13.2", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schemars" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" -dependencies = [ - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 3.0.9", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_derive_internals" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_json" -version = "1.0.154" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.11.3", -] - -[[package]] -name = "sha3" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" -dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", -] - -[[package]] -name = "shake" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "sm3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da6a89ba31723d185fd7413b98c576a575f356d9b84729d8ecb6ead60000a5b6" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "smallvec" -version = "1.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" - -[[package]] -name = "sponge-cursor" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strip-ansi-escapes" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" -dependencies = [ - "vte 0.14.1", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - -[[package]] -name = "supports-hyperlinks" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" - -[[package]] -name = "supports-unicode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "sysinfo" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.62.2", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf", - "phf_codegen", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "test-with" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c5885e41a979ecda466cfed636b80467de49e1727fc9755d221a61be95240f1" -dependencies = [ - "test-with-derive", - "which", -] - -[[package]] -name = "test-with-derive" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7801350f687fb6747e957c82cc7c57c1f7a798eaebc95f6c2ff1bbeb0b9c1c37" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn 3.0.9", - "which", -] - -[[package]] -name = "textwrap" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" -dependencies = [ - "icu_segmenter", - "unicode-width 0.2.3", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl 2.0.21", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[package]] -name = "tinystr" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tokio" -version = "1.54.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "toml" -version = "1.2.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.2.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "triomphe" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" - -[[package]] -name = "type-map" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" -dependencies = [ - "rustc-hash", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - -[[package]] -name = "unicode-ident" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "utf16_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7de35d9d8757dbc1f5125dc8b51438c68b4430546a735c3a9b91aaaaf9a18a" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf8-chars" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "utf8_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" - -[[package]] -name = "uu_arch" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce13fa692b9a935828053cf02cceed52a4bfe68ac6a7f0273fc98d94a62a1cc" -dependencies = [ - "clap", - "fluent", - "platform-info", - "uucore", -] - -[[package]] -name = "uu_b2sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa4061f010d64b18f046f6aba14cac3d2dfe4a824816e5d1d30d554c7001c6e" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_base32" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4472f433014202b99debd2907cda4650aafbb6881b8a5dca139b255dceed892" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_base64" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6b72f857b343f12c7f4be39917343a00d214a8250eaf49509cf9df6f6c8ddc" -dependencies = [ - "clap", - "fluent", - "uu_base32", - "uucore", -] - -[[package]] -name = "uu_basename" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b29c8c798465d8001a90b6231554f8e00e9c4cf781a9f7d0b96224dcaac77ecd" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_basenc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c9841519e45b7dd994f5d8d40dc31164759e1476f52935f5d5fca8bd2fd7b79" -dependencies = [ - "clap", - "fluent", - "uu_base32", - "uucore", -] - -[[package]] -name = "uu_cat" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75143ac91809c365ca76bb141ea2a7a35ffdf821046f52e14bd83b710bb5ce34" -dependencies = [ - "clap", - "fluent", - "memchr", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_checksum_common" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475c8f9e5873fb5fc87ff796e07b39b309f64a553232172242014d2208bfe8d" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_cksum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db6b4b87b7d6c92add8ba1a4cfae7355734bc9afbf74d6904e1bc6b05ffcef5" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_comm" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce0974ee091b6dce8b4db465da4869c80dd1208cd38e9ab9f567c03929a25283" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_cp" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4c15f0ba298a490b66abac0fefc3a44ff1b8004c38cf140d27c027f1f80784" -dependencies = [ - "clap", - "filetime", - "fluent", - "indicatif", - "libc", - "nix 0.31.3", - "rustix", - "thiserror 2.0.21", - "uucore", - "walkdir", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_csplit" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55aa4ee843fea3f6ec8ab55c69c39f5758e52f1f00c8b5a6f941001b66d11acb" -dependencies = [ - "clap", - "fluent", - "regex", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_cut" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2087dececd3be61b5671c0978605e0fa9ecbe707294137896efdb2848094326" -dependencies = [ - "bstr", - "clap", - "fluent", - "memchr", - "uucore", -] - -[[package]] -name = "uu_date" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949f0a6c6449604f71c42915ac5ad8ffa87644d403c028e97023bb775483d789" -dependencies = [ - "clap", - "fluent", - "icu_calendar", - "icu_locale", - "jiff", - "jiff-icu", - "jiff-tzdb", - "libc", - "parse_datetime", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_dd" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82610ff943844c3ba8be998f3e964cc75a23cedd42be4956a70bd05ec1d0238c" -dependencies = [ - "clap", - "fluent", - "gcd", - "libc", - "rustix", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_df" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b4991a0996888e1c64872f2fd2eea978102418e0576a58c2e4a0e8c0e716e" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror 2.0.21", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "uu_dir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53753588e40c6e17b817b7dd012bd1d68ca372cf35a50d8dbef8441bd1b27625" -dependencies = [ - "clap", - "uu_ls", - "uucore", -] - -[[package]] -name = "uu_dircolors" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a029149ed7941dffc2e25c2ab56747873e1d1a01b5c8b5bfe7a15c56dc950665" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_dirname" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2affe4ee8747234885830bd8269d747bab7e78d730c0e360920663e5c72fe75" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_du" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae0fca6efbb42021688f47ce7c1ef88059452c6b73b744c0a99304a38973363" -dependencies = [ - "clap", - "fluent", - "glob", - "rustc-hash", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_echo" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f9afb484c165b55a3fee0d17e18921865dc2914feccd5eaed9298810e3e446" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_env" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63d245c1d0f7c48c076bc820491a32020e4a8bcd31e3dcbc35513be279b7ea" -dependencies = [ - "clap", - "fluent", - "nix 0.31.3", - "rust-ini", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_expand" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb12c2abb138ad08d01557ab6fdf2d201e06a0f88122d7d5a283fe2d8273151c" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_expr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601663e752b83657be04ec8c54838310a516dd66d30affc36f02f3cc71f26199" -dependencies = [ - "clap", - "fancy-regex", - "fluent", - "num-bigint", - "num-traits", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_factor" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debab9d4be53ba727176e9ecd9bc669b5ec801549544fb51beffc69247fc27f4" -dependencies = [ - "clap", - "fluent", - "memchr", - "num-bigint", - "num-prime", - "uucore", -] - -[[package]] -name = "uu_false" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dee4cf0e44a93aceab88533e274fa3fac69ed9608a6be564066f298483187b9" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_fmt" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9180eae586d5947c9c85915dfeb14bfdef2316c143c1b713b85a3dd630a3be77" -dependencies = [ - "clap", - "fluent", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_fold" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28755578b0b98318371e055fcc70b94c9198bacfce0d125d3049ab1c2d2beb2" -dependencies = [ - "clap", - "fluent", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "uu_head" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d8bd166467c73ad000c303a0d8c3c3fa03160000bb4493cfd40b8f7816fb95" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_hostname" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c160a28e366d2c1b63b75ea837ebc9be5d3e3901f1a1bb2d7ea44ec9c92d40b3" -dependencies = [ - "clap", - "fluent", - "hostname", - "uucore", -] - -[[package]] -name = "uu_join" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864d33141e4b8684a6af365202f8ac0dc73a2f08ea26a3e2929de66ffbe5dcb2" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_link" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50af1219ea0db13447ef438d3ab47ad1bbe9823286076b58149b8c06ef9d43d6" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_ln" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a942fbe008800998bdde259ae54c73b61febce9f7a35b7cf734bdeae40d3f6" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_ls" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8f956231b30edd22f1c0ef803d6928da0d7cb5f07add01a4d15e5b2d4a2540" -dependencies = [ - "ansi-width", - "clap", - "fluent", - "glob", - "hostname", - "lscolors", - "rustc-hash", - "terminal_size", - "thiserror 2.0.21", - "uucore", - "uutils_term_grid", -] - -[[package]] -name = "uu_md5sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18aab2c28bf8a1d596a08ee9ad94ab9f56d1df7acae43bb8cadec09e09f7b4e4" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_mkdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "665e031110e8de62ebe483eb50b21c19f57c6f02c571e3c57cfb6cf344209370" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_mktemp" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71146f15c55f02214362b2357630e0b8078e249657b234b52f25469dbbead79b" -dependencies = [ - "clap", - "fluent", - "rand 0.10.3", - "tempfile", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_more" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf56076f34c0d91d2821986c5393d31d57542f944f93c466530b62dd8909c134" -dependencies = [ - "clap", - "crossterm", - "fluent", - "uucore", -] - -[[package]] -name = "uu_mv" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5108b8ea076d2d45f2aa4e546c0fa2cecd25fb2f5b44aa56a95c15d59c9da7c8" -dependencies = [ - "clap", - "fluent", - "indicatif", - "libc", - "nix 0.31.3", - "rustc-hash", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_nl" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ab04808b361b764c96380882db6e593d12c7ae5d62582fcee0ca670ba356349" -dependencies = [ - "clap", - "fluent", - "itoa", - "regex", - "uucore", -] - -[[package]] -name = "uu_nproc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aeceedaa99805ec023954728d321fa85229df46943af2a9cb0bc60dedb3a7f7" -dependencies = [ - "clap", - "fluent", - "libc", - "rustix", - "uucore", -] - -[[package]] -name = "uu_numfmt" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c14711615381c8311fa70a69e8a96175748ea97a8c0972be7efb5fe9323c58b1" -dependencies = [ - "clap", - "fluent", - "memchr", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_od" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f163305dfac8638f14e110666bd632f12e99c637382bbcbeeee63c771476c79e" -dependencies = [ - "clap", - "fluent", - "half", - "libc", - "rustix", - "uucore", -] - -[[package]] -name = "uu_paste" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edddacb62a1b7058ec9f33974cb4dbf677171b83d710e153764e6e5995fffaeb" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_pr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a86126634ad64f323a8e2d487fc7fa3b92b1b251a08eef631c113ae57d112678" -dependencies = [ - "clap", - "fluent", - "itertools 0.15.0", - "memchr", - "regex", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_printenv" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7f1090fe0d0ac3e8b7a79cd48830f2f93577329d2ee739d84bb0d893889ccc" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_printf" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ad2cacf36fe7936f5bda5a4c259f6961e3e5f77cf04db26e1922ea7dffad195" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_ptx" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a31e18a9e4519f59376c0fe3667bdaaf2ce3cb0fb100256e3968fe4ac9296ea" -dependencies = [ - "clap", - "fluent", - "regex", - "rustc-hash", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_pwd" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905aa087f76dcfbd22cf9549f56d2713be97f80d68a7638334c8afc91ae528f9" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_readlink" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4390796d1f70cfd2393ce3d6c8ba855352d5057e57f6527bca80a8638dd37d07" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_realpath" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf967086871590e1d92d32e402b152045cd2c09156cbd877c97b6a7d82aabf0" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_rm" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16cd2b5f4e7647a10a316c001b804e688a80aba77685578cdf11bdf4b31f780" -dependencies = [ - "clap", - "fluent", - "indicatif", - "libc", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_rmdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5df882bcefc05b9e4d915a51ffe8475b3b2baabb07d04c9708c5b8b6fbf5d" -dependencies = [ - "clap", - "fluent", - "libc", - "uucore", -] - -[[package]] -name = "uu_seq" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad149ba156667c2ce8d1d87411e091632db5608d5c8777fbe1a134856e89eae0" -dependencies = [ - "bigdecimal", - "clap", - "fluent", - "num-bigint", - "num-traits", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_sha1sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08cf227c5ea9b50b7995ffae6103e575d8ce15744a37a5849adf319573eaa86b" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha224sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961716db2eefd48b450fd837881b110568de7761acbfe4a92abe49b3f5899253" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha256sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c065f8ea49bf27afcdbb9d5c28d94c5690955e89f2b9076c88e03faa3c84d183" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha384sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a5a28caaed1f09d6980287a77d522f8192378c36c98a23293085b3a4c597d22" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_sha512sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d820ee09c795721569ef812b53a4701cc44c3466c16adc0261d4aff3f1bd99d" -dependencies = [ - "clap", - "fluent", - "uu_checksum_common", - "uucore", -] - -[[package]] -name = "uu_shred" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9a6aa2c9854085579e44f5c5955f23c2d2a0630b8b633585ca61248ed68a6c" -dependencies = [ - "clap", - "fluent", - "libc", - "rand 0.10.3", - "uucore", -] - -[[package]] -name = "uu_shuf" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05bbc6c1d00e2c28003b1d4d3aebd28faeb5eb7028458704ce07de80dff6a64a" -dependencies = [ - "clap", - "fluent", - "itoa", - "rand 0.10.3", - "rand_chacha 0.10.0", - "rustc-hash", - "sha3", - "uucore", -] - -[[package]] -name = "uu_sleep" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "015cf50857143f9a946e9194960cdba7da0b4a17cdfae0bec1128b84e6cdb552" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_sort" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45084032c01910cb5d0515acbcea8ee6377d07e46e6fc84afde79fbd9dc6c6f8" -dependencies = [ - "bigdecimal", - "clap", - "ctrlc", - "fluent", - "foldhash 0.2.0", - "itertools 0.15.0", - "libc", - "memchr", - "rand 0.10.3", - "rayon", - "rustix", - "self_cell", - "tempfile", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_split" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8b146825e117f1b5574250f5a60590a4e02885a425aad65fcea557da301e3e8" -dependencies = [ - "clap", - "fluent", - "memchr", - "rustix", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_sum" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4053123109cfaa474967027a3ab74e0c1e37177db80f278960c80c2adf3a58cf" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_sync" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b7e40a16222e557795012311211f161390babaf7fe6bb945748b6456a6aaf39" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tac" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ec0679c585e00258cd3c70fca14ad1d5220c02c7fdb56aa513003f6fdd73ba" -dependencies = [ - "clap", - "fluent", - "libc", - "memchr", - "memmap2", - "regex", - "tempfile", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_tail" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04ff141dd3fab47b991b2063fb8c1c9ff5ca4f9f23f5bcba75c691a247e1aca4" -dependencies = [ - "clap", - "fluent", - "libc", - "memchr", - "notify", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tee" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97e59ca8e741e2e8e095898ec01795674a907ee0991883bd5f6313fd688b9d66" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", -] - -[[package]] -name = "uu_test" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9007d3a30901a0dba2e28c017aba9edb2aa26c919946ae84c770446067e98304" -dependencies = [ - "clap", - "fluent", - "libc", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_touch" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1784987c9ae4beeb3153c173e5866e1907752180289f6d219cc6c1223ae7a5f0" -dependencies = [ - "clap", - "filetime", - "fluent", - "jiff", - "libc", - "parse_datetime", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_tr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc8f860fa347eaf1505a3da8ac292559980c1103b5978ba4f1142aff0755b00" -dependencies = [ - "bytecount", - "clap", - "fluent", - "nom 8.0.0", - "uucore", -] - -[[package]] -name = "uu_true" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7a03ae2661faa57115019fe88ce91b6d8143c6b76930460b06a949de8f8aaa" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_truncate" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1991acfea078be5657c18bab731d77d083bb7facf5157c2567e53f2a6d0b9727" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_tsort" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1a4fbe486b87a15e06e8b00230b6c47eaa15d095bc3fc4cc122d116aae62e0" -dependencies = [ - "clap", - "fluent", - "hashbrown 0.17.1", - "memchr", - "rustc-hash", - "rustix", - "thiserror 2.0.21", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_uname" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53566dcd5e385c71e1fb277f1ebd5db0d3f04795fa07b58ccb038a440e28c906" -dependencies = [ - "clap", - "fluent", - "platform-info", - "uucore", -] - -[[package]] -name = "uu_unexpand" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4bd141cb78bd0e7611ff4b3040901d3bafbe45621bdd2372484848e5a2b1f34" -dependencies = [ - "clap", - "fluent", - "rustix", - "thiserror 2.0.21", - "uucore", -] - -[[package]] -name = "uu_uniq" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c59563ff6419f674e2cd5b50cef9f3a0f0e73a06291dd635c006fc253ad268" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_unlink" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3ca22bbb2d23bdd136ec8a6260c12c2550f9ae44c679323bee246bf460494b" -dependencies = [ - "clap", - "fluent", - "uucore", -] - -[[package]] -name = "uu_vdir" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec245d6aaaae422622318b1a2e98e05709d346e5d7f2bf98f4f49329fb6f4418" -dependencies = [ - "clap", - "uu_ls", - "uucore", -] - -[[package]] -name = "uu_wc" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45457ac8613955d62a62e2cea12aab7023a4d015c24a30b4719ee9138d8598d" -dependencies = [ - "bytecount", - "clap", - "fluent", - "libc", - "rustix", - "thiserror 2.0.21", - "unicode-width 0.2.3", - "uucore", -] - -[[package]] -name = "uu_whoami" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1267f81972136719195877c21cefd2fb1bc821c8fb692d16f8b0ce4b717108c" -dependencies = [ - "clap", - "fluent", - "rustix", - "uucore", - "windows-sys 0.61.2", -] - -[[package]] -name = "uu_yes" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a00721c606207353591d7d5a4fe6c89d94073decccd061392f3f0dea1c37af4" -dependencies = [ - "clap", - "fluent", - "itertools 0.15.0", - "rustix", - "uucore", -] - -[[package]] -name = "uucore" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f24a5910ebbf2c0baf486a9125705f8e026a8f7741a3ea8cf5eca3c9257c9bdf" -dependencies = [ - "base64-simd", - "bigdecimal", - "blake2b_simd", - "blake3", - "bstr", - "clap", - "crc-fast", - "data-encoding", - "data-encoding-macro", - "digest 0.11.3", - "dunce", - "fluent", - "fluent-syntax", - "glob", - "hex", - "icu_calendar", - "icu_collator", - "icu_datetime", - "icu_decimal", - "icu_locale", - "icu_provider", - "itertools 0.15.0", - "jiff", - "jiff-icu", - "libc", - "md-5", - "memchr", - "nix 0.31.3", - "num-traits", - "os_display", - "procfs", - "rustc-hash", - "rustix", - "sha1", - "sha2", - "sha3", - "shake", - "sm3", - "thiserror 2.0.21", - "unic-langid", - "unicode-width 0.2.3", - "unit-prefix", - "uucore_procs", - "walkdir", - "wild", - "windows-sys 0.61.2", - "xattr", - "z85", -] - -[[package]] -name = "uucore_procs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50aa1f3bba55168888ae6f1f2714ce58897d8020d742d21f474f01e86468fedd" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "uutils_term_grid" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70bdc0434ef5f30856fbe0908a7d96568f8b212a28c20b16d44851f753e465f" -dependencies = [ - "ansi-width", -] - -[[package]] -name = "uzers" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" -dependencies = [ - "libc", - "log", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "vt100" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb05cdd19ae8707bf6a0a269972c07e6358103a7079faec749dec318a5b3dc23" -dependencies = [ - "itoa", - "unicode-width 0.2.3", - "vte 0.15.0", -] - -[[package]] -name = "vte" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" -dependencies = [ - "memchr", -] - -[[package]] -name = "vte" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" -dependencies = [ - "arrayvec", - "memchr", -] - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" -dependencies = [ - "cfg-if", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" -dependencies = [ - "bitflags 2.13.2", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "which" -version = "8.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" -dependencies = [ - "libc", -] - -[[package]] -name = "whoami" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - -[[package]] -name = "wild" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" -dependencies = [ - "glob", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow-args" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfdd2eeff4a8c3f3628a429821f0683a6a7277306fbc6a806643e50fab74532" -dependencies = [ - "terminal_size", - "winnow", - "winnow-args-derive", -] - -[[package]] -name = "winnow-args-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a4317df522e25332010cf1ef5aec53f1a1e996690c9a18cfdfb5597f48fab16" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "winnow-args-man" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e894b630ba53e83262e666fbc2547032445a956caa2bda14c978bb7eee23d6" -dependencies = [ - "roff", - "winnow-args-spec", -] - -[[package]] -name = "winnow-args-markdown" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7b08f880d5f8d60010c0fff8c30cf082c6eb8b3431e91eae5ae3fac0237050" -dependencies = [ - "winnow-args-spec", -] - -[[package]] -name = "winnow-args-spec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fffe4bc58f1ea37f80ae144051d8dd5233d580815dba1355b6fbaadf68c330" -dependencies = [ - "toml", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -dependencies = [ - "bitflags 2.13.2", - "futures", - "once_cell", - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease 0.2.37", - "syn 2.0.119", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" -dependencies = [ - "anyhow", - "prettyplease 0.2.37", - "proc-macro2", - "quote", - "syn 2.0.119", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" -dependencies = [ - "anyhow", - "bitflags 2.13.2", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.239.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -dependencies = [ - "either", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "xattr" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "xshell" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" -dependencies = [ - "xshell-macros", -] - -[[package]] -name = "xshell-macros" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" - -[[package]] -name = "xtask" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "junit-parser", - "libc", - "serde", - "serde_json", - "tempfile", - "winnow-args", - "winnow-args-man", - "winnow-args-markdown", - "winnow-args-spec", - "xshell", -] - -[[package]] -name = "yaml_serde" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" -dependencies = [ - "indexmap", - "itoa", - "libyaml-rs", - "ryu", - "serde", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "z85" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675" - -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/userland/brush/README.md b/userland/brush/README.md new file mode 100644 index 0000000..c8922b5 --- /dev/null +++ b/userland/brush/README.md @@ -0,0 +1,22 @@ +# The world shell + +`source/` contains Brush at upstream revision `943553433ae04f5b3bd55d2cf207e39eb9ef2af7` from . +The upstream MIT license and notices remain in that directory. +All Brush crates belong to the repository's root Cargo workspace. +The root `Cargo.lock` covers their dependencies. + +The checked-in Sausage changes replace the WASI process launcher and path lookup. +They use the process interface in `brush-core/sausage-process/`. +The process launcher uses a timer so that host tasks can supply filesystem and stream data. +The shell also records its pinned source revision without a Git command during compilation. +The root manifest supplies the upstream workspace metadata and lint settings. + +Build the shell from the repository root: + +```sh +nu userland/cwd-init/build.nu +nu userland/brush/build.nu +``` + +`bundle.nu` invokes the same build and installs the result as `sh` and `bash`. +The build does not fetch, copy, or patch source files. diff --git a/userland/brush/build.nu b/userland/brush/build.nu index f5318d7..efba029 100644 --- a/userland/brush/build.nu +++ b/userland/brush/build.nu @@ -1,44 +1,10 @@ #!/usr/bin/env nu -# Build the pinned brush source with only its WASI launcher seam replaced. -def copy-if-changed [source: path, destination: path] { - if not ($destination | path exists) or (open --raw $source) != (open --raw $destination) { - cp $source $destination - } -} +# Build the checked-in shell from the shared workspace for WASI. def main [] { let here = $env.FILE_PWD - let source = ($here | path join source) - let revision = '943553433ae04f5b3bd55d2cf207e39eb9ef2af7' - if not ($source | path exists) { - ^git clone --no-checkout https://github.com/reubeno/brush $source - if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush clone failed'} } - ^git -C $source checkout $revision - if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush checkout failed'} } - } - if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} } - let core = ($source | path join brush-core) - copy-if-changed ($here | path join process.rs) ($core | path join src sys wasm process.rs) - copy-if-changed ($here | path join lookup.rs) ($core | path join src sys wasm sausage-lookup.rs) - let fs = ($core | path join src sys wasm fs.rs) - let fs_text = (open --raw $fs) - if not ($fs_text | str contains 'include!("sausage-lookup.rs")') { - $fs_text + "\ninclude!(\"sausage-lookup.rs\");\n" | save --force $fs - } - mkdir ($core | path join sausage-process) - let wit = ($here | path join .. .. host wit | path expand) - copy-if-changed ($wit | path join process.wit) ($core | path join sausage-process process.wit) - mkdir ($core | path join sausage-process deps) - copy-if-changed ($wit | path join deps io.wit) ($core | path join sausage-process deps io.wit) - let manifest = ($core | path join Cargo.toml) - let text = (open --raw $manifest) - let marker = "[target.'cfg(target_family = \"wasm\")'.dependencies]" - if not ($text | str contains 'wit-bindgen =') { - $text | str replace $marker ($marker + "\nwit-bindgen = \"0.46\"") | save -f $manifest - } - copy-if-changed ($here | path join Cargo.lock) ($source | path join Cargo.lock) - $env.SAUSAGE_CWD_INIT = ($here | path join .. cwd-init init.o | path expand) - $env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($here | path join .. cwd-init link.nu | path expand) + $env.SAUSAGE_CWD_INIT = ($env.SAUSAGE_CWD_INIT? | default ($here | path join .. cwd-init init.o | path expand)) + $env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($env.CARGO_TARGET_WASM32_WASIP2_LINKER? | default ($here | path join .. cwd-init link.nu | path expand)) $env.CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = '-C link-arg=sausage-cwd-init' - ^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked + ^cargo build --manifest-path ($here | path join .. .. Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} } } diff --git a/userland/brush/source/.cargo/config.toml b/userland/brush/source/.cargo/config.toml new file mode 100644 index 0000000..1b5132a --- /dev/null +++ b/userland/brush/source/.cargo/config.toml @@ -0,0 +1,10 @@ +[alias] +xtask = "run --package xtask --" + +[target.wasm32-unknown-unknown] +# Select a getrandom backend that will work for this target +rustflags = ["--cfg", 'getrandom_backend="wasm_js"'] + +[target.wasm32-wasip3] +# Uninteresting flag to ensure this overrides the flags in [build] +rustflags = ["--cfg", "x"] diff --git a/userland/brush/source/.cliffignore b/userland/brush/source/.cliffignore new file mode 100644 index 0000000..fafa76f --- /dev/null +++ b/userland/brush/source/.cliffignore @@ -0,0 +1,9 @@ +# Commits not to include in changelog + +a8ba7982d9ca3746cd6f6e3c26783e9d1d765466 + +448e60c51cb721bd6991c00bc39e926c7a1ffdac +6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19 +5400c8267db15ed56b5758a6fc35667678a5c856 +87ad64f9ae2044d1aa70effd50244491a765c33e +6567ede8f0a9be622176dc5e840e5b93344aeb15 diff --git a/userland/brush/source/.config/ast-grep/rule-tests/platform-cfg-outside-sys-test.yml b/userland/brush/source/.config/ast-grep/rule-tests/platform-cfg-outside-sys-test.yml new file mode 100644 index 0000000..99f2ba9 --- /dev/null +++ b/userland/brush/source/.config/ast-grep/rule-tests/platform-cfg-outside-sys-test.yml @@ -0,0 +1,21 @@ +id: platform-cfg-outside-sys +valid: + - "#[cfg(test)] fn f() {}" + - '#[cfg(feature = "unix")] fn f() {}' + - "// #[cfg(unix)]" + - "fn f() -> &'static str { std::env::consts::OS }" +invalid: + - "#![cfg(windows)]" + - "#[cfg(unix)] fn f() {}" + - '#[cfg(all(test, target_os = "linux"))] fn f() {}' + - '#[cfg(not(target_family = "wasm"))] fn f() {}' + - '#[cfg(target_pointer_width = "64")] fn f() {}' + - "#[cfg_attr(unix, derive(Debug))] struct S;" + - "struct S { #[cfg(windows)] x: u8 }" + - "fn f(v: u8) { match v { #[cfg(unix)] 0 => {} _ => {} } }" + - "fn f() { #[cfg(windows)] let x = 1; }" + - 'fn f() -> bool { cfg!(target_os = "macos") }' + - "fn f() { cfg_select! { windows => {} _ => {} } }" + - "cfg_if::cfg_if! { if #[cfg(unix)] { fn f() {} } }" + - "macro_rules! m { () => { #[cfg(unix)] fn f() {} } }" + - "fn f() { vec![#[cfg(unix)] 1]; }" diff --git a/userland/brush/source/.config/ast-grep/rules/platform-cfg-outside-sys.yml b/userland/brush/source/.config/ast-grep/rules/platform-cfg-outside-sys.yml new file mode 100644 index 0000000..0bad064 --- /dev/null +++ b/userland/brush/source/.config/ast-grep/rules/platform-cfg-outside-sys.yml @@ -0,0 +1,40 @@ +# Platform-specific code lives in brush_core::sys, so the rest of brush-core +# stays platform-neutral. Outside sys, platform code can only exist behind a +# target cfg -- otherwise some cross-compiled CI target fails to build -- so +# flagging those cfgs is enough to keep it out. +# +# rustc's target cfgs are `unix`, `windows`, and the `target_*` keys +# (target_os, target_family, target_arch, ...); every other platform is a value +# of one of those keys. Runtime checks such as `std::env::consts::OS` aren't +# cfgs and aren't covered. +id: platform-cfg-outside-sys +language: rust +severity: error +message: Platform-specific code belongs in brush_core::sys. +note: Move it behind an API in brush_core::sys. +files: ["brush-core/src/**/*.rs"] +ignores: ["brush-core/src/sys.rs", "brush-core/src/sys/**"] +utils: + platform-predicate: + kind: token_tree + has: + kind: identifier + regex: ^(unix|windows|target_[a-z_]+)$ + stopBy: end +rule: + any: + # #[cfg(...)], #![cfg(...)], #[cfg_attr(...)] + - kind: attribute + all: + - has: { kind: identifier, regex: ^cfg(_attr)?$ } + - has: { matches: platform-predicate } + # cfg!(...), cfg_select! { ... } + - kind: macro_invocation + all: + - has: { field: macro, regex: (^|::)(cfg|cfg_select)$ } + - has: { matches: platform-predicate } + # #[cfg(...)] inside a macro body (cfg_if!, macro_rules!, ...), where it's + # unparsed tokens rather than an attribute + - kind: token_tree + regex: ^\[\s*cfg(_attr)?\s*\( + has: { matches: platform-predicate } diff --git a/userland/brush/source/.config/nextest.toml b/userland/brush/source/.config/nextest.toml new file mode 100644 index 0000000..34500a2 --- /dev/null +++ b/userland/brush/source/.config/nextest.toml @@ -0,0 +1,8 @@ +[profile.default] +retries = 3 +status-level = "slow" +slow-timeout = { period = "5s", terminate-after = 3, grace-period = "2s" } +fail-fast = false + +[profile.default.junit] +path = "test-results.xml" diff --git a/userland/brush/source/.devcontainer/Dockerfile b/userland/brush/source/.devcontainer/Dockerfile new file mode 100644 index 0000000..a9cc306 --- /dev/null +++ b/userland/brush/source/.devcontainer/Dockerfile @@ -0,0 +1,55 @@ +FROM ubuntu:latest + +ARG USERNAME=devel +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Install basic utilities and prerequisites. +# bash-completion - for completion testing +# bsdmainutils - provides hexdump, used by integration tests +# neovim - for convenience and modern editing +RUN apt-get update -y && \ + apt-get install -y \ + bash \ + bash-completion \ + bsdmainutils \ + build-essential \ + curl \ + git \ + iputils-ping \ + language-pack-en \ + neovim \ + sed \ + sudo \ + wget + +# Install gh cli +# Reference: https://github.com/cli/cli/blob/trunk/docs/install_linux.md +RUN mkdir -p -m 755 /etc/apt/keyrings && \ + wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && \ + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ + apt-get update -y && \ + apt-get install -y gh + +# Add a non-root user that we'll do our best to use for development. +RUN userdel ubuntu && \ + groupadd --gid $USER_GID $USERNAME && \ + useradd --uid $USER_UID --gid $USER_GID -m $USERNAME && \ + echo "devel ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Switch to user. +USER devel + +# Set up path to include rust components. +ENV PATH="${PATH}:/home/devel/.cargo/bin" + +# Copy scripts to temp dir. +WORKDIR /tmp + +# Install rust toolchain and cargo tools. +COPY install-rust-tools.sh . +RUN ./install-rust-tools.sh + +# Switch back to home dir for normal usage. +WORKDIR /home/devel diff --git a/userland/brush/source/.devcontainer/devcontainer.json b/userland/brush/source/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4977471 --- /dev/null +++ b/userland/brush/source/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "build": { + "dockerfile": "./Dockerfile", + "context": "." + }, + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml" + ] + } + }, + "features": {}, + "postCreateCommand": "rustup component add llvm-tools-preview", + "remoteUser": "devel", + "updateRemoteUserUID": true +} diff --git a/userland/brush/source/.devcontainer/install-rust-tools.sh b/userland/brush/source/.devcontainer/install-rust-tools.sh new file mode 100755 index 0000000..cffd3f2 --- /dev/null +++ b/userland/brush/source/.devcontainer/install-rust-tools.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# Install rustup and needed components +# Reference: https://rustup.rs/ +curl https://sh.rustup.rs -sSf | sh -s -- -y +rustup component add llvm-tools-preview + +# Install cargo binstall +# Reference: https://github.com/cargo-bins/cargo-binstall +curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash + +# Install cargo tools using binstall +cargo binstall --no-confirm cargo-audit +# cargo binstall --no-confirm cargo-flamegraph +cargo binstall --no-confirm cargo-llvm-cov +cargo binstall --no-confirm cargo-nextest + +# prek runs the linters pinned in .pre-commit-config.yaml; `cargo xtask ci full` +# needs it for the pre-commit hooks step. +cargo binstall --no-confirm prek diff --git a/userland/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml b/userland/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml new file mode 100644 index 0000000..e0fd213 --- /dev/null +++ b/userland/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml @@ -0,0 +1,86 @@ +name: Compatibility Bug Report +description: Report a case where brush behaves differently from bash +labels: ["bug", "area: compat"] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve brush's compatibility! + + This template is for reporting cases where brush produces different behavior than bash for the same input. Before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if this has already been reported. + + - type: input + id: brush-version + attributes: + label: brush version + description: The version of brush you're using. You can find this with `brush --version`. + placeholder: "e.g., brush 0.2.9" + validations: + required: true + + - type: input + id: bash-version + attributes: + label: bash version (for comparison) + description: The version of bash you compared against, if applicable. You can find this with `bash --version`. + placeholder: "e.g., GNU bash, version 5.2.21(1)-release" + validations: + required: false + + - type: dropdown + id: platform + attributes: + label: Platform / OS + options: + - Linux + - macOS + - WSL + - Windows + - Other + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: > + Provide the shell commands, script, or other usage details that + reproduced the issue. If you can narrow it down to a minimal + reproducing example, that's very helpful but not required. + placeholder: | + # Example: + brush -c 'some command' + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior (brush) + description: What did brush do in this case? + placeholder: Describe or paste the brush output... + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: > + What did you expect to happen? If relevant, what does bash or another + comparable shell do in this case? + placeholder: Describe or paste the expected output or behavior + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: > + Is there anything else you think is important to share? You might consider + including additional environment specifics (e.g., relevant .bashrc config), + workarounds, related issues, any additional debugging you've performed, etc. + validations: + required: false diff --git a/userland/brush/source/.github/ISSUE_TEMPLATE/config.yml b/userland/brush/source/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..83e7647 --- /dev/null +++ b/userland/brush/source/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Discussion + url: https://github.com/reubeno/brush/discussions + about: Ask questions, share ideas, or discuss brush in GitHub Discussions. diff --git a/userland/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml b/userland/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..7a3283f --- /dev/null +++ b/userland/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,52 @@ +name: Feature Request +description: Suggest a new feature or enhancement for brush +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest an improvement to brush! We welcome and appreciate all input and feedback. While we can't commit to prioritizing or implementing all such requests, we'd rather you make the suggestion than not. + + Our only requests: (1) please ensure you follow our project's [Code of Conduct](https://github.com/reubeno/brush#coc-ov-file), and (2) before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if a similar request already exists. If it does, feel free to add a comment to the existing issue instead. + + - type: textarea + id: description + attributes: + label: Feature description + description: A clear and concise description of the feature or enhancement you'd like. + placeholder: Describe the feature... + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use case / motivation + description: | + Why is this feature important to you? What problem does it solve? How would you use it? + + Providing context helps us understand the value and prioritize accordingly. + placeholder: Explain why this feature would be useful... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: > + If you have ideas on how this could be implemented or how it should work, + share them here. Note that this is purely optional. + placeholder: Describe how you think this could work... + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: > + Any other context, references, or screenshots that would help us understand your request. + For example, links to relevant documentation, other shells that implement this, etc. + validations: + required: false diff --git a/userland/brush/source/.github/ISSUE_TEMPLATE/other.yml b/userland/brush/source/.github/ISSUE_TEMPLATE/other.yml new file mode 100644 index 0000000..cf00102 --- /dev/null +++ b/userland/brush/source/.github/ISSUE_TEMPLATE/other.yml @@ -0,0 +1,19 @@ +name: Other Issue +description: "Report an issue that doesn't fit the other templates" +labels: ["state: triage"] +body: + - type: markdown + attributes: + value: | + If your issue is a **feature request**, please use the [Feature Request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) template instead. If it's about **brush behaving differently from bash** or another compatibility issue, please use the [Compatibility Bug Report](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml) template instead. + + For general questions or discussion, consider using [GitHub Discussions](https://github.com/reubeno/brush/discussions). + + - type: textarea + id: description + attributes: + label: Description + description: Please describe the issue in detail. + placeholder: Describe the issue... + validations: + required: true diff --git a/userland/brush/source/.github/copilot-instructions.md b/userland/brush/source/.github/copilot-instructions.md new file mode 100644 index 0000000..a815d00 --- /dev/null +++ b/userland/brush/source/.github/copilot-instructions.md @@ -0,0 +1,405 @@ +# GitHub Copilot Coding Agent Instructions for brush + +## Project Overview + +**brush** (Bourne Rusty Shell) is a POSIX- and bash-compatible shell implemented in Rust. It's a multi-crate workspace (~60K lines of Rust code) targeting Linux, macOS, and WSL, with experimental Windows and WASM support. The project emphasizes compatibility testing against bash as an oracle. + +**Key Stats:** Rust 2024 edition, 5 main crates, 1500+ compatibility test cases, published to crates.io. The workspace MSRV is the `rust-version` under `[workspace.package]` in the root `Cargo.toml`; the application-tier crates (brush, brush-shell, brush-interactive, brush-fuzz) declare a higher one in their own manifests. See `docs/reference/msrv-policy.md`. + +## Critical: Read AGENTS.md First + +**BEFORE making any changes, read `/AGENTS.md`.** It contains detailed architecture patterns, testing workflows, and development guidelines specific to this project. The information below supplements (not replaces) AGENTS.md. + +Before investigating or implementing, also read the canonical +[independent-implementation and provenance policy](../CONTRIBUTING.md#independent-implementation-and-provenance) +required by `AGENTS.md`. Apply it to delegated agents and their tools. + +## Code Review Checklist + +When reviewing PRs, verify: + +- [ ] **Documentation**: All exported APIs have rustdoc comments (missing docs = CI failure) +- [ ] **Forbidden patterns**: No `panic`, `unwrap_in_result`, `expect_used`, or `todo` (all denied by clippy) +- [ ] **Error handling**: Uses `thiserror` for crate errors; `anyhow` only in tests +- [ ] **Logging**: Uses `tracing::debug!(target: trace_categories::CATEGORY, "msg")` pattern +- [ ] **Testing**: Compatibility fixes include YAML test cases in `brush-shell/tests/cases/` +- [ ] **Testing**: Builtin changes have tests in `brush-shell/tests/cases/builtin/` +- [ ] **Testing**: Unit tests expected for new public APIs (when feasible) (see AGENTS.md section 2) +- [ ] **Platform code**: Platform-specific code is in `brush-core/src/sys/` modules +- [ ] **Breaking changes**: Public API changes are clearly highlighted and documented +- [ ] **Builder pattern**: Configuration uses builder pattern (see `Shell::builder()`) +- [ ] **Code quality**: Passes `cargo fmt --check` and `cargo clippy` without warnings +- [ ] **Commit format**: Follows [Conventional Commits](https://www.conventionalcommits.org/) (feat:, fix:, docs:, test:) +- [ ] **Dependencies**: No unnecessary cloning (use references when possible) +- [ ] **Cross-platform**: Uses appropriate `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")` + +## Workspace Structure + +``` +brush/ +├── brush-shell/ # CLI application & main entry point +├── brush-interactive/ # Interactive shell (readline, completion) +├── brush-core/ # Core shell runtime & builtins +├── brush-builtins/ # Shell builtin implementations +├── brush-parser/ # AST generation & parsing +├── xtask/ # Build automation tasks +└── docs/ # Diátaxis-structured documentation +``` + +**Dependency flow:** brush-shell → brush-interactive → brush-core → brush-parser + ↘ brush-builtins ↗ + +## Build & Validation Commands + +### Terminal Command Execution + +When running commands that may take more than a few seconds (cargo build, cargo check, cargo test, cargo clippy, cargo xtask, etc.), **run them in background mode and poll for results** rather than blocking. This prevents commands from being cancelled due to timeouts. Use `isBackground: true` with `run_in_terminal`, then use `get_terminal_output` to check results. + +### Using xtask (Recommended) + +The project provides a `cargo xtask` command that centralizes common development tasks. This is the recommended approach for running checks and tests. + +#### Quick Development Cycle + +```bash +# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests +cargo xtask ci quick + +# Run the full workflow (~60s warm): quick + pre-commit hooks, schemas, integration tests +cargo xtask ci full + +# Run with --continue-on-error to see all failures at once +cargo xtask ci full -k + +# Add -v for verbose output showing exact commands being run +cargo xtask -v ci full +``` + +`cargo xtask ci quick` needs only the Rust toolchain. `cargo xtask ci full` also +needs `prek` on `PATH` (see CONTRIBUTING.md for install commands). If `prek` is +unavailable in your environment, run `cargo xtask ci full --no-hooks`; CI +still runs the hooks. + +#### Individual Checks + +```bash +# Format check +cargo xtask check fmt + +# Lint check (clippy) +cargo xtask check lint + +# Pre-commit hooks (file hygiene, typos, zizmor, lychee, cargo-deny) +cargo xtask check hooks + +# One hook, by id or alias +cargo xtask check hooks deps + +# Build check +cargo xtask check build + +# Schema check (regenerates and diffs) +cargo xtask check schemas +``` + +#### Running Tests + +```bash +# Run unit tests (fast tests excluding integration binaries) +cargo xtask test unit + +# Run integration tests (all workspace tests including compat tests) +cargo xtask test integration + +# Run tests with coverage +cargo xtask test integration --coverage --coverage-output codecov.xml +``` + +### Manual Approach (Alternate) + +For finer-grained control or when xtask isn't available: + +#### Quick Development Cycle (Use These Frequently) + +```bash +# Fast syntax/type checking (< 5 seconds) +cargo check --workspace + +# Package-specific checking (even faster) +cargo check --package brush-core + +# Format code (ALWAYS run before committing) +cargo fmt --all + +# Lint code (ALWAYS run before committing) +cargo clippy --workspace --all-features --all-targets + +# Run package-specific tests (fast iteration) +cargo test --package brush-parser +cargo test --package brush-core +``` + +### Comprehensive Testing Workflow + +Follow this **exact order** for efficient testing: + +1. **Inner loop** (during development): + ```bash + cargo check --package + cargo test --package + ``` + +2. **Compatibility tests** (critical for shell behavior): + ```bash + cargo test --test brush-compat-tests + + # Run specific test case: + cargo test --test brush-compat-tests -- 'builtin/echo' + ``` + +3. **Full workspace tests** (before considering work complete): + ```bash + cargo test --workspace + ``` + +**Test timing:** Package tests: 3-20 seconds. Compat tests: ~18 seconds build + test time. Full workspace: several minutes. + +### Pre-Commit Validation (Before Every Commit) + +**Recommended:** Run the xtask quick workflow: + +```bash +cargo xtask ci quick +``` + +**Manual approach:** Run these before every commit: + +```bash +cargo fmt --check --all +cargo clippy --workspace --all-features --all-targets +``` + +### Pre-PR Validation (Before Opening Pull Request) + +**Recommended:** Run the full workflow, which includes the full test suite: + +```bash +cargo xtask ci full +``` + +**Manual approach:** In addition to the full workflow, also run: + +```bash +cargo test --workspace +``` + +### Pre-Finish Quality Gates (Run Before Completing Task) + +**Recommended:** Run the xtask full workflow, which covers all essential checks: + +```bash +cargo xtask ci full +``` + +**Manual approach:** + +```bash +cargo test --test brush-compat-tests +cargo clippy --workspace --all-features --all-targets +cargo fmt --check --all +cargo test --workspace +cargo xtask check hooks # typos, zizmor, lychee, cargo-deny, file hygiene (run LAST, not frequently) +``` + +**Timing note:** `cargo xtask check hooks` takes a few seconds once its hook environments are cached; the first run builds cargo-deny from source. Only run as a final validation step, and skip it with `cargo xtask ci full --no-hooks` if `prek` is unavailable. + +### Build Variants + +```bash +# Standard debug build +cargo build + +# Release build (takes ~2+ minutes, avoid during iteration) +cargo build --release + +# Check all targets and features +cargo check --all-features --all-targets +``` + +## Testing Philosophy + +**Test-driven approach:** When fixing bugs or adding features, write test cases in `brush-shell/tests/cases/*.yaml` BEFORE implementation. Use these to validate your changes. + +**Integration test structure:** Tests are YAML-based, run shell commands, compare stdout/stderr/exit codes against bash oracle. See `docs/reference/integration-testing.md` and AGENTS.md section 2 for detailed testing strategy. + +**Test categories:** +- Unit tests: In-file with `#[cfg(test)]` +- Integration tests: `brush-shell/tests/` directory +- Compatibility tests: YAML cases in `brush-shell/tests/cases/` +- Benchmarks: `brush-shell/benches/` and crate-level `benches/` + +## Common Pitfalls & Solutions + +### ❌ Don't Do This +- Run full test suite on every change (too slow) +- Skip `cargo fmt` and `cargo clippy` before committing +- Run `cargo xtask check hooks` during development iteration (it is a pre-PR check) +- Clone values unnecessarily (use references) +- Add breaking changes to public APIs without highlighting them +- Forget to add compat test cases for compatibility fixes + +### ✅ Do This +- Target specific packages/tests during development +- Run fmt/clippy before every commit +- Follow builder pattern for configuration (see `Shell::builder()`) +- Keep platform-specific code in `brush-core/src/sys/` +- Document all exported APIs with rustdoc +- Use `tracing::debug!(target: trace_categories::CATEGORY, "msg")` for logging +- Add test cases to `brush-shell/tests/cases/` for compatibility changes + +## Error Handling & Logging + +```rust +// Use thiserror for crate-specific errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MyError { ... } + +// Use anyhow ONLY in tests +#[cfg(test)] +use anyhow::Result; + +// Logging with trace categories +use crate::trace_categories; +tracing::debug!(target: trace_categories::COMMANDS, "executing: {}", cmd); +``` + +**Available trace categories:** COMMANDS, COMPLETION, EXPANSION, FUNCTIONS, INPUT, JOBS, PARSE, PATTERN, UNIMPLEMENTED + +## Linting Configuration + +The project uses **extremely strict** linting (workspace-level in `Cargo.toml`): +- All Rust warnings denied +- All clippy warnings denied (pedantic, cargo, nursery, perf) +- `expect_used`, `panic`, `todo`, `unwrap_in_result` are **forbidden** +- Missing docs on exported items are errors + +**Your code MUST pass `cargo clippy` without warnings.** + +## Cross-Platform Considerations + +- Primary targets: Linux (x86_64, aarch64), macOS (aarch64) +- Secondary: Windows (x86_64), WASM (wasm32-unknown-unknown, wasm32-wasip3) +- Platform-specific code goes in `brush-core/src/sys/` modules +- Use `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")` appropriately +- See `.cargo/config.toml` for target-specific configurations + +## CI Pipeline (What Will Run on Your PR) + +GitHub Actions runs these checks (from `.github/workflows/`): + +1. **Build** on multiple platforms (x86_64/aarch64 Linux, macOS, Windows, WASM) +2. **Tests** on Linux x86_64, Linux aarch64, macOS +3. **Static checks** (format, clippy) on stable + the workspace MSRV; the MSRV leg excludes the application-tier crates +4. **Pre-commit hooks** (`cargo xtask check hooks`: file hygiene, typos, zizmor, lychee, cargo-deny), in their own workflow +5. **Compatibility tests** with bash as oracle +6. **Code coverage** reports (70% overall threshold, no 5% negative delta) +7. **External test suites** (bash-completion test suite) +8. **OS compatibility** (Arch, Debian, Fedora, NixOS, openSUSE) +9. **Benchmarks** (performance regression detection on PRs) +10. **Public API analysis** (breaking change detection) + +**All of these must pass for PR to merge.** + +## Making Changes + +### Editing Core Shell Behavior +1. Check `brush-core/src/shell.rs` for `Shell` struct +2. Use `Shell::builder()` for construction +3. Update `brush-shell/src/main.rs` if CLI changes needed + +### Adding/Modifying Builtins +1. Edit files in `brush-builtins/src/` +2. Register in `brush-builtins/src/factory.rs` +3. Add test cases in `brush-shell/tests/cases/builtin/` + +### Parser Changes +1. Modify `brush-parser/src/` +2. Update AST definitions +3. Test with `cargo test --package brush-parser` + +### Breaking Changes Policy +- Avoid breaking public APIs (all crate exports are public) +- If unavoidable, highlight clearly and document thoroughly +- New optional fields on public structs are OK if struct implements `Default` +- See AGENTS.md section 3 for complete breaking change policy + +## Performance & Benchmarking + +```bash +# Run benchmarks (using xtask) +cargo xtask analyze bench + +# Run benchmarks with output file +cargo xtask analyze bench --output benchmarks.txt + +# Run benchmarks (manual) +cargo bench --workspace --benches + +# Collect flamegraphs (10 second profiling) +cargo bench --workspace --benches -- --profile-time 10 +# Output: target/criterion//profile/*.svg +``` + +**Note:** Performance regression testing runs automatically on PRs. Don't worry about it unless working on performance-specific features. + +## Documentation Standards + +**Rustdoc:** REQUIRED for all exported types, functions, traits, modules. Missing docs = CI failure. +**Examples:** Only needed for major feature additions. +**Style:** Follow Rust documentation best practices. + +## Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): +``` +feat: add support for X +fix: correct behavior of Y +docs: update Z documentation +test: add test cases for W +``` + +## AI-Assisted Contributions + +If using AI assistance significantly, add to PR description or commit message: +``` +Assisted-by: GitHub Copilot +``` + +## When Something Fails + +1. **Test failures:** Focus on affected area first, check if new tests are needed +2. **Format/clippy failures:** Fix immediately before proceeding +3. **Compat test failures:** Indicates shell behavior change, may need test updates +4. **Build failures:** Check dependencies, verify the toolchain satisfies the `rust-version` of the crate being built +5. **Timeout issues:** Build from scratch can take 2+ minutes + +## Quick Reference + +| Command | When | Time | +|---------|------|------| +| `cargo xtask ci quick` | Rapid iteration (inner loop) | ~7s | +| `cargo xtask ci full` | Before opening a PR (comprehensive) | ~60s | +| `cargo xtask ci full -k` | See all failures at once | ~60s | +| `cargo check` | Constantly during dev | ~3-5s | +| `cargo test --package X` | After each change | 3-20s | +| `cargo xtask test unit` | Fast unit tests | ~4.5s | +| `cargo xtask test integration` | All workspace tests | ~36s | +| `cargo xtask check fmt` | Before every commit | <1s | +| `cargo xtask check lint` | Before commit | ~5-10s | +| `cargo xtask check hooks` | Final validation only | ~1-5s once cached | + +## Trust These Instructions + +Only search the codebase if information here or in AGENTS.md is incomplete, contradictory, or proven incorrect. These instructions are validated against the actual working repository. diff --git a/userland/brush/source/.github/dependabot.yml b/userland/brush/source/.github/dependabot.yml new file mode 100644 index 0000000..7cfc1d1 --- /dev/null +++ b/userland/brush/source/.github/dependabot.yml @@ -0,0 +1,62 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + cooldown: + default-days: 7 + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "cargo" + cooldown: + default-days: 7 + directory: "/" + schedule: + interval: "weekly" + groups: + cargo: + patterns: + - "*" + + # .pre-commit-config.yaml is the single place typos, zizmor, lychee, and the + # file-hygiene hooks are pinned, so this is what keeps those linters current. + - package-ecosystem: "pre-commit" + cooldown: + default-days: 7 + directory: "/" + schedule: + interval: "weekly" + groups: + pre-commit: + patterns: + - "*" + + # rust-toolchain.toml pins an exact toolchain so rustfmt and clippy behave the + # same everywhere; this keeps that pin at the latest stable release. + - package-ecosystem: "rust-toolchain" + cooldown: + default-days: 7 + directory: "/" + schedule: + interval: "weekly" + + # Base images of the e2e adapters (e2e/*/Dockerfile). The applications they test are pinned + # by ARGs with checksums, which Dependabot cannot update. + - package-ecosystem: "docker" + cooldown: + default-days: 7 + directories: + - "/e2e/*" + schedule: + interval: "weekly" + ignore: + # Upstream's rolling test image, deliberately unpinned (see e2e/bash-completion/Dockerfile). + - dependency-name: "ghcr.io/scop/bash-completion/test" + groups: + e2e-docker: + patterns: + - "*" diff --git a/userland/brush/source/.github/workflows/cd.yaml b/userland/brush/source/.github/workflows/cd.yaml new file mode 100644 index 0000000..5d76453 --- /dev/null +++ b/userland/brush/source/.github/workflows/cd.yaml @@ -0,0 +1,310 @@ +# +# Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml +# +# N.B. scripts/install/install.sh depends on details of this workflow: its path (attestations are +# verified against it), the `brush-shell-v*` release tags, the `brush-.tar.gz` and +# `brush-.sha256` asset names, the `brush` binary at the archive root, and the glibc +# floor of the `*-linux-gnu` builds (2.34, set by the ubuntu-22.04 runners; the script picks the +# gnu build only on glibc 2.34+ and the musl build otherwise). Changing any of these breaks the +# install script for new releases; update it (and its tests) to match. +# +# Pushes to `main` also publish canary builds to ghcr.io/reubeno/brush-canary (see the +# publish-canary job), which `install.sh --canary` installs from. +# + +name: CD # Continuous Deployment +run-name: CD${{ github.event_name == 'release' && ' (release)' || (github.event_name == 'push' && github.repository == 'reubeno/brush') && ' (canary)' || ' (dry run)' }} + +on: + release: + types: [published] + + # Manual triggers don't actually publish but dry-run the builds. + workflow_dispatch: null + + # Run on PR in dry-run mode to make sure this workflow is still generally + # working. + pull_request: + branches: ["main"] + + # Run on pushes into `main` to publish canary builds. + push: + branches: ["main"] + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_GIT_FETCH_WITH_CLI: true + CARGO_NET_RETRY: 10 + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + RUSTUP_MAX_RETRIES: 10 + +defaults: + run: + shell: bash + +permissions: {} + +jobs: + upload-docs: + name: "Generate and upload documentation" + + permissions: + contents: write + id-token: write + attestations: write + + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v')) + runs-on: ubuntu-26.04 + timeout-minutes: 30 + + steps: + - name: "Checkout repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: "Install Rust toolchain" + run: rustup toolchain install --profile minimal + + - name: "Generate documentation distribution" + run: cargo xtask gen docs dist --out brush-docs.tar.gz + + - name: "Upload documentation to release" + if: github.event_name == 'release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload ${GITHUB_RELEASE_TAG} \ + brush-docs.tar.gz \ + brush-docs.tar.gz.sha256 \ + brush-docs.tar.gz.sha512 + + - name: "Generate artifact attestation" + if: github.event_name == 'release' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: "brush-docs.tar.gz*" + + - name: "Upload documentation artifact (dry-run)" + if: github.event_name != 'release' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: brush-docs + path: | + brush-docs.tar.gz + brush-docs.tar.gz.sha256 + brush-docs.tar.gz.sha512 + + upload-assets: + name: ${{ matrix.target }} + + permissions: + contents: write + id-token: write + attestations: write + + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v')) + runs-on: ${{ matrix.os }} + strategy: + # Run all jobs to completion regardless of errors. + # This is useful because sometimes we fail to compile for a certain target. + fail-fast: false + matrix: + include: + - target: aarch64-pc-windows-msvc + os: windows-2025 + force_dry_run: false + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + force_dry_run: false + - target: x86_64-apple-darwin + os: macos-26-intel + force_dry_run: false + - target: x86_64-pc-windows-msvc + os: windows-2025 + force_dry_run: false + - target: x86_64-unknown-linux-musl + os: ubuntu-26.04 + force_dry_run: false + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04 + force_dry_run: false + - target: aarch64-unknown-linux-musl + os: ubuntu-26.04 + force_dry_run: false + - target: aarch64-apple-darwin + os: macos-26 + force_dry_run: false + timeout-minutes: 60 + + steps: + - name: "Checkout repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: "Install Rust toolchain" + uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 + with: + cache: false + # The cross ARM64 musl linker wrapper emits shell traces as linker warnings. + # See https://github.com/cross-rs/cross/issues/1618. + build-warnings: ${{ matrix.target == 'aarch64-unknown-linux-musl' && 'warn' || 'deny' }} + + - name: "Setup cross-compiling toolchain" + if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl') + uses: taiki-e/setup-cross-toolchain-action@12b7ad4acfa95a1476779d6c06699b96ec1691f8 # v1.42.0 + with: + target: ${{ matrix.target }} + + - name: "Install musl cross tools" + if: contains(matrix.target, '-musl') + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: cross + + - name: "Workaround: clean cache" + run: cargo clean + + - name: "Install cargo-about" + run: cargo install cargo-about --locked --features cli + + - name: "Generate license notices" + working-directory: brush-shell + run: cargo about generate -o ../THIRD_PARTY_LICENSES.html about.hbs + + - name: "Update build flags" + if: endsWith(matrix.target, 'windows-msvc') + run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}" + + - name: "Build and upload binaries to release" + uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2 + id: upload-release + env: + # Canary builds of `main` say so in `brush --version`. + BRUSH_VERSION_SUFFIX: ${{ github.event_name == 'push' && '+canary' || '' }} + # cross only forwards environment variables it's told about into its build container. + CROSS_BUILD_ENV_PASSTHROUGH: BRUSH_VERSION_SUFFIX + with: + dry-run: ${{ github.event_name != 'release' || matrix.force_dry_run }} + bin: brush + locked: true + target: ${{ matrix.target }} + tar: unix + zip: windows + checksum: sha256,sha512 + token: ${{ secrets.GITHUB_TOKEN }} + include: "LICENSE,THIRD_PARTY_LICENSES.html" + + - name: "Generate artifact attestation" + # Canary builds (pushes to `main`) are attested too, for `install.sh --canary` to verify. + if: (github.event_name == 'release' && !matrix.force_dry_run) || (github.event_name == 'push' && github.repository == 'reubeno/brush') + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: "${{ steps.upload-release.outputs.archive }}.*" + + - name: "Upload artifacts (dry-run)" + if: github.event_name != 'release' || matrix.force_dry_run + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: brush-${{ matrix.target }} + path: "${{ steps.upload-release.outputs.archive }}.*" + + # Publishes builds of `main` to ghcr.io/reubeno/brush-canary, where `install.sh --canary` finds + # them. Each target's archive is pushed as a single-file OCI artifact tagged `-` + # and then, once every target's archive is in place, `` (always the newest build). + # + # N.B. install.sh depends on the package name, the tags, and each manifest having the archive + # as its only layer. It also shows users the build's revision and created annotations. + publish-canary: + name: "Publish canary builds" + needs: upload-assets + + permissions: + packages: write + + if: github.event_name == 'push' && github.repository == 'reubeno/brush' + runs-on: ubuntu-26.04 + timeout-minutes: 15 + + steps: + - name: "Download build artifacts" + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + # brush-, but not brush-docs. + pattern: brush-*-* + merge-multiple: true + + - name: "Install ORAS" + uses: oras-project/setup-oras@5e2f2482b30b0cb3e14e67f89a80c88c659a7c63 # v2.0.3 + + - name: "Push to ghcr.io" + env: + PACKAGE: ghcr.io/reubeno/brush-canary + REGISTRY_TOKEN: ${{ github.token }} + run: | + oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${REGISTRY_TOKEN}" + + created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + targets=() + for archive in brush-*.tar.gz brush-*.zip; do + target="${archive#brush-}" + target="${target%.tar.gz}" + target="${target%.zip}" + case "${archive}" in + *.zip) media_type="application/zip" ;; + *) media_type="application/gzip" ;; + esac + + oras push "${PACKAGE}:${target}-${GITHUB_SHA}" \ + --artifact-type "application/vnd.reubeno.brush.canary.v1" \ + --annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --annotation "org.opencontainers.image.created=${created}" \ + --annotation "org.opencontainers.image.description=Unreleased, unstable builds of brush from main." \ + "${archive}:${media_type}" + targets+=("${target}") + done + + # Only now move each `` tag, so it never points at a partially published build. + for target in "${targets[@]}"; do + oras tag "${PACKAGE}:${target}-${GITHUB_SHA}" "${target}" + done + + # Deletes old canary builds. Kept apart from publish-canary so that nothing here can push builds. + prune-canary: + name: "Prune old canary builds" + needs: publish-canary + + permissions: + packages: write + + runs-on: ubuntu-26.04 + timeout-minutes: 10 + + steps: + - name: "Delete all but the newest ~100 builds" + env: + GH_TOKEN: ${{ github.token }} + run: | + versions="/users/${GITHUB_REPOSITORY_OWNER}/packages/container/brush-canary/versions" + + # Each build adds one version per target (8 of them), so keeping the newest 800 versions + # keeps about 100 builds. Versions still tagged with a bare `` (the newest build + # of that target) are never deleted. + gh api --paginate --slurp "${versions}?per_page=100" | + jq -r 'add | sort_by(.created_at) | reverse | .[800:][] + | select(.metadata.container.tags | all(test("-[0-9a-f]{40}$"))) + | .id' | + while read -r id; do + # GitHub refuses to delete public versions with over 5,000 downloads; don't fail over that. + gh api --method DELETE "${versions}/${id}" || echo "::warning::could not delete ${versions}/${id}" + done diff --git a/userland/brush/source/.github/workflows/ci-reports.yaml b/userland/brush/source/.github/workflows/ci-reports.yaml new file mode 100644 index 0000000..968bef9 --- /dev/null +++ b/userland/brush/source/.github/workflows/ci-reports.yaml @@ -0,0 +1,138 @@ +name: "PR Reports" +on: + # We intentionally run this workflow in the context of the target of a PR; we are careful + # an intentional about how we handle the data coming from the PR. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["CI"] + types: + - completed + +permissions: {} + +jobs: + report: + name: "Report" + + permissions: + actions: read + checks: write + contents: read + pull-requests: write + + runs-on: ubuntu-26.04 + + # We only run this job if the workflow run that completed was triggered by a pull request. + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Extract PR number and create event file + id: get-pr + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + // Find the workflow run that triggered this job. + const workflowRun = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + // Extract the source commit info. + const { head_branch, head_sha, head_repository } = workflowRun.data; + const head_repo_owner = head_repository.owner.login; + core.setOutput('head_branch', head_branch); + core.setOutput('head_sha', head_sha); + core.setOutput('head_repo', head_repository.full_name); + + // Try to find all PRs from that source. + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${head_repo_owner}:${head_branch}`, + per_page: 20 + }); + + // Filter PRs to find the one that targets the main branch. + // NOTE: This only supports a target branch of 'main'. + const filtered_prs = prs.filter(pr => pr.base.ref === 'main'); + + if (filtered_prs.length > 0) { + const pr = filtered_prs[0]; + core.setOutput('pr_number', pr.number); + + // Write out a mostly-stubbed event file. + const eventData = { + pull_request: { + head: { + sha: head_sha, + repo: { + full_name: head_repository.full_name + } + } + } + }; + + fs.writeFileSync('event-file.json', JSON.stringify(eventData), 'utf-8'); + } else { + core.setOutput('pr_number', ''); + } + + - name: Download code coverage reports + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: codecov-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Download performance reports + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: perf-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Download test results + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: test-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Show published reports + continue-on-error: true + if: steps.get-pr.outputs.pr_number != '' + run: | + ls -lR reports/ + + - name: "Publish test results" + uses: EnricoMi/publish-unit-test-result-action@7622c5bf3d112a72ab4fa3951c2aa5723d8ab92d # v2.25.0 + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + with: + commit: ${{ github.event.workflow_run.head_sha }} + event_file: event-file.json + event_name: ${{ github.event.workflow_run.event }} + files: reports/test-results-*.xml + + - name: "Publish available .md reports to PR" + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + if: steps.get-pr.outputs.pr_number != '' + with: + path: reports/*.md + number: ${{ steps.get-pr.outputs.pr_number }} diff --git a/userland/brush/source/.github/workflows/ci.yaml b/userland/brush/source/.github/workflows/ci.yaml new file mode 100644 index 0000000..d49e6ff --- /dev/null +++ b/userland/brush/source/.github/workflows/ci.yaml @@ -0,0 +1,848 @@ +name: "CI" +on: + pull_request: + paths-ignore: + - "release-plz.toml" + push: + paths-ignore: + - "docs/**" + - "**.md" + - "LICENSE" + - "release-plz.toml" + branches: + - main + +env: + # Incremental compilation only pays off across repeated edit-build cycles. + # Each CI job builds once, so it would only add compile time and bloat + # target/. rust-cache sets this itself, but not every job here uses it. + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + # The toolchain that every job needing nightly uses: matrix legs that say + # `toolchain: "nightly"` resolve to it, and check-deps uses it directly. + # (`env` isn't available in `strategy.matrix`, hence the indirection.) + # + # NIGHTLY PIN (temporary): this is nightly-2026-10-31 (rustc 92b5b8c87, + # 2026-10-29) rather than floating `nightly`. The next nightly, + # nightly-2026-11-01 (rustc f9ceb4a4d), fails to compile brush-core with + # "error[E0391]: cycle detected when computing when `ArithmeticTarget` needs + # async drop": a rustc regression on mutually recursive enums held across an + # `.await`, likely from rust-lang/rust#164987 (stable and beta are + # unaffected). Once a nightly compiles brush again, set this back to + # `nightly`. analyze-public-api doesn't use it and stays on floating + # `nightly`: rustdoc doesn't hit the regression. + NIGHTLY_TOOLCHAIN: nightly-2026-10-31 + +permissions: + actions: read + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Build and upload release binaries for all relevant architectures. + build: + strategy: + fail-fast: false + matrix: + include: + # Build for x86_64/linux target on native host. + # N.B. We intentionally pin to Ubuntu 22.04 for now to increase + # the range of distros that will be able to run the produced binaries. + # Newer release of Ubuntu upgrade glibc to a point not yet supported + # by some latest stable versions of distros. + - host: "ubuntu-22.04" + target: "" + cross_command: "" + cross_tool_to_install: "" + os: "linux" + arch: "x86_64" + binary_name: "brush" + extra_build_args: "" + # Build for x86_64/linux using musl as target C runtime. + # We don't bother pinning to an earlier version of Ubuntu because + # the produced binary will statically link the C runtime anyhow. + - host: "ubuntu-26.04" + target: "x86_64-unknown-linux-musl" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "linux-musl" + arch: "x86_64" + binary_name: "brush" + extra_build_args: "" + # Build for aarch64/macos target on native host. + - host: "macos-latest" + target: "" + cross_command: "" + cross_tool_to_install: "" + os: "macos" + arch: "aarch64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + # Build for aarch64/linux target on x86_64/Linux host. + - host: "ubuntu-26.04" + target: "aarch64-unknown-linux-gnu" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "linux" + arch: "aarch64" + required_tools: "gcc-aarch64-linux-gnu" + binary_name: "brush" + extra_build_args: "" + # Build for wasm32 target on x86_64/linux host. + - host: "ubuntu-26.04" + target: "wasm32-unknown-unknown" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "unknown" + arch: "wasm32" + required_tools: "" + binary_name: "brush.wasm" + extra_build_args: "" + # Build for WASI-0.3 target on x86_64/linux host. wasm32-wasip3 first + # ships prebuilt std in Rust 1.100; until rust-toolchain.toml is at + # 1.100+, use nightly. Drop `toolchain` once it is. + - host: "ubuntu-26.04" + target: "wasm32-wasip3" + cross_command: "cargo" + cross_tool_to_install: "" + os: "wasi-0.3" + arch: "wasm32" + required_tools: "" + binary_name: "brush.wasm" + extra_build_args: "" + toolchain: "nightly" + # Build for x86_64/windows target on x86_64/linux host. + # cross 0.2.5's windows-gnu container exits 1, without an error, on ubuntu-26.04. + - host: "ubuntu-24.04" + target: "x86_64-pc-windows-gnu" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "windows" + arch: "x86_64" + required_tools: "" + binary_name: "brush.exe" + extra_build_args: "" + # Build for x86_64/android target on x86_64/linux host. + - host: "ubuntu-26.04" + target: "x86_64-linux-android" + cross_command: "cargo ndk" + cross_tool_to_install: "cargo-ndk" + os: "android" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + # x86_64/FreeBSD target on x86_64/linux host, via cross. Compile-check + # only (check_only): cross's curated sysroot omits some base-system + # libraries (libgeom/libkvm/...) that `sysinfo` links against, so the + # final link of a real binary fails. Checking still validates that the + # shell compiles for the target; producing a binary is a TODO (would + # need a full sysroot with those libraries present). + - host: "ubuntu-26.04" + target: "x86_64-unknown-freebsd" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "freebsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + check_only: true + # x86_64/NetBSD target on x86_64/linux host, via cross. Compile-check + # only for the same reason as FreeBSD above. + - host: "ubuntu-26.04" + target: "x86_64-unknown-netbsd" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "netbsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + check_only: true + # OpenBSD is tier-3 with no prebuilt std, so even compile-checking it + # requires building std from source (-Zbuild-std, hence nightly + + # rust-src + no rustup target). panic_abort is built explicitly because + # our release profile sets panic = "abort" (plain -Zbuild-std only + # builds std + panic_unwind). Compile-check only, like the other BSDs; + # check links nothing, so no C cross-compiler/sysroot/linker is needed + # (none of the workspace's C-compiling deps are in this default graph). + - host: "ubuntu-26.04" + target: "x86_64-unknown-openbsd" + cross_command: "cargo" + cross_tool_to_install: "" + os: "openbsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "-Zbuild-std=std,panic_abort" + toolchain: "nightly" + components: "rust-src" + # std is built from source, so there's no prebuilt target to install. + skip_rustup_target: true + check_only: true + + name: "${{ matrix.check_only && 'Check' || 'Build' }} (${{ matrix.arch }}/${{ matrix.os }})" + runs-on: ${{ matrix.host }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain + shell: bash + env: + # Empty -- meaning the toolchain pinned in rust-toolchain.toml -- for + # every leg that doesn't set `toolchain` itself. + TOOLCHAIN: ${{ matrix.toolchain == 'nightly' && env.NIGHTLY_TOOLCHAIN || matrix.toolchain }} + TARGET: ${{ !matrix.skip_rustup_target && matrix.target || '' }} + COMPONENTS: ${{ matrix.components }} + run: | + if [[ -n "${TOOLCHAIN}" ]]; then + rustup toolchain install "${TOOLCHAIN}" --profile minimal + # A directory override takes precedence over rust-toolchain.toml. + rustup override set "${TOOLCHAIN}" + else + rustup toolchain install --profile minimal + fi + # Added separately: the nameless `rustup toolchain install` form + # (driven by rust-toolchain.toml) silently ignores --target and + # --component. These act on the now-active toolchain. + if [[ -n "${TARGET}" ]]; then rustup target add "${TARGET}"; fi + if [[ -n "${COMPONENTS}" ]]; then rustup component add "${COMPONENTS}"; fi + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: "${{ matrix.target }}" + + - name: Install additional prerequisite tools + if: ${{ matrix.required_tools != '' }} + env: + REQUIRED_TOOLS: ${{ matrix.required_tools }} + run: sudo apt-get update -y && sudo apt-get install -y ${REQUIRED_TOOLS} + + - name: Install cross-compilation toolchain + if: ${{ matrix.cross_tool_to_install != '' }} + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: ${{ matrix.cross_tool_to_install }} + + - name: "Build (native)" + if: ${{ matrix.target == '' }} + env: + EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }} + run: cargo build --release --all-targets ${EXTRA_BUILD_ARGS} + + - name: "${{ matrix.check_only && 'Check' || 'Build' }} (cross)" + if: ${{ matrix.target != '' }} + env: + CROSS_COMMAND: ${{ matrix.cross_command }} + CARGO_SUBCOMMAND: ${{ matrix.check_only && 'check' || 'build' }} + TARGET: ${{ matrix.target }} + EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }} + CARGO_NDK_PLATFORM: 24 + run: ${CROSS_COMMAND} ${CARGO_SUBCOMMAND} --release --target=${TARGET} ${EXTRA_BUILD_ARGS} + + - name: "Upload binaries" + # check_only targets don't produce a binary to upload. + if: ${{ !matrix.check_only }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: binaries-${{ matrix.arch }}-${{ matrix.os }} + path: target/${{ matrix.target }}/release/${{ matrix.binary_name }} + + - name: "Upload integration test binaries" + if: ${{ matrix.target == '' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: integration-tests-${{ matrix.arch }}-${{ matrix.os }} + path: | + target/${{ matrix.target }}/release/deps/brush_*_tests-* + !**/*.d + + # Test functional correctness + test: + strategy: + fail-fast: false + matrix: + include: + - host: "ubuntu-26.04" + variant: "linux-x86_64" + artifact_suffix: "linux-x86_64" + name_suffix: "(linux/x86_64)" + homebrew_supported: true + coverage: true + coverage_min_percent: 70 + + - host: "ubuntu-26.04-arm" + variant: "linux-aarch64" + artifact_suffix: "linux-aarch64" + name_suffix: "(linux/aarch64)" + homebrew_supported: false + coverage: true + coverage_min_percent: 70 + + - host: "macos-latest" + variant: "macos" + artifact_suffix: "-macos" + name_suffix: "(macOS)" + homebrew_supported: true + coverage: true + coverage_min_percent: 70 + + - host: "windows-2025" + variant: "windows" + artifact_suffix: "-windows" + name_suffix: "(windows)" + homebrew_supported: false + coverage: true + coverage_min_percent: 20 + + - host: "ubuntu-26.04" + variant: "wasi" + artifact_suffix: "-wasi" + name_suffix: "(wasm32/wasi-0.3)" + homebrew_supported: false + coverage: false + # See the wasm32-wasip3 build leg: nightly until Rust 1.100. + toolchain: "nightly" + extra_rust_targets: "wasm32-wasip3" + wasi_runtime: "wasmtime" + xtask_test_args: "--wasi" + + name: "Test ${{ matrix.name_suffix }}" + runs-on: ${{ matrix.host }} + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain + env: + # Empty -- meaning the toolchain pinned in rust-toolchain.toml -- for + # every leg that doesn't set `toolchain` itself. + TOOLCHAIN: ${{ matrix.toolchain == 'nightly' && env.NIGHTLY_TOOLCHAIN || matrix.toolchain }} + TARGETS: ${{ matrix.extra_rust_targets }} + # Targets/components are added separately: the nameless + # `rustup toolchain install` form ignores --target and --component. + run: | + if [[ -n "${TOOLCHAIN}" ]]; then + rustup toolchain install "${TOOLCHAIN}" --profile minimal + # A directory override takes precedence over rust-toolchain.toml. + rustup override set "${TOOLCHAIN}" + else + rustup toolchain install --profile minimal + fi + rustup component add llvm-tools-preview + if [[ -n "${TARGETS}" ]]; then rustup target add "${TARGETS}"; fi + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Install cargo-nextest + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: cargo-nextest + + - name: Install cargo-llvm-cov + if: ${{ matrix.coverage }} + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: cargo-llvm-cov + + - name: Install WASI runtime + if: ${{ matrix.wasi_runtime }} + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: ${{ matrix.wasi_runtime }} + + - name: Set up Homebrew + if: ${{ matrix.homebrew_supported }} + id: set-up-homebrew + # We ignore the stale-action-refs check because this action does not have any releases or tags. + # We're forced to pick a specific commit. + uses: Homebrew/actions/setup-homebrew@c262868c096d909fc278daa87ed9772967331ea3 # zizmor: ignore[stale-action-refs] + with: + stable: true + + - name: "Install recent bash for tests" + if: ${{ matrix.homebrew_supported }} + run: | + set -x + + # Install it + brew install bash + + # Find the path + BASH_PATH="$(brew --prefix bash)/bin/bash" + + # Log what we're using + echo "Using bash from: ${BASH_PATH}" + echo "bash version:" + ${BASH_PATH} --version + echo "BASH_PATH=${BASH_PATH}">>$GITHUB_ENV + + - name: "Download recent bash-completion sources for tests" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: "scop/bash-completion" + ref: "2.17.0" + path: "bash-completion" + + - name: "Setup bash-completion" + run: echo "BASH_COMPLETION_PATH=${GITHUB_WORKSPACE}/bash-completion/bash_completion">>$GITHUB_ENV + + - name: Test + env: + VARIANT: ${{ matrix.variant }} + run: | + args="${{ matrix.xtask_test_args || '' }}" + args="${args} --results-output ./test-results-${VARIANT}.xml" + if [[ "${{ matrix.coverage }}" == "true" ]]; then + args="${args} --coverage --coverage-output ./codecov-${VARIANT}.xml" + fi + cargo xtask test integration ${args} + + - name: "Upload test results" + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: test-reports${{ matrix.artifact_suffix }} + path: test-results-*.xml + + - name: "Generate code coverage report" + uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7.0.0 + if: ${{ always() && matrix.coverage }} + id: "code_coverage_report" + with: + artifact_download_workflow_names: "CI" + artifact_name: coverage-%name%${{ matrix.artifact_suffix }} + filename: codecov-${{ matrix.variant }}.xml + overall_coverage_fail_threshold: ${{ matrix.coverage_min_percent }} + only_list_changed_files: ${{ github.event_name == 'pull_request' }} + fail_on_negative_difference: true + negative_difference_by: "overall" + negative_difference_threshold: 5 + + - name: "Upload code coverage report" + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: codecov-reports${{ matrix.artifact_suffix }} + path: code-coverage-results.md + + # Static analysis of the code. + check: + name: "Source code checks" + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # Test the pinned toolchain as well as MSRV. The "stable" leg means the + # toolchain pinned in rust-toolchain.toml, which Dependabot keeps at the + # latest stable release; the label is kept so check names don't change. + rust-version: ["stable", "1.88.0"] + # Run checks on Linux, Windows, and macOS. + os: ["ubuntu-26.04", "windows-2025", "macos-latest"] + # Extra args for the build check, per toolchain. Spelled out for both + # legs so it's clear that stable compiles every crate in the workspace. + include: + - rust-version: "stable" + build_check_args: "" + # The application-tier crates declare a higher MSRV than the + # workspace-wide one, so they can't be built by the MSRV toolchain; + # see docs/reference/msrv-policy.md. + - rust-version: "1.88.0" + build_check_args: "--workspace-msrv" + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain (${{ matrix.rust-version }}) + shell: bash + env: + MSRV: ${{ matrix.rust-version != 'stable' && matrix.rust-version || '' }} + run: | + if [[ -n "${MSRV}" ]]; then + rustup toolchain install "${MSRV}" --profile minimal --component clippy,rustfmt + # A directory override takes precedence over rust-toolchain.toml. + rustup override set "${MSRV}" + else + rustup toolchain install --profile minimal + fi + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Format check + run: cargo xtask check fmt + + - name: Check + run: cargo xtask check build ${{ matrix.build_check_args }} + + - name: Crate structure check + run: cargo xtask check crates + + - name: Clippy check + if: matrix.rust-version == 'stable' + run: cargo xtask check lint + + # Check for unneeded dependencies. + check-deps: + name: "Check for unneeded dependencies" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Overriding (not just installing) nightly means xtask builds with it too, + # so the pinned toolchain doesn't have to be installed as well. + - name: Set up nightly rust toolchain + run: | + rustup toolchain install "${NIGHTLY_TOOLCHAIN}" --profile minimal + rustup override set "${NIGHTLY_TOOLCHAIN}" + + - name: Install cargo-udeps + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: cargo-udeps + + # xtask runs `cargo + udeps`, which takes precedence over the override. + - name: Check for unused dependencies + run: cargo xtask check unused-deps --toolchain "${NIGHTLY_TOOLCHAIN}" + + # Check that generated schemas are up-to-date + check-schemas: + name: "Check generated schemas" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain + run: rustup toolchain install --profile minimal + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Check generated schemas + run: cargo xtask check schemas + + # Analyze public API surface + analyze-public-api: + if: github.event_name == 'pull_request' + name: "Analyze public API" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Overriding (not just installing) nightly means xtask builds with it too, + # so the pinned toolchain doesn't have to be installed as well. + - name: Set up nightly rust toolchain + run: | + rustup toolchain install nightly --profile minimal + rustup override set nightly + + - name: Install cargo-public-api + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: cargo-public-api + + - name: Set up branches + run: | + set -euo pipefail + git fetch origin main + + - name: Compare public APIs with main + run: cargo xtask analyze public-api --base origin/main --output-dir reports + + - name: Upload test report + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-reports-public-api + path: reports/*.md + + # Performance analysis of the code. + benchmark: + if: github.event_name == 'pull_request' + name: "Benchmarks" + runs-on: ubuntu-26.04 + steps: + - name: Checkout PR sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: pr + + - name: Checkout main sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: main + ref: main + + # main/ is built with its own pinned toolchain, which differs from the PR's + # only when the PR changes rust-toolchain.toml. + - name: Set up rust toolchain + run: rustup toolchain install --profile minimal + working-directory: pr + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: | + ./pr + ./main + + # Criterion keeps past results under target/criterion by default, which the cargo cache + # saves; a later run on the branch then fails to load them as a baseline, and its errors + # break the bencher-format output. CRITERION_HOME keeps results out of the cache. + - name: Performance analysis on PR + run: cargo xtask analyze bench --output benchmarks.txt + working-directory: pr + env: + CRITERION_HOME: ${{ runner.temp }}/criterion-pr + + - name: Performance analysis on main + run: cargo bench --workspace --benches -- --output-format bencher | tee benchmarks.txt + working-directory: main + env: + CRITERION_HOME: ${{ runner.temp }}/criterion-main + + - name: Compare benchmark results + run: | + ./pr/scripts/compare-benchmark-results.py -b main/benchmarks.txt -t pr/benchmarks.txt >benchmark-results.md + + - name: Upload performance results + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: perf-reports + path: | + pr/benchmarks.txt + main/benchmarks.txt + benchmark-results.md + + # Run real applications' own shell-integration suites (e2e/) against the release binary. + # The default adapter set plus bash-completion, which is report-only: its failures show as a pass + # rate in the dashboard without failing the job. blesh and bash-tests are opt-in (see + # e2e/README.md). + e2e-tests: + name: "External tests / e2e suites" + runs-on: ubuntu-26.04 + needs: build + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download prebuilt brush binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: "binaries-x86_64-linux" + path: "binaries" + + - name: Setup downloads + run: chmod +x binaries/* + + - name: Set up rust toolchain + run: rustup toolchain install --profile minimal + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Run e2e suites + run: | + cargo xtask test --brush-path "${GITHUB_WORKSPACE}/binaries/brush" \ + e2e --with bash-completion --timeout 600 \ + --results-dir e2e-results --summary-output e2e-results/dashboard.md + + # The dashboard goes to the run page as well as the PR comment; failing adapters' logs go + # to the job log, so the comment stays short and the details are one click away. + - name: Publish dashboard + if: always() + run: | + dashboard=e2e-results/dashboard.md + [[ -f "${dashboard}" ]] || exit 0 + # The blank line ends the last
block; without it the link stays raw HTML. + printf '\n📎 [Run logs](%s/%s/actions/runs/%s) · artifact `e2e-logs`\n' \ + "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}" >> "${dashboard}" + cat "${dashboard}" >> "${GITHUB_STEP_SUMMARY}" + cp "${dashboard}" test-results-e2e.md + for log in e2e-results/run-*/*/log.txt; do + app=$(basename "$(dirname "${log}")") + if grep -qE "^\| (❌|💥) \| ${app} \|" "${dashboard}"; then + echo "::group::${app} log.txt" + cat "${log}" + echo "::endgroup::" + fi + done + + - name: Upload dashboard + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-reports-e2e + path: test-results-e2e.md + + - name: Upload logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: e2e-logs + path: e2e-results/ + retention-days: 7 + + # Test release binary on a variety of OS platforms. + os-tests: + strategy: + fail-fast: false + matrix: + include: + # N.B. We don't include Ubuntu because it's already covered by the initial test job. + - container: "archlinux:latest" + description: "Arch Linux/latest" + prereqs_command: "pacman -Sy --noconfirm bash-completion iputils grep less sed util-linux which" + + - container: "mcr.microsoft.com/azurelinux/core:4" + description: "Azure Linux/4" + prereqs_command: "dnf install -y bash-completion ca-certificates git iputils grep less procps-ng sed util-linux which" + + - container: "debian:testing" + description: "Debian/testing" + prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed which" + + - container: "fedora:latest" + description: "Fedora/latest" + prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux which" + + - container: "nixos/nix:latest" + description: "NixOS/latest" + prereqs_command: "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install nixpkgs#gnused nixpkgs#hexdump nixpkgs#bash-completion" + + # NOTE: We use Tumbleweed to make sure we get a recent enough version of bash. + - container: "opensuse/tumbleweed:latest" + description: "openSUSE Tumbleweed/latest" + prereqs_command: "zypper install -y bash-completion git which" + + name: "OS target tests (${{ matrix.description }})" + runs-on: ubuntu-26.04 + # The floating tags in the matrix above are deliberate: the whole point of + # this job is to catch breakage against *current* distro releases, so + # digest-pinning these images would defeat it. + container: ${{ matrix.container }} # zizmor: ignore[unpinned-images] + needs: build + env: + # Reference: https://github.com/Azure/azure-cli/issues/29835 + GNUPGHOME: /root/.gnupg + steps: + # Install prerequisites *first*; may need some of them for checkout itself. + - name: Install prerequisites + if: ${{ matrix.prereqs_command != '' }} + env: + PREREQS_COMMAND: ${{ matrix.prereqs_command }} + run: bash -c "${PREREQS_COMMAND}" + + # Workaround path issues on NixOS + - name: "Apply workarounds: NixOS" + if: ${{ matrix.container == 'nixos/nix:latest' }} + run: | + set -x + + # Allow use of experimental features to avoid nix from complaining. + export NIX_CONFIG="extra-experimental-features = nix-command flakes" + + # The nixos/nix image ships no /etc/os-release (unlike a full NixOS install + # and every other container we test against). Provide one so the test harness + # can identify the OS, which it relies on for tests' `incompatible_os` tags. + printf 'NAME=NixOS\nID=nixos\nPRETTY_NAME="NixOS (nix container)"\n' > /etc/os-release + + # Hacks to get the mounted nodejs by github actions work as it's dynamically linked + # https://github.com/actions/checkout/issues/334#issuecomment-716068696 + nix build --no-link --max-jobs 2 --cores 0 'nixpkgs#stdenv.cc.cc.lib' 'nixpkgs#glibc' + echo "LD_LIBRARY_PATH=$(nix path-info 'nixpkgs#stdenv.cc.cc.lib')/lib" >> "$GITHUB_ENV" + ln -s "$(nix path-info 'nixpkgs#glibc' --recursive | grep glibc | grep -v bin)/lib64" /lib64 + + # Provide alternate path for bash-completion + echo "BASH_COMPLETION_PATH=$(nix path-info 'nixpkgs#bash-completion')/share/bash-completion/bash_completion">>"$GITHUB_ENV" + + # Forcibly add system paths + echo "BRUSH_TEST_PATH_VAR=$PATH">>"$GITHUB_ENV" + + # Checkout sources for YAML-based test cases + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: sources + + - name: Download prebuilt brush binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: binaries-x86_64-linux + path: binaries + + - name: Download integration test binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: integration-tests-x86_64-linux + path: binaries + + - name: Setup downloads + run: | + # N.B. Can't use -o pipefail because it's not supported on Debian. + set -eux + chmod +x binaries/* + ls -l -R sources/brush-shell/tests + ls -l binaries + + - name: Run tests + shell: bash + run: | + export BRUSH_PATH=$PWD/binaries/brush + export BRUSH_VERBOSE=true + + result=0 + for test_name in binaries/*tests*; do + if [[ ${test_name} == *compat* ]]; then + export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/compat + elif [[ ${test_name} == *integration* ]]; then + export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/brush + elif [[ ${test_name} == *interactive* || ${test_name} == *version* ]]; then + # TODO(tests): Re-enable these tests. + echo "WARNING: skipping test: ${test_name}" + continue + fi + + echo "Running test: ${test_name}" + chmod +x ${test_name} + ${test_name} || result=$? + done + + exit ${result} diff --git a/userland/brush/source/.github/workflows/codeql.yml b/userland/brush/source/.github/workflows/codeql.yml new file mode 100644 index 0000000..e1cef8d --- /dev/null +++ b/userland/brush/source/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "35 1 * * 3" + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-26.04 + permissions: + # required for all workflows + security-events: write + # required to fetch internal or private CodeQL packs + packages: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: python + build-mode: none + - language: rust + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + category: "/language:${{matrix.language}}" diff --git a/userland/brush/source/.github/workflows/devcontainer.yaml b/userland/brush/source/.github/workflows/devcontainer.yaml new file mode 100644 index 0000000..fc40996 --- /dev/null +++ b/userland/brush/source/.github/workflows/devcontainer.yaml @@ -0,0 +1,72 @@ +name: "Devcontainer" +on: + push: + branches: + - main + pull_request: + paths: + - ".devcontainer/**" + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: "Build devcontainer" + runs-on: ubuntu-26.04 + permissions: + contents: read + packages: read + steps: + - name: Checkout sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to GitHub Container Registry + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pre-build dev container image + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + imageName: ghcr.io/reubeno/brush/devcontainer + imageTag: latest + cacheFrom: ghcr.io/reubeno/brush/devcontainer + push: never + + build_and_publish: + name: "Build and publish devcontainer" + runs-on: ubuntu-26.04 + permissions: + contents: read + packages: write + steps: + - name: Checkout sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Login to GitHub Container Registry + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pre-build dev container image + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + imageName: ghcr.io/reubeno/brush/devcontainer + imageTag: latest + cacheFrom: ghcr.io/reubeno/brush/devcontainer + push: filter + refFilterForPush: refs/heads/main + eventFilterForPush: push diff --git a/userland/brush/source/.github/workflows/docs.yaml b/userland/brush/source/.github/workflows/docs.yaml new file mode 100644 index 0000000..6332b26 --- /dev/null +++ b/userland/brush/source/.github/workflows/docs.yaml @@ -0,0 +1,59 @@ +name: "Docs" +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +env: + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + +permissions: + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Generate docs content + generate-docs: + name: "Generate usage docs" + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain + run: rustup toolchain install --profile minimal + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Create dirs + run: mkdir -p ./md ./man + + - name: Build markdown info + run: cargo xtask gen docs markdown --out ./md/brush.md + + - name: Upload markdown docs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-markdown + path: md + + - name: Build man pages + run: cargo xtask gen docs man --output-dir ./man + + - name: Upload man pages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-man + path: man diff --git a/userland/brush/source/.github/workflows/hooks.yaml b/userland/brush/source/.github/workflows/hooks.yaml new file mode 100644 index 0000000..60df28b --- /dev/null +++ b/userland/brush/source/.github/workflows/hooks.yaml @@ -0,0 +1,72 @@ +# Every hook defined in .pre-commit-config.yaml: file hygiene, spelling, +# workflow analysis, link checking, the cargo-deny audit, and rustfmt. Runs the +# same `cargo xtask check hooks` a contributor runs. +# +# This is its own workflow rather than a job in ci.yaml so that it can run on +# every change -- including docs-only pushes, which ci.yaml skips -- without +# dragging the build and test matrix along with it. +name: "Pre-commit hooks" +on: + pull_request: + push: + branches: + - main + +env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + +permissions: + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + hooks: + name: "Pre-commit hooks" + runs-on: ubuntu-26.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up rust toolchain + run: rustup toolchain install --profile minimal + + - name: Enable cargo cache + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Install prek + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1 + with: + tool: prek + + - name: Cache hook environments + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/prek + key: prek-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: prek-${{ runner.os }}- + + - name: Prepare hooks + run: prek prepare-hooks + env: + # Pass through the token in case 'cargo binstall' needs it + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run pre-commit hooks + run: cargo xtask check hooks + + # The fixing hooks rewrite files in place and then fail. Show what they + # changed so the required fix is visible in the log. + - name: Show changes made by hooks + if: failure() + run: git diff diff --git a/userland/brush/source/.github/workflows/install-script.yaml b/userland/brush/source/.github/workflows/install-script.yaml new file mode 100644 index 0000000..eaa9b08 --- /dev/null +++ b/userland/brush/source/.github/workflows/install-script.yaml @@ -0,0 +1,77 @@ +name: "Install script" + +on: + pull_request: + paths: + - "scripts/install/**" + - ".github/workflows/install-script.yaml" + push: + branches: ["main"] + paths: + - "scripts/install/**" + - ".github/workflows/install-script.yaml" + # Weekly, to catch release changes (e.g., renamed assets) that break the script. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: null + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: "Test (${{ matrix.name }})" + + permissions: + contents: read + attestations: read # needed for `gh attestation verify` + + strategy: + fail-fast: false + matrix: + include: + - name: "linux-x86_64" + os: ubuntu-26.04 + - name: "linux-aarch64" + os: ubuntu-26.04-arm + - name: "macos-aarch64" + os: macos-latest + - name: "macos-x86_64" + os: macos-26-intel + # musl + busybox userland, no gh. + - name: "alpine" + os: ubuntu-26.04 + container: "alpine:3" + prepare: "apk add --no-cache bash curl" + # glibc too old for the gnu build; exercises the musl fallback. + - name: "almalinux-8" + os: ubuntu-26.04 + container: "almalinux:8" + + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + timeout-minutes: 15 + + steps: + - name: "Install container prerequisites" + if: ${{ matrix.prepare }} + shell: sh # bash may not be installed yet + run: ${{ matrix.prepare }} + + - name: "Checkout repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: "Install uv" + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: "Run tests" + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: uvx 'pytest==8.*' -p no:cacheprovider -v scripts/install/tests diff --git a/userland/brush/source/.github/workflows/shellcheck.yaml b/userland/brush/source/.github/workflows/shellcheck.yaml new file mode 100644 index 0000000..4aac761 --- /dev/null +++ b/userland/brush/source/.github/workflows/shellcheck.yaml @@ -0,0 +1,42 @@ +name: "Shell scripts" + +on: + pull_request: + paths: + - "**.sh" + - ".github/workflows/shellcheck.yaml" + push: + branches: ["main"] + paths: + - "**.sh" + - ".github/workflows/shellcheck.yaml" + workflow_dispatch: null + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + shellcheck: + name: "shellcheck" + + permissions: + contents: read + + runs-on: ubuntu-26.04 + timeout-minutes: 10 + + # Pinned, since findings differ across shellcheck versions. + container: koalaman/shellcheck-alpine:v0.11.0@sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002 + + steps: + - name: "Checkout repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: "Run shellcheck" + run: find . -name '*.sh' -not -path './.git/*' -exec shellcheck {} + diff --git a/userland/brush/source/.github/workflows/workflow-checks.yaml b/userland/brush/source/.github/workflows/workflow-checks.yaml new file mode 100644 index 0000000..9e646ed --- /dev/null +++ b/userland/brush/source/.github/workflows/workflow-checks.yaml @@ -0,0 +1,45 @@ +# +# This workflow was based on https://docs.zizmor.sh/integrations/ +# + +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +# N.B. This is the *online* zizmor pass: it runs the audits that need network +# access and uploads SARIF to code scanning for stateful triage in the Security +# tab, and it deliberately does not fail the build. +# +# The complementary offline pass -- which does fail, and fails fast -- is the +# `zizmor` hook in .pre-commit-config.yaml, run in CI by hooks.yaml and locally +# by `cargo xtask check hooks workflows`. The two are not redundant; do not +# collapse them without checking which audits each one actually runs. + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + zizmor: + name: zizmor latest via zizmor-action + runs-on: ubuntu-26.04 + permissions: + security-events: write # needed for SARIF uploads + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Reporting pass: uploads SARIF to code scanning, giving us stateful + # analysis and incremental triage in the Security tab. Does not fail + # with non-zero result but reports them. + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 diff --git a/userland/brush/source/.gitignore b/userland/brush/source/.gitignore new file mode 100644 index 0000000..b365ff0 --- /dev/null +++ b/userland/brush/source/.gitignore @@ -0,0 +1,4 @@ +target/ +mutants.out*/ +__pycache__/ +.pytest_cache/ diff --git a/userland/brush/source/.pre-commit-config.yaml b/userland/brush/source/.pre-commit-config.yaml new file mode 100644 index 0000000..0d1c66d --- /dev/null +++ b/userland/brush/source/.pre-commit-config.yaml @@ -0,0 +1,247 @@ +# Third-party linter definitions, and the single place their versions are +# pinned. As with GitHub Actions, each `rev:` is a full commit SHA, since a tag +# can be moved to point at different code; the `# frozen: ` comment records +# which release that is. Dependabot's `pre-commit` ecosystem keeps both current +# (see .github/dependabot.yml). +# +# Two different front ends read this file: +# +# cargo xtask check hooks # runs every hook; needs no knowledge of prek +# cargo xtask check hooks ... # runs only the named hooks, by id or by the +# # aliases below: spelling, links, workflows, deps, +# # structure +# +# ...and prek (https://prek.j178.dev) directly, as installed git hooks. CI's +# Pre-commit hooks workflow (.github/workflows/hooks.yaml) goes through +# `cargo xtask check hooks` as well, so CI and a local run execute the same +# command. prek is a single-binary Rust +# reimplementation of pre-commit; it reads this file and reimplements the +# common hygiene hooks natively, so no Python environment is needed for them. +# +# Install: cargo binstall prek (or: uv tool install prek / brew install prek) +# Enable the git hooks (optional): prek install +# +# Useful invocations: +# prek run # staged files, commit-stage hooks +# prek run --all-files # whole tree, commit-stage hooks (= check hooks) +# prek run --all-files --stage pre-push # the cargo-backed check +# prek run --all-files typos # a single hook +# prek run --all-files --skip rustfmt # skip one hook +# +# The upstream Python `pre-commit` also reads this file, but spells two of those +# flags differently: `--hook-stage` rather than `--stage`, and `SKIP=` in the +# environment rather than `--skip`. +# +# Git hooks are entirely optional -- `cargo xtask ci quick` and +# `cargo xtask ci full` are the documented contributor path and neither requires +# `prek install`. + +default_install_hook_types: [pre-commit, commit-msg, pre-push] +# `default_stages` only applies to hooks whose upstream manifest doesn't declare +# its own `stages`; several of the hygiene hooks declare [pre-commit, pre-push, +# manual], so they're pinned to the commit stage individually below. Keeping the +# fixing hooks off the pre-push stage matters: a hook that rewrites a file during +# `git push` leaves you with an already-made commit and unstaged fixes. +default_stages: [pre-commit] + +repos: + # --------------------------------------------------------------------------- + # File hygiene. prek substitutes its own native Rust implementations for these + # automatically, so nothing is cloned or installed for this block. + # --------------------------------------------------------------------------- + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + stages: [pre-commit] + # Shell fragments inside compat test cases carry semantically meaningful + # trailing whitespace; insta snapshots must match byte-for-byte; the + # changelog is generated by git-cliff. + exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$ + - id: end-of-file-fixer + stages: [pre-commit] + exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$ + - id: mixed-line-ending + args: [--fix=lf] + stages: [pre-commit] + exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$ + + - id: check-merge-conflict + - id: check-symlinks + - id: destroyed-symlinks + stages: [pre-commit] + - id: check-added-large-files + stages: [pre-commit] + - id: detect-private-key + + # brush is built and tested on macOS and Windows too. + - id: check-case-conflict + - id: check-illegal-windows-names + + - id: check-yaml + - id: check-toml + - id: check-json + # .vscode/*.json are JSONC (comments), not strict JSON. + exclude: ^\.vscode/ + + - id: check-executables-have-shebangs + stages: [pre-commit] + - id: check-shebang-scripts-are-executable + stages: [pre-commit] + # Rust files opening with an inner attribute (`#![...]`) look like they + # have a shebang. + exclude: \.rs$ + + # --------------------------------------------------------------------------- + # Schema validation for release-plz.toml, so a misspelled key or wrong type + # fails here instead of at release time. The schema is the one release-plz + # publishes, as of a release-plz release tag. Dependabot doesn't track that + # URL; bump the tag by hand when adopting newer release-plz config. + # check-jsonschema caches the download, so only the first run needs the + # network. + # --------------------------------------------------------------------------- + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 + hooks: + - id: check-jsonschema + name: Validate release-plz.toml + files: ^release-plz\.toml$ + # Upstream's manifest limits this hook to [json, yaml]. + types_or: [toml] + args: + - --schemafile + - https://raw.githubusercontent.com/release-plz/release-plz/release-plz-v0.4.3/.schema/latest.json + + # --------------------------------------------------------------------------- + # Spelling. Configured by Cargo.toml [workspace.metadata.typos]. + # --------------------------------------------------------------------------- + - repo: https://github.com/crate-ci/typos + rev: b859c0df7f391deba73030f79b957e62b4d81dc6 # frozen: varcon-core-v5.0.7 + hooks: + - id: typos + alias: spelling + # Report, don't rewrite. Overriding `args` replaces the upstream + # manifest's `[--write-changes, --force-exclude]`, and dropping + # --write-changes is the point: automatic corrections inside shell test + # cases would silently change what the tests exercise. --force-exclude + # is what makes typos honor its own exclude config for files named + # explicitly on the command line, so keep it. + args: [--force-exclude] + stages: [pre-commit] + + # --------------------------------------------------------------------------- + # GitHub Actions security analysis. Configured by zizmor.yml. This is the + # offline pass, which fails fast; the online pass that uploads SARIF lives in + # .github/workflows/workflow-checks.yaml and is not redundant with it. + # --------------------------------------------------------------------------- + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 + hooks: + - id: zizmor + alias: workflows + args: [--no-progress, --offline] + + # --------------------------------------------------------------------------- + # Link checking. Configured by lychee.toml. `types_or` mirrors lychee's own + # default `--extensions` list (markdown, html, css, txt, xml), so this reaches + # the same files as pointing lychee at the whole tree. + # + # Dependabot bumps this SHA but can't rewrite the comment, because lychee's + # tags carry a `lychee-` prefix; fix the comment by hand in its bump PRs. + # --------------------------------------------------------------------------- + - repo: https://github.com/lycheeverse/lychee + rev: 2bba271688c1abb1503097a064e6c3bc1d1b6a9b # frozen: lychee-v0.24.2 + hooks: + - id: lychee + alias: links + args: [--no-progress, --offline] + types_or: [markdown, html, css, plain-text, xml] + + # --------------------------------------------------------------------------- + # Dependency audit: advisories, licenses, bans, sources. Configured by + # deny.toml. cargo-deny is a third-party cargo subcommand, not a toolchain + # component, so rust-toolchain.toml does not pin it; this does. prek builds + # the pinned release from source (`cargo install --locked`), a one-time cost + # per version bump that is cached afterwards. Upstream's hook already passes + # `--all-features check`, which runs every check, and only fires when a + # Cargo.toml or Cargo.lock is among the files. + # --------------------------------------------------------------------------- + - repo: https://github.com/EmbarkStudios/cargo-deny + rev: bca0dde53651ee946720e4540b5ce2610bec8f06 # frozen: 0.20.2 + hooks: + - id: cargo-deny + alias: deps + + # --------------------------------------------------------------------------- + # Structural lints over the syntax tree, for architectural rules that clippy + # can't express. Configured by sgconfig.yml; the rules and their tests live + # in .config/ast-grep/. Pre-existing violations are baselined in place, and + # --error=unused-suppression fails once one of those is no longer needed, so + # the baseline can only shrink. This is a thin community wrapper around the + # official @ast-grep/cli npm package, whose version it tracks; a `repo: local` + # hook would avoid it, but Dependabot skips those. + # --------------------------------------------------------------------------- + - repo: https://github.com/boidolr/ast-grep-pre-commit + rev: 13126a89e67880b0dac82cf40350abd2cfedd458 # frozen: 0.51.0 + hooks: + - id: ast-grep + alias: structure + args: [--error=unused-suppression] + types: [rust] + # Each rule's test cases must or mustn't match. Skipping snapshot tests + # skips the extra comparison of each match's exact output (span, message) + # against a recorded __snapshots__ file, which would need regenerating on + # every rule edit without telling us more about what the rule catches. + - id: ast-grep-rule-tests + args: [--skip-snapshot-tests] + files: ^(sgconfig\.yml|\.config/ast-grep/) + + # --------------------------------------------------------------------------- + # Conventional Commits, as required by CONTRIBUTING.md and consumed by + # git-cliff and release-plz. + # --------------------------------------------------------------------------- + - repo: https://github.com/compilerla/conventional-pre-commit + rev: 33b50eb042bee26eac0ee96ad84aa9c1b4dccdd8 # frozen: v4.5.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] + + # --------------------------------------------------------------------------- + # Repo-specific checks. These delegate to xtask so that the Rust-side checks + # have exactly one definition, and so that hook users and non-hook users run + # identical code. + # --------------------------------------------------------------------------- + - repo: local + hooks: + # `rustfmt` rather than `cargo fmt --all`: passing the staged files formats + # only what is being committed. `cargo fmt --all` would rewrite files + # outside the commit, and pre-commit stashes unstaged changes before + # running hooks, so rewriting a file that also has unstaged edits makes the + # stash restore conflict. rustfmt reads rustfmt.toml for the edition. + - id: rustfmt + name: rustfmt + entry: rustfmt + language: system + types: [rust] + require_serial: true + + # One hook, not four. `ci quick` is the same command CONTRIBUTING.md tells + # contributors to run, so people who install the hooks and people who + # don't execute identical code. It covers fmt, build, clippy, and unit + # tests; schemas, cargo-deny, and integration tests are left to + # `cargo xtask ci full` and to CI. + # + # Deliberately `ci quick` and not `ci full`: `ci full` runs the pre-commit + # hooks, which invoke prek, which is what is running this hook. xtask + # refuses to start prek when PRE_COMMIT is set, so getting this wrong + # fails fast rather than recursing. + - id: xtask-ci-quick + name: cargo xtask ci quick + entry: cargo xtask ci quick + language: system + files: \.rs$|(^|/)Cargo\.(toml|lock)$|(^|/)(rustfmt|clippy)\.toml$ + pass_filenames: false + require_serial: true + stages: [pre-push] diff --git a/userland/brush/source/.rgignore b/userland/brush/source/.rgignore new file mode 100644 index 0000000..fa8e1b4 --- /dev/null +++ b/userland/brush/source/.rgignore @@ -0,0 +1,5 @@ +# Search dot-prefixed files and directories (e.g. .github, .gitignore) by default. +!.* +# Re-exclude dot-prefixed directories that would otherwise be ignored. +.git +.pytest_cache/ diff --git a/userland/brush/source/.vscode/extensions.json b/userland/brush/source/.vscode/extensions.json new file mode 100644 index 0000000..9940626 --- /dev/null +++ b/userland/brush/source/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + // List of extensions to recommend for users of this workspace. + // See https://go.microsoft.com/fwlink/?LinkId=827846 for more details. + "recommendations": [ + "rust-lang.rust-analyzer", + ], +} diff --git a/userland/brush/source/.vscode/launch.json b/userland/brush/source/.vscode/launch.json new file mode 100644 index 0000000..59240ba --- /dev/null +++ b/userland/brush/source/.vscode/launch.json @@ -0,0 +1,65 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'brush'", + "cargo": { + "args": [ + "build", + "--bin=brush", + "--package=brush-shell" + ], + "filter": { + "name": "brush", + "kind": "bin" + } + }, + "args": [ + "--enable-highlighting" + ], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug integration test 'brush-compat-tests'", + "cargo": { + "args": [ + "test", + "--no-run", + "--test=brush-compat-tests", + "--package=brush-shell" + ], + "filter": { + "name": "brush-compat-tests", + "kind": "test" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'xtask'", + "cargo": { + "args": [ + "build", + "--bin=xtask", + "--package=xtask" + ], + "filter": { + "name": "xtask", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/userland/brush/source/.vscode/settings.json b/userland/brush/source/.vscode/settings.json new file mode 100644 index 0000000..8d79b62 --- /dev/null +++ b/userland/brush/source/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "editor.formatOnSave": true, + "files.exclude": { + "target/": true + }, + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.profiles.linux": { + "bash": { + "path": "/bin/bash" + } + }, + "rust-analyzer.check.command": "clippy", + "rewrap.wrappingColumn": 90, + "rust-analyzer.debug.openDebugPane": true, + "rust-analyzer.testExplorer": true, + "rust-analyzer.check.features": "all", + "rust-analyzer.check.allTargets": true, + "rust-analyzer.check.workspace": true, + "sarif-viewer.connectToGithubCodeScanning": "on", +} diff --git a/userland/brush/source/AGENTS.md b/userland/brush/source/AGENTS.md new file mode 100644 index 0000000..b4f3999 --- /dev/null +++ b/userland/brush/source/AGENTS.md @@ -0,0 +1,349 @@ +# Agent Development Guide for `brush` + +This guide helps AI agents work efficiently on the `brush` codebase by providing essential context about architecture, patterns, and development workflows. + +## Required: Independent Implementation + +Before investigating or implementing a change, read and follow the +[independent-implementation and provenance policy](CONTRIBUTING.md#independent-implementation-and-provenance). +It is the canonical policy for contributors and all agents, including delegated +agents and their tools. Use Bash as a black-box behavioral oracle, not as +implementation source material. If you encounter Bash implementation material, +stop implementation and disclose the exposure; do not translate, summarize, or +rewrite it into brush. Disclose this to the human contributor to clarify how +to proceed. + +## 1. Architecture Overview & Navigation + +### Architecture + +[docs/explanation/architecture.md](docs/explanation/architecture.md) is the definition of +brush's architecture: its crates, how they depend on each other, how `brush-core` is +structured, and the layering principles that decide which crate code belongs in (e.g. +what `brush-core` owns vs. the interactive front end). Read it before adding a crate, +moving code between crates, or changing a public API's shape, and keep it up to date when +the architecture changes. + +### Key Files & Entry Points + +**Critical files to understand first:** + +- `brush-core/src/shell.rs` - Main `Shell` struct and creation logic +- `brush-core/src/lib.rs` - Public API exports +- `brush-shell/src/main.rs` - CLI application entry point + +**Architecture patterns:** + +- Shell instances are created via `Shell::builder()` +- The project uses builder patterns for type-safe configuration +- Platform-specific code in `brush-core` MUST live under the `sys` module +- Follows Rust 2024 edition standards + +### Module Dependencies + +```text +brush-shell → brush-interactive ─────────────────────────→ brush-core → brush-parser + ↘ brush-builtins → brush-winnow-args-adapter ↗ +``` + +## 2. Testing Strategy + +### Test Execution Priority + +**Recommended development workflow:** + +#### Using xtask (Recommended) + +The project provides a `cargo xtask` command that centralizes common development tasks: + +```bash +# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests +cargo xtask ci quick + +# Run the full workflow (~60s warm): quick + pre-commit hooks, schemas, integration tests +cargo xtask ci full + +# Run with --continue-on-error to see all failures at once +cargo xtask ci full -k + +# Add -v for verbose output showing exact commands being run +cargo xtask -v ci full +``` + +`cargo xtask ci quick` needs only the Rust toolchain. `cargo xtask ci full` also +needs `prek` on `PATH` (see CONTRIBUTING.md for install commands). If `prek` is +unavailable in your environment, run `cargo xtask ci full --no-hooks`; CI +still runs the hooks. + +#### Individual Test Commands + +```bash +# Run unit tests (fast tests excluding integration binaries) +cargo xtask test unit + +# Run integration tests (all workspace tests including compat tests) +cargo xtask test integration + +# Run tests with coverage +cargo xtask test integration --coverage --coverage-output codecov.xml +``` + +#### Manual Approach (Alternate) + +For finer-grained control: + +##### Inner Loop (Fast Iteration) + +1. **Quick validation**: `cargo check --package ` - Fast syntax/type checking +2. **Correctness validation**: `cargo test --package ` - Target specific crates for faster feedback + +##### Outer Loop (Comprehensive Testing) + +1. **Compatibility tests**: `cargo test --test brush-compat-tests` - Bash compatibility validation +2. **Full workspace tests**: `cargo test --workspace` - Complete test suite + +#### Pre-Finish Quality Validation + +**Recommended:** Run the xtask full workflow: + +```bash +cargo xtask ci full +``` + +**Manual approach:** Before considering work complete, run these validation steps: + +- **Compatibility tests**: `cargo test --test brush-compat-tests` +- **Linting**: `cargo clippy` +- **Formatting**: `cargo fmt --check` +- **Pre-commit hooks** (spelling, links, workflows, dependency audit, structural lints): `cargo xtask check hooks` (needs `prek`) +- **Full test suite**: `cargo test --workspace` + +**When tests fail:** + +- Focus on failures in the area you changed first +- Compatibility test failures often indicate shell behavior changes +- Check if new functionality needs corresponding test cases +- Format/clippy failures should be fixed before proceeding + +**Common pitfalls:** + +- **Test scope mistakes**: Running full test suite too early instead of targeting specific areas first +- **Skipping test-driven development**: Add tests that specify desired behavior before implementing + +**Test-driven development approach:** + +- When possible, write tests first that specify the desired behavior +- Use unit tests for logic changes, compatibility tests for shell behavior changes +- Use these tests as validation that your implementation is working correctly + +**Pro tip**: For specific compatibility test cases, use: + +```bash +cargo test --test brush-compat-tests -- '' +``` + +**Fast iteration strategies:** + +- Target specific crates: `cargo test --package ` +- Target specific test cases: `cargo test ` or `cargo test --test ` +- Requires knowledge of which tests best exercise the code being changed + +**Testing approach:** + +- Follow good software engineering practice: start by validating the specific area being changed, then iteratively move to incrementally broader sets of tests + +### Test Organization + +**Testing expectations for new public APIs:** + +- Unit tests are expected if feasible +- Examples are nice to have and worthwhile for sufficiently critical APIs + +**Test patterns and conventions:** + +- **Compatibility tests**: For any compatibility-related fixes, it's critical to add new test cases to the compat tests (see docs/how-to/run-tests.md and section 3 for when breaking changes apply) + +**Test categories:** + +- Unit tests: In `src/` files with `#[cfg(test)]` +- Integration tests: In `tests/` directories +- Examples: In `examples/` directories (must be runnable) +- Shell script tests: YAML-based test cases in `brush-shell/tests/cases/` + +### Performance Testing + +**Performance regression testing:** + +- Not a chief concern for most changes +- For performance-specific work, benchmarks are available (see docs/how-to/run-benchmarks.md) +- Performance sensitivity will be identified in the initial brief if relevant + +## 3. Breaking Changes & Compatibility + +### API Stability Guidelines + +**Breaking change policy:** + +- Non-backwards compatible changes to public APIs are considered breaking +- Breaking changes are still in consideration, but need to be highlighted and carefully reviewed +- Any APIs exported from crates are considered public because all of the crates are published to crates.io + +**Adding new fields to public structs:** + +- New optional fields are fine to add as long as the struct implements the Default trait and as long as the defaulted value is a sensible one + +### Dependency Impact + +When changing public APIs in `brush-core` (see section 3 for breaking change policy): + +1. Check `brush-shell/src/main.rs` for struct initialization sites +2. Check `brush-interactive/` for any usage + +## 4. Documentation & Examples Standards + +### Documentation Requirements + +**Rustdoc documentation standards:** + +- At minimum we must have good rustdoc documentation for exported types, functions, traits, etc. as well as on all exported modules and crates +- Documentation for internal components should be a best-effort, nice to have thing + +**Examples for new features:** + +- Unless explicitly requested, only major feature additions warrant an example. + +**Documentation style:** + +- Follow general best practices for Rust + +### Example Standards + +Examples should: + +- Be self-contained and runnable with `cargo run --package brush-core --example ` +- Include comprehensive error handling +- Demonstrate both basic and advanced usage patterns +- Include output examples in comments when helpful + +## 5. Build & Release Process + +### Development Tools + +The project uses several tools for code quality: + +**Using xtask (Recommended):** + +The project provides a `cargo xtask` command that centralizes common development tasks: + +```bash +# Run all checks (comprehensive) +cargo xtask ci full + +# Individual checks +cargo xtask check fmt # Format check +cargo xtask check lint # Clippy +cargo xtask check build # Compilation check +cargo xtask check crates # Crate structure (e.g. LICENSE symlinks) +cargo xtask check schemas # Schema drift check +cargo xtask check hooks # prek hooks: file hygiene, typos, zizmor, lychee, cargo-deny, ast-grep +cargo xtask check hooks deps # One hook, by id or alias + +# Tests +cargo xtask test unit # Fast unit tests (excludes integration binaries) +cargo xtask test integration # All workspace tests (unit + compat) + +# Analysis +cargo xtask analyze bench # Run benchmarks +``` + +**Manual approach (Alternate):** + +- Standard cargo commands (e.g., check, test, build, run, clippy) +- You may need to reverse engineer some of the args looking at CI checks in .github/*.yml + +**Command frequency guidelines:** + +- **Frequent (inner loop)**: `cargo xtask ci quick`, `cargo check`, `cargo test --package ` +- **Regular (before commits)**: `cargo xtask ci quick` or `cargo fmt` + `cargo clippy` +- **Occasional (outer loop)**: `cargo xtask test integration` or `cargo test --workspace` +- **Rare (before opening a PR)**: `cargo xtask ci full`, which adds the pre-commit hooks, schemas, and integration tests + +**Pre-commit validation:** + +- Recommended: `cargo xtask ci quick` +- Before opening a PR: `cargo xtask ci full` +- Manual: Run `cargo fmt` and `cargo clippy` before committing + +**Outer loop validation:** + +- `cargo xtask check hooks` should pass (file hygiene, typos, zizmor, lychee, cargo-deny, ast-grep) - not for frequent use during development; `cargo xtask ci full --no-hooks` skips it when `prek` is unavailable + +## 6. Performance & Error Handling Patterns + +### Error Handling + +**Error handling patterns:** + +- `thiserror` is used for implementing crate-specific errors +- Use `anyhow` only in tests + +**Logging and tracing patterns:** + +- Use `tracing` for debug logging with predefined categories +- Categories are defined in `trace_categories.rs` modules (e.g., `COMMANDS`, `COMPLETION`, `EXPANSION`, `FUNCTIONS`, `INPUT`, `JOBS`, `PARSE`, `PATTERN`, `UNIMPLEMENTED`) +- Usage pattern: `tracing::debug!(target: trace_categories::CATEGORY_NAME, "message")` +- Example: `tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", job_id)` + +### Performance Considerations + +**Clone vs references:** + +- Avoid cloning by default, no reason to make extra copies +- Only use cloning when you really must capture a separate copy for async safety or similarly important reasons + +--- + +## 7. Commit Message Conventions + +Following the Linux kernel convention, when AI assistants or advanced coding tools have been used in the development of a change, include an `Assisted-by:` tag in the commit message. This provides transparency about the use of such tools in the development process. + +The format for the Assisted-by tag is: +``` +Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2] +``` + +Example: +``` +Assisted-by: Mistral Vibe:mistral-medium-3.5 +``` + +## Quick Reference Checklist + +When making changes to brush: + +### Before Starting + +- [ ] Understand which crate(s) are affected +- [ ] Check if changes might break dependent crates +- [ ] Identify relevant test files and examples + +### During Development + +- [ ] Run `cargo check` frequently during development +- [ ] Test changes with package-specific tests first (see section 2 for testing workflow) +- [ ] Update dependent crate usage if needed (see section 3 for compatibility considerations) +- [ ] Add/update examples for major feature additions only (see section 4) + +### Before Committing + +- [ ] Run full test suite: `cargo test` (see section 2 for complete testing workflow) +- [ ] Format code: `cargo fmt` (see section 5 for tool details) +- [ ] Check linting: `cargo clippy` +- [ ] Use conventional commit format +- [ ] If using AI assistants or advanced coding tools, include an `Assisted-by:` tag in the format `AGENT_NAME:MODEL_VERSION` (following Linux kernel convention) + +### Documentation + +- [ ] Add rustdoc to exported APIs (see section 4 for documentation standards) +- [ ] Include working examples for major features only +- [ ] Update this guide if new patterns emerge diff --git a/userland/brush/source/CHANGELOG.md b/userland/brush/source/CHANGELOG.md new file mode 100644 index 0000000..ec56f3a --- /dev/null +++ b/userland/brush/source/CHANGELOG.md @@ -0,0 +1,1074 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +All notable changes to this project will be documented in this file. + +## [0.4.0] - 2026-05-02 + +### 🚀 Features + +- *(parser)* Add official `serde` feature to `brush-parser` for AST serialization ([#783](https://github.com/reubeno/brush/pull/783)) +- *(parser)* [**breaking**] Replace ParsingNearToken with ParsingNear ([#1022](https://github.com/reubeno/brush/pull/1022)) +- Introduce notion of marking errors as fatal ([#770](https://github.com/reubeno/brush/pull/770)) +- Expose key bindings for hint acceptance ([#802](https://github.com/reubeno/brush/pull/802)) +- Add 'serde' feature to `brush-core` ([#831](https://github.com/reubeno/brush/pull/831)) +- Enable using word expander without command substs ([#877](https://github.com/reubeno/brush/pull/877)) +- Enable session leading config ([#904](https://github.com/reubeno/brush/pull/904)) +- Implement \l sequence in prompt expansion ([#913](https://github.com/reubeno/brush/pull/913)) +- *(windows)* Various path fixes for windows platform ([#1075](https://github.com/reubeno/brush/pull/1075)) +- Implement fatal error propagation ([#773](https://github.com/reubeno/brush/pull/773)) +- Implement `set -u` semantics ([#774](https://github.com/reubeno/brush/pull/774)) +- Source tracking improvements ([#805](https://github.com/reubeno/brush/pull/805)) +- Support asynchronous builtin execution ([#810](https://github.com/reubeno/brush/pull/810)) +- *(builtins)* Implement caller builtin ([#812](https://github.com/reubeno/brush/pull/812)) +- Implement "compgen -A binding" ([#814](https://github.com/reubeno/brush/pull/814)) +- Emulate `exec` in subshell ([#823](https://github.com/reubeno/brush/pull/823)) +- Experimental builtin for serializing full shell state ([#835](https://github.com/reubeno/brush/pull/835)) +- Expand tilde expansion support ([#842](https://github.com/reubeno/brush/pull/842)) +- Implement `set -e` (a.k.a. `errexit`) + `pipefail` semantics ([#852](https://github.com/reubeno/brush/pull/852)) +- Experimental shell/terminal integration ([#872](https://github.com/reubeno/brush/pull/872)) +- *(hooks)* Experimental `zsh`-style preexec/precmd hooks ([#652](https://github.com/reubeno/brush/pull/652)) +- Rudimentary readline macro support ([#880](https://github.com/reubeno/brush/pull/880)) +- Optional TOML-based config file for brush-shell ([#895](https://github.com/reubeno/brush/pull/895)) +- *(read)* Fill out more of `read` builtin ([#914](https://github.com/reubeno/brush/pull/914)) +- Add convenience cmd line option to set up xtrace ([#915](https://github.com/reubeno/brush/pull/915)) +- Scaffolding for winnow parser ([#974](https://github.com/reubeno/brush/pull/974)) +- *(completion)* Implement COMP_KEY and COMP_TYPE ([#1008](https://github.com/reubeno/brush/pull/1008)) +- *(options)* Implement failglob option ([#1011](https://github.com/reubeno/brush/pull/1011)) +- *(well-known-vars)* Implement BASH_ARGC and BASH_ARGV variables ([#1013](https://github.com/reubeno/brush/pull/1013)) +- *(getopts)* Implement OPTERR semantics ([#1048](https://github.com/reubeno/brush/pull/1048)) +- *(mapfile)* Add `-O` flag ([#558](https://github.com/reubeno/brush/pull/558)) +- Add coproc AST and placeholder execution ([#1029](https://github.com/reubeno/brush/pull/1029)) +- *(trap)* Implement ERR trap ([#1020](https://github.com/reubeno/brush/pull/1020)) +- *(windows)* Add compat /dev/null handling ([#1044](https://github.com/reubeno/brush/pull/1044)) +- *(coproc)* Implement coprocs ([#1068](https://github.com/reubeno/brush/pull/1068)) +- Implement $_ well-known variable for last command argument ([#1030](https://github.com/reubeno/brush/pull/1030)) +- Add optional bundling of coreutils builtins behind experimental feature flag ([#1031](https://github.com/reubeno/brush/pull/1031)) + +### 🐛 Bug Fixes + +- Fill in more SourceLocation implementations ([#804](https://github.com/reubeno/brush/pull/804)) +- Correct parsing of escaped backslash in ANSI-C quotes ([#850](https://github.com/reubeno/brush/pull/850)) +- Bang formatted without space leads to invalid command ([#1109](https://github.com/reubeno/brush/pull/1109)) +- Avoid allocating in `pre_exec` closure ([#777](https://github.com/reubeno/brush/pull/777)) +- Improve "compgen -A {user,group}" ([#815](https://github.com/reubeno/brush/pull/815)) +- Correct completion word tokenizing ([#816](https://github.com/reubeno/brush/pull/816)) +- Honor mark-directories option ([#817](https://github.com/reubeno/brush/pull/817)) +- Correct scope mismatch on error ([#821](https://github.com/reubeno/brush/pull/821)) +- Suppress color when help is redirected ([#822](https://github.com/reubeno/brush/pull/822)) +- History import robustness ([#878](https://github.com/reubeno/brush/pull/878)) +- Avoid some allocation in brace-expansion ([#884](https://github.com/reubeno/brush/pull/884)) +- Eliminate unneeded clone ([#886](https://github.com/reubeno/brush/pull/886)) +- More direct Intos ([#946](https://github.com/reubeno/brush/pull/946)) +- Remove ref-options ([#952](https://github.com/reubeno/brush/pull/952)) +- Don't clobber builtin usage error if error display fails ([#965](https://github.com/reubeno/brush/pull/965)) +- Use std::env::{split_paths, join_paths} for `PATH` splitting/combining ([#968](https://github.com/reubeno/brush/pull/968)) +- Handle more readline macro forms ([#967](https://github.com/reubeno/brush/pull/967)) +- Build for freebsd ([#980](https://github.com/reubeno/brush/pull/980)) +- *(commands)* Use async pipe reads for command substitution +- Do not panic on file clone failure ([#1051](https://github.com/reubeno/brush/pull/1051)) +- *(wasm)* Avoid panic in split_paths on wasm ([#1064](https://github.com/reubeno/brush/pull/1064)) +- *(android)* Address android and 32-bit build issues ([#1070](https://github.com/reubeno/brush/pull/1070)) +- Address hang on macos when used as login shell ([#1095](https://github.com/reubeno/brush/pull/1095)) +- *(windows)* Fix /dev/null handling ([#1104](https://github.com/reubeno/brush/pull/1104)) +- Expose -c flag via $- ([#767](https://github.com/reubeno/brush/pull/767)) +- Don't allow builtin to invoke disabled builtin ([#719](https://github.com/reubeno/brush/pull/719)) +- Getopts with no provided args ([#796](https://github.com/reubeno/brush/pull/796)) +- Preserve $? during interactive shell prompt expansion ([#798](https://github.com/reubeno/brush/pull/798)) +- Correct initialization of SHELL var ([#800](https://github.com/reubeno/brush/pull/800)) +- Correct exit status handling in assignment-only cmds ([#801](https://github.com/reubeno/brush/pull/801)) +- Extended test redirection ([#811](https://github.com/reubeno/brush/pull/811)) +- Negative substring offsets ([#818](https://github.com/reubeno/brush/pull/818)) +- Do not tilde-expand prompts ([#819](https://github.com/reubeno/brush/pull/819)) +- Escaping in pattern character sets ([#824](https://github.com/reubeno/brush/pull/824)) +- Special-casing for OLDPWD ([#825](https://github.com/reubeno/brush/pull/825)) +- Omit unset vars from `set` builtin output ([#826](https://github.com/reubeno/brush/pull/826)) +- Getopts short options ([#827](https://github.com/reubeno/brush/pull/827)) +- Update default of COMP_WORDBREAKS ([#828](https://github.com/reubeno/brush/pull/828)) +- Disable reedline for cmd mode / script exec ([#845](https://github.com/reubeno/brush/pull/845)) +- Correct trace depth computation ([#847](https://github.com/reubeno/brush/pull/847)) +- Assorted ANSI-C quote compat fixes ([#851](https://github.com/reubeno/brush/pull/851)) +- True and false must ignore all args ([#865](https://github.com/reubeno/brush/pull/865)) +- Gracefully handle closed stdout ([#873](https://github.com/reubeno/brush/pull/873)) +- Gracefully handle closed stderr ([#875](https://github.com/reubeno/brush/pull/875)) +- Delay profile/rc loading until after input backend attached ([#879](https://github.com/reubeno/brush/pull/879)) +- *(completion)* Properly escape filenames with spaces or special chars ([#870](https://github.com/reubeno/brush/pull/870)) +- *(unset)* Allow unsetting non-standard names ([#893](https://github.com/reubeno/brush/pull/893)) +- *(test)* Correct handling of -- and invalid test exprs ([#894](https://github.com/reubeno/brush/pull/894)) +- Reenable a few tests ([#896](https://github.com/reubeno/brush/pull/896)) +- Handle redir form: `>&` PATH ([#908](https://github.com/reubeno/brush/pull/908)) +- Compat fixes for \[ and \] in prompt expansion ([#909](https://github.com/reubeno/brush/pull/909)) +- Parsing array accesses in array indices ([#910](https://github.com/reubeno/brush/pull/910)) +- Add missing assignment arithmetic operators (|=, ^=) ([#911](https://github.com/reubeno/brush/pull/911)) +- Correct overzealous regex escaping ([#912](https://github.com/reubeno/brush/pull/912)) +- Systematic removal of problematic .unwrap() calls ([#921](https://github.com/reubeno/brush/pull/921)) +- *(builtins)* Shadowing special builtins with funcs ([#922](https://github.com/reubeno/brush/pull/922)) +- *(hash)* Accept names with slashes without lookup or error ([#924](https://github.com/reubeno/brush/pull/924)) +- Ignore errors in -x tracing ([#928](https://github.com/reubeno/brush/pull/928)) +- Trim space in test comparisons ([#950](https://github.com/reubeno/brush/pull/950)) +- Exit status in command arg expansion failure ([#951](https://github.com/reubeno/brush/pull/951)) +- Correctly handle pipe errors in builtins ([#953](https://github.com/reubeno/brush/pull/953)) +- *(completion)* Provide default fallback completion for shell vars ([#954](https://github.com/reubeno/brush/pull/954)) +- *(extglob)* Handle escaped parens ([#960](https://github.com/reubeno/brush/pull/960)) +- *(patterns)* Correct backslash preservation ([#963](https://github.com/reubeno/brush/pull/963)) +- *(return)* Exit code when stderr is not writable ([#975](https://github.com/reubeno/brush/pull/975)) +- Check func names against reserved words ([#978](https://github.com/reubeno/brush/pull/978)) +- *(test)* Resolve bash to absolute path before running compat tests ([#994](https://github.com/reubeno/brush/pull/994)) +- *(pathsearch)* Include symlink'd executables in search ([#991](https://github.com/reubeno/brush/pull/991)) ([#992](https://github.com/reubeno/brush/pull/992)) +- Readonly should operate on global scope ([#1003](https://github.com/reubeno/brush/pull/1003)) +- *(peg)* Support legacy $[expr] arithmetic syntax ([#1004](https://github.com/reubeno/brush/pull/1004)) +- *(arrays)* Add set + unset support for negative array indices ([#1005](https://github.com/reubeno/brush/pull/1005)) +- *(expansion)* Allow ${#arr[i]} on unset variables with set -u ([#1007](https://github.com/reubeno/brush/pull/1007)) +- *(bind)* Silently succeed when key bindings unavailable ([#1009](https://github.com/reubeno/brush/pull/1009)) +- *(compgen)* Disable pathname expansion for -W word list ([#1010](https://github.com/reubeno/brush/pull/1010)) +- *(completion)* Only deduplicate completions in interactive presentation ([#1012](https://github.com/reubeno/brush/pull/1012)) +- *(trap)* - with multiple signals should clear all handlers ([#1015](https://github.com/reubeno/brush/pull/1015)) +- *(heredoc)* Preserve quotes in heredoc expansion (rebased) ([#1014](https://github.com/reubeno/brush/pull/1014)) +- *(arithmetic)* Allow space around prefix arithmetic operators ([#1016](https://github.com/reubeno/brush/pull/1016)) +- *(arithmetic)* More faithful overflow/underflow handling ([#1017](https://github.com/reubeno/brush/pull/1017)) +- *(arithmetic-for)* Correct parsing + eval issues ([#1018](https://github.com/reubeno/brush/pull/1018)) +- *(arithmetic)* Implement high-radix literals ([#1019](https://github.com/reubeno/brush/pull/1019)) +- *(printf)* Quote empty strings as '' in printf %q ([#1026](https://github.com/reubeno/brush/pull/1026)) +- *(expansion)* Correct descending brace sequence expansion with step ([#1025](https://github.com/reubeno/brush/pull/1025)) +- *(builtins)* Add builtin commands to `compgen -A command`([#997](https://github.com/reubeno/brush/pull/997)) ([#1027](https://github.com/reubeno/brush/pull/1027)) +- *(expansion)* Preserve quoted empty strings with nullglob enabled ([#1035](https://github.com/reubeno/brush/pull/1035)) +- *(command_subst)* Strip NULL bytes from command substitutions ([#1049](https://github.com/reubeno/brush/pull/1049)) +- Return 2 for unknown cmdline options ([#1050](https://github.com/reubeno/brush/pull/1050)) +- *(complete)* Complete current and parent directories. ([#887](https://github.com/reubeno/brush/pull/887)) +- *(arithmetic)* Detect infinite arithmetic var reference recursion ([#1021](https://github.com/reubeno/brush/pull/1021)) +- *(tokenizer)* Prioritize heredoc body over terminating char in `next_token_until` ([#1055](https://github.com/reubeno/brush/pull/1055)) +- *(tokenizer)* Unquote heredoc end tag in `delimit_current_token` ([#1056](https://github.com/reubeno/brush/pull/1056)) ([#1057](https://github.com/reubeno/brush/pull/1057)) +- Use i64 for exit ([#1065](https://github.com/reubeno/brush/pull/1065)) +- *(heredoc)* Handle unbalanced single quotes and backticks in heredoc bodies inside `$(…)` ([#1067](https://github.com/reubeno/brush/pull/1067)) +- *(cmdline)* Handle `--` as option terminator for `-c` flag ([#1076](https://github.com/reubeno/brush/pull/1076)) +- *(login)* Correctly reflect login option via shopt ([#1078](https://github.com/reubeno/brush/pull/1078)) +- *(-c)* Call EXIT traps on exit; add tests ([#1080](https://github.com/reubeno/brush/pull/1080)) +- *(builtins)* Add reserved keywords to `compgen -A command` ([#1047](https://github.com/reubeno/brush/pull/1047)) +- *(-f)* Implement -f command line option ([#1091](https://github.com/reubeno/brush/pull/1091)) +- *(parser)* Preserve whitespace in regex operands of extended tests ([#1096](https://github.com/reubeno/brush/pull/1096)) +- *(wellknownvars)* Set OSTYPE on macOS ([#1097](https://github.com/reubeno/brush/pull/1097)) + +### 🚜 Refactor + +- Reexport parser types ([#784](https://github.com/reubeno/brush/pull/784)) +- Reorganize existing PEG parser ([#899](https://github.com/reubeno/brush/pull/899)) +- Split shell.rs => shell/*.rs ([#945](https://github.com/reubeno/brush/pull/945)) +- Use let chains ([#982](https://github.com/reubeno/brush/pull/982)) +- Make `try_iter_open_fds` fully iterator-based ([#1001](https://github.com/reubeno/brush/pull/1001)) +- Rename TokenLocation -> SourceSpan ([#788](https://github.com/reubeno/brush/pull/788)) +- Merge func call and script stacks ([#789](https://github.com/reubeno/brush/pull/789)) +- Merge brushinfo and brushctl ([#813](https://github.com/reubeno/brush/pull/813)) +- Shell / interactive-shell layering ([#832](https://github.com/reubeno/brush/pull/832)) +- Make Shell fields private ([#900](https://github.com/reubeno/brush/pull/900)) +- Extract build/test/validate commands into `xtask` ([#898](https://github.com/reubeno/brush/pull/898)) +- Reuse test harness for non-oracle tests ([#916](https://github.com/reubeno/brush/pull/916)) +- [**breaking**] Make `Shell` generic over `ShellExtensions` type param ([#941](https://github.com/reubeno/brush/pull/941)) + +### 📚 Documentation + +- Update todo comments ([#787](https://github.com/reubeno/brush/pull/787)) +- Update readme ([#853](https://github.com/reubeno/brush/pull/853)) +- Update readme ([#976](https://github.com/reubeno/brush/pull/976)) +- Minor updates ([#1061](https://github.com/reubeno/brush/pull/1061)) +- Update compat tables ([#1062](https://github.com/reubeno/brush/pull/1062)) +- Update header image on readme ([#1113](https://github.com/reubeno/brush/pull/1113)) +- Update reference docs ([#1114](https://github.com/reubeno/brush/pull/1114)) + +### ⚡ Performance + +- Assorted performance changes ([#936](https://github.com/reubeno/brush/pull/936)) +- Prealloc capacity for some vectors, remove unnecessary Strings ([#940](https://github.com/reubeno/brush/pull/940)) +- Address regex cache contention ([#1043](https://github.com/reubeno/brush/pull/1043)) + +### 🧪 Testing + +- *(parser)* Per syntax element tests ([#938](https://github.com/reubeno/brush/pull/938)) +- Add error handling tests, resolve nextest issue ([#768](https://github.com/reubeno/brush/pull/768)) +- Extend IFS tests ([#775](https://github.com/reubeno/brush/pull/775)) +- Add not-yet-passing call-stack/`caller` tests ([#785](https://github.com/reubeno/brush/pull/785)) +- Skip confused test ([#820](https://github.com/reubeno/brush/pull/820)) +- Refactor + augment expansion test cases ([#846](https://github.com/reubeno/brush/pull/846)) +- More tests for traps and related options ([#947](https://github.com/reubeno/brush/pull/947)) +- Add mkosi config ([#1093](https://github.com/reubeno/brush/pull/1093)) +- Add basic wasm32-wasip2 tests to CI ([#1098](https://github.com/reubeno/brush/pull/1098)) +- *(nameref)* Add large set of nameref-focused tests ([#1102](https://github.com/reubeno/brush/pull/1102)) +- Temporarily disable flaky test ([#1103](https://github.com/reubeno/brush/pull/1103)) + +### ⚙️ Miscellaneous Tasks + +- Rename feature: fuzz-testing => arbitrary ([#844](https://github.com/reubeno/brush/pull/844)) +- Switch non-Unix plats to use std::env::home_dir ([#830](https://github.com/reubeno/brush/pull/830)) +- Update dependencies ([#985](https://github.com/reubeno/brush/pull/985)) +- *(clippy)* Fix nightly lints ([#998](https://github.com/reubeno/brush/pull/998)) +- Enable windows tests ([#1083](https://github.com/reubeno/brush/pull/1083)) +- Replace git expectrl dependency with v0.8.0 release ([#779](https://github.com/reubeno/brush/pull/779)) +- Workflow sync + cleanup ([#790](https://github.com/reubeno/brush/pull/790)) +- Upgrade several dependencies ([#843](https://github.com/reubeno/brush/pull/843)) +- Add placeholders for new named options (not yet supported) ([#848](https://github.com/reubeno/brush/pull/848)) +- Remove pprof dependency ([#891](https://github.com/reubeno/brush/pull/891)) +- Update dependencies ([#892](https://github.com/reubeno/brush/pull/892)) +- Update dependencies ([#923](https://github.com/reubeno/brush/pull/923)) +- Upgrade deps ([#942](https://github.com/reubeno/brush/pull/942)) +- Update dependencies ([#966](https://github.com/reubeno/brush/pull/966)) +- Update dependencies ([#977](https://github.com/reubeno/brush/pull/977)) +- Upgrade MSRV to 1.88 and update deps ([#981](https://github.com/reubeno/brush/pull/981)) +- Upgrade dependencies ([#1063](https://github.com/reubeno/brush/pull/1063)) +- Address clippy error in xtask ([#1108](https://github.com/reubeno/brush/pull/1108)) + +### Build + +- *(deps)* Bump utf8-chars from 3.0.5 to 3.0.6 in the cargo group ([#781](https://github.com/reubeno/brush/pull/781)) +- *(deps)* Bump the cargo group with 2 updates ([#807](https://github.com/reubeno/brush/pull/807)) +- *(deps)* Bump the cargo group with 3 updates ([#836](https://github.com/reubeno/brush/pull/836)) +- *(deps)* Bump uuid from 1.18.1 to 1.19.0 in the cargo group ([#840](https://github.com/reubeno/brush/pull/840)) +- *(deps)* Bump the cargo group with 3 updates ([#882](https://github.com/reubeno/brush/pull/882)) +- *(deps)* Bump the cargo group with 3 updates ([#925](https://github.com/reubeno/brush/pull/925)) +- *(deps)* Bump thiserror from 2.0.17 to 2.0.18 in the cargo group ([#961](https://github.com/reubeno/brush/pull/961)) +- *(deps)* Bump the cargo group with 8 updates ([#1024](https://github.com/reubeno/brush/pull/1024)) +- *(deps)* Bump the cargo group with 4 updates ([#1032](https://github.com/reubeno/brush/pull/1032)) +- *(deps)* Bump the cargo group with 6 updates ([#1041](https://github.com/reubeno/brush/pull/1041)) +- *(deps)* Bump the cargo group with 7 updates ([#1059](https://github.com/reubeno/brush/pull/1059)) +- *(deps)* Bump the cargo group with 4 updates ([#1081](https://github.com/reubeno/brush/pull/1081)) +- *(deps)* Bump the cargo group with 4 updates ([#1088](https://github.com/reubeno/brush/pull/1088)) +- *(deps)* Bump the cargo group with 5 updates ([#1112](https://github.com/reubeno/brush/pull/1112)) + + + +## [0.3.0] - 2025-11-17 + +### 🚀 Features + +- Add ShellBuilder, ParserBuilder ([#651](https://github.com/reubeno/brush/pull/651)) +- [**breaking**] Refactor SourcePosition to use Arc ([#727](https://github.com/reubeno/brush/pull/727)) +- *(bind)* Extend key binding support ([#740](https://github.com/reubeno/brush/pull/740)) +- Enable custom error formatting ([#722](https://github.com/reubeno/brush/pull/722)) +- [**breaking**] Introduce SourceLocation ([#728](https://github.com/reubeno/brush/pull/728)) +- Implement fc builtin ([#739](https://github.com/reubeno/brush/pull/739)) +- Implement alternate arithmetic for syntax ([#744](https://github.com/reubeno/brush/pull/744)) +- Implement BASH_XTRACEFD ([#747](https://github.com/reubeno/brush/pull/747)) +- Revisit how ExecutionParameters layer open files atop the Shell ([#749](https://github.com/reubeno/brush/pull/749)) +- Basic support for trap EXIT ([#750](https://github.com/reubeno/brush/pull/750)) +- Implement opt-in fd inheritance from host env ([#753](https://github.com/reubeno/brush/pull/753)) + +### 🐛 Bug Fixes + +- Workaround error on nightly ([#711](https://github.com/reubeno/brush/pull/711)) +- Comment unsafe blocks + better harden 1 block ([#733](https://github.com/reubeno/brush/pull/733)) +- Don't fail importing unreadable history lines ([#710](https://github.com/reubeno/brush/pull/710)) +- Tokenizer handling of here docs in quoted command substitutions ([#716](https://github.com/reubeno/brush/pull/716)) +- Address lint errors from stable + nightly ([#723](https://github.com/reubeno/brush/pull/723)) +- *(builtins)* Correct `read` var update on empty input ([#729](https://github.com/reubeno/brush/pull/729)) +- Address race conditions in basic input tests ([#730](https://github.com/reubeno/brush/pull/730)) +- Do not pass along exported-but-unset vars ([#732](https://github.com/reubeno/brush/pull/732)) +- *(builtins)* Suppress error when type -p sees no command ([#745](https://github.com/reubeno/brush/pull/745)) +- Command substitutions with large output ([#748](https://github.com/reubeno/brush/pull/748)) +- Expansion in select double-quoted parameter exprs ([#751](https://github.com/reubeno/brush/pull/751)) +- Correct expansion behavior in prompts ([#756](https://github.com/reubeno/brush/pull/756)) +- Escaping and pipeline parse issues ([#762](https://github.com/reubeno/brush/pull/762)) + +### 🚜 Refactor + +- Use `Shell` builder pattern in more code ([#688](https://github.com/reubeno/brush/pull/688)) +- Os_pipe::pipe() -> std::io::pipe() ([#695](https://github.com/reubeno/brush/pull/695)) +- Extract script + function call stacks to their own modules ([#709](https://github.com/reubeno/brush/pull/709)) +- Update Shell::new() to take creation options as owned ([#689](https://github.com/reubeno/brush/pull/689)) +- Move builtins into their own crate ([#690](https://github.com/reubeno/brush/pull/690)) +- Shell struct API improvements ([#692](https://github.com/reubeno/brush/pull/692)) +- Error/result type overhaul ([#720](https://github.com/reubeno/brush/pull/720)) +- Move more platform-specific code under sys ([#735](https://github.com/reubeno/brush/pull/735)) + +### 📚 Documentation + +- Update readme ([#742](https://github.com/reubeno/brush/pull/742)) + +### 🧪 Testing + +- Add not-yet-passing tests for set -u and set -e ([#736](https://github.com/reubeno/brush/pull/736)) +- Add new command substitution test case ([#752](https://github.com/reubeno/brush/pull/752)) + +### ⚙️ Miscellaneous Tasks + +- Run static code checks on linux + macOS too ([#678](https://github.com/reubeno/brush/pull/678)) +- Fix build error with cargo nightly ([#687](https://github.com/reubeno/brush/pull/687)) +- *(msrv)* [**breaking**] Upgrade MSRV to 1.87.0 ([#693](https://github.com/reubeno/brush/pull/693)) +- Fix benchmark execution ([#691](https://github.com/reubeno/brush/pull/691)) +- Update dependencies ([#696](https://github.com/reubeno/brush/pull/696)) +- Update dependencies ([#757](https://github.com/reubeno/brush/pull/757)) +- Add brush crate + docs publishing ([#760](https://github.com/reubeno/brush/pull/760)) + +### Build + +- *(deps)* Bump procfs from 0.17.0 to 0.18.0 in the cargo group across 1 directory ([#671](https://github.com/reubeno/brush/pull/671)) +- *(deps)* Bump bon from 3.7.2 to 3.8.0 in the cargo group ([#698](https://github.com/reubeno/brush/pull/698)) +- *(deps)* Bump the cargo group with 4 updates ([#676](https://github.com/reubeno/brush/pull/676)) +- *(deps)* Bump serde from 1.0.221 to 1.0.223 in the cargo group ([#680](https://github.com/reubeno/brush/pull/680)) +- *(deps)* Bump the cargo group with 5 updates ([#684](https://github.com/reubeno/brush/pull/684)) +- *(deps)* Bump the cargo group with 3 updates ([#686](https://github.com/reubeno/brush/pull/686)) +- *(deps)* Bump the cargo group with 5 updates ([#708](https://github.com/reubeno/brush/pull/708)) +- *(deps)* Bump the cargo group with 8 updates ([#713](https://github.com/reubeno/brush/pull/713)) +- *(deps)* Bump the cargo group with 3 updates ([#755](https://github.com/reubeno/brush/pull/755)) + + + +## [0.2.23] - 2025-08-30 + +### 🐛 Bug Fixes + +- *(cmdline)* Correct exit code for `--version` + `--help` ([#667](https://github.com/reubeno/brush/pull/667)) + + +## [0.2.22] - 2025-08-29 + +### 🚀 Features + +- *(diag)* Add minimal miette support to parser ([#648](https://github.com/reubeno/brush/pull/648)) + +### 🐛 Bug Fixes + +- Exclude bind-bound commands from history ([#650](https://github.com/reubeno/brush/pull/650)) +- *(cmdline)* Improve error handling for unknown cmdline options ([#656](https://github.com/reubeno/brush/pull/656)) + +### 📚 Documentation + +- Update readme ([#657](https://github.com/reubeno/brush/pull/657)) + +### ⚙️ Miscellaneous Tasks + +- Additional clippy fixes ([#661](https://github.com/reubeno/brush/pull/661)) +- Downgrade homedir ([#662](https://github.com/reubeno/brush/pull/662)) +- Address warnings on windows targets ([#663](https://github.com/reubeno/brush/pull/663)) + +### Build + +- *(deps)* Bump the cargo group with 4 updates ([#654](https://github.com/reubeno/brush/pull/654)) +- *(deps)* Bump the cargo group with 4 updates ([#659](https://github.com/reubeno/brush/pull/659)) +- *(deps)* Bump tracing-subscriber from 0.3.19 to 0.3.20 in the cargo group ([#664](https://github.com/reubeno/brush/pull/664)) + + +## [0.2.21] - 2025-08-13 + +### 🚀 Features + +- *(history)* Implement history builtin ([#599](https://github.com/reubeno/brush/pull/599)) + +### 🐛 Bug Fixes + +- *(parser)* Resolve issue with parser confusing subshell for arith expr ([#624](https://github.com/reubeno/brush/pull/624)) +- *(expansion)* Support broader set of nested brace expansions ([#625](https://github.com/reubeno/brush/pull/625)) +- Correct obvious string indexing errors ([#641](https://github.com/reubeno/brush/pull/641)) +- Fixes for preexec-style bash extensions ([#643](https://github.com/reubeno/brush/pull/643)) +- Prepare tests to run against bash-5.3 ([#610](https://github.com/reubeno/brush/pull/610)) +- *(unset)* Correct unset of associative array element ([#626](https://github.com/reubeno/brush/pull/626)) +- *(declare)* Refine varname validation ([#629](https://github.com/reubeno/brush/pull/629)) +- Hyphenated script args ([#630](https://github.com/reubeno/brush/pull/630)) +- Special case for command subst ([#632](https://github.com/reubeno/brush/pull/632)) + +### 📚 Documentation + +- *(readme)* Update Arch Linux install instructions ([#604](https://github.com/reubeno/brush/pull/604)) +- Adds homebrew section to README installation instructions ([#638](https://github.com/reubeno/brush/pull/638)) + +### ⚙️ Miscellaneous Tasks + +- Publish license notices in CD flow ([#622](https://github.com/reubeno/brush/pull/622)) +- Update dependencies ([#623](https://github.com/reubeno/brush/pull/623)) +- Cleanup allow attributes, switch to expect where possible ([#642](https://github.com/reubeno/brush/pull/642)) + +### Build + +- *(deps)* Bump indenter from 0.3.3 to 0.3.4 in the cargo group ([#627](https://github.com/reubeno/brush/pull/627)) +- *(deps)* Bump the cargo group with 3 updates ([#612](https://github.com/reubeno/brush/pull/612)) +- *(deps)* Bump the cargo group with 2 updates ([#613](https://github.com/reubeno/brush/pull/613)) +- *(deps)* Bump the cargo group with 2 updates ([#619](https://github.com/reubeno/brush/pull/619)) +- *(deps)* Bump the cargo group across 1 directory with 5 updates ([#609](https://github.com/reubeno/brush/pull/609)) +- *(deps)* Bump tokio from 1.46.1 to 1.47.0 in the cargo group ([#616](https://github.com/reubeno/brush/pull/616)) +- *(deps)* Bump the cargo group with 2 updates ([#621](https://github.com/reubeno/brush/pull/621)) + + +## [0.2.20] - 2025-07-04 + +### 🚀 Features + +- *(api)* API usability improvements for `Shell::invoke_function` (#596) +- Enable -o/+o on brush command line (#590) + +### 🐛 Bug Fixes + +- *(dot)* Only shadow args when some provided to `source` (#582) + +### 🚜 Refactor + +- *(ShellValue)* Take in an `Into` (#598) + +### 📚 Documentation + +- README.md installation updates (#580) +- Update README.md badges (#588) + +### 🧪 Testing + +- Tag test binary dependencies (#585) +- Add test cases for open issues (#587) +- Add not-yet-passing history tests (#591) + +### ⚙️ Miscellaneous Tasks + +- Update dependencies + deny policy (#586) +- Remove unneeded dev deps from 'test-with' (#594) +- Update dependencies (#601) + +### Build + +- *(deps)* Bump test-with from 0.15.1 to 0.15.2 in the cargo group (#593) + + +## [0.2.19] - 2025-06-25 + +### 🚀 Features + +- *(AndOrList)* Add iteration abilities (#512) +- Generic Shell functions to parse a script from bytes (#509) +- Ability to clear all functions from shell environment (#546) +- *(vars)* Implement correct updating for -u/-c/-l vars (#529) +- *(env)* Introduce BRUSH_VERSION variable (#531) +- Enable cargo-binstall to work with brush (#536) +- *(parser)* Add gettext enabled quotes (#446) +- *(printf)* Replace printf impl with uucore wrapper (#552) +- *(args)* Add --rcfile command-line option (#568) + +### 🐛 Bug Fixes + +- *(vars)* Ensure effective GID is first in GROUPS (#526) +- *(traps)* Add stub definition for RETURN trap (#559) +- *(typeset)* Mark typeset as a declaration builtin (#517) +- *(tokenizer)* Correctly treat $( (...) ) as a cmd substitution (#521) +- *(backquote)* Correctly handle trailing backslash in backquoted command (#524) +- *(test)* Reuse -ef, -nt, -ot support (#525) +- *(arithmetic)* Permit space after unary operators (#527) +- *(tokenizer)* Correctly handle here docs terminated by EOF (#551) +- *(interactive)* Fix behavior of cmds piped to stdin (#539) +- *(functions)* Allow func names to contain slashes (#560) +- *(tokenizer)* Handle escaped single-quote in ANSI-C quoted string (#561) +- *(arithmetic)* Correct left shift handling (#562) +- *(local)* Enable use of local to detect function (#565) +- *(expansion)* Handle signed numbers in brace-expansion ranges (#566) +- *(redirection)* Assorted fixes to redirection (#567) +- *(prompt)* Implement `\A` (#569) +- *(expansion)* Correct ${!PARAM@...} (#570) +- *(expansion)* Fix parsing escaped single-quotes in ANSI-C strs (#571) +- *(for/case)* Allow reserved words in for word lists (#578) + +### 🚜 Refactor + +- *(parser)* Abstract parse errors (#574) + +### ⚡ Performance + +- Remove redundant lookups in path searching (#573) + +### 🧪 Testing + +- *(parser)* Enable serde::Serialize on AST et al. for test targets (#544) +- *(tokenizer)* Adopt insta for tokenizer tests (#550) +- *(parser)* Start using insta crate for snapshot-testing parser (#545) + +### ⚙️ Miscellaneous Tasks + +- Upgrade MSRV to 1.85.0 (#553) +- Upgrade crates to Rust 2024 edition (#554) +- Enable more lints + fixes (#555) +- Upgrade dependencies (#556) + +### Build + +- *(deps)* Bump pprof from 0.14.0 to 0.15.0 in the cargo group (#542) +- *(deps)* Bump the cargo group with 3 updates (#522) + + +## [0.2.18] - 2025-05-22 + +### 🚀 Features + +- *(apply_unary_predicate_to_str)* Implement `-R` operand +- *(apply_binary_predicate)* Implement `-ef` operand +- *(apply_binary_predicate)* Implement `-nt` operand +- *(ShellValue)* Add `is_set` method +- *(apply_binary_predicate)* Implement `-ot` operand +- *(Shell)* Add methods to add environmental variables and builtins ([#447](https://github.com/reubeno/brush/pull/447)) +- Implement {cd,pwd} -{L,P} ([#458](https://github.com/reubeno/brush/pull/458)) +- *(builtins)* Implement initial (partial) `bind -x` support ([#478](https://github.com/reubeno/brush/pull/478)) +- *(mapfile)* Register also as readarray ([#486](https://github.com/reubeno/brush/pull/486)) +- *(funcs)* Implement function exporting/importing ([#492](https://github.com/reubeno/brush/pull/492)) +- *(mapfile)* Implement `-n`, `-d`, `-s` ([#490](https://github.com/reubeno/brush/pull/490)) +- *(ulimit)* Implement ulimit builtin ([#482](https://github.com/reubeno/brush/pull/482)) + +### 🐛 Bug Fixes + +- *(break,continue)* Use `default_value_t` for flag values ([#493](https://github.com/reubeno/brush/pull/493)) +- *(help)* Better compact argument help ([#499](https://github.com/reubeno/brush/pull/499)) +- *(tests)* Wrong boolean operator +- *(trap)* Handle '-' unregistration syntax ([#452](https://github.com/reubeno/brush/pull/452)) +- *(complete)* Fixes + tests for "declare -r" ([#462](https://github.com/reubeno/brush/pull/462)) +- Enable unnameable_types lint and mitigate errors ([#459](https://github.com/reubeno/brush/pull/459)) +- *(builtin)* Fix issues with 'builtin' invoking declaration builtins ([#466](https://github.com/reubeno/brush/pull/466)) +- *(declare)* Suppress errors for non-existent functions in declare -f / -F ([#467](https://github.com/reubeno/brush/pull/467)) +- *(expansion)* Allow negative subscripts with indexed arrays + slices ([#468](https://github.com/reubeno/brush/pull/468)) +- Allow "bind" usage in .bashrc ([#485](https://github.com/reubeno/brush/pull/485)) +- *(expansion)* Fix issues parsing backquoted commands nested in single/double-quoted strings ([#491](https://github.com/reubeno/brush/pull/491)) +- Fix typos + add spell-checking PR check ([#501](https://github.com/reubeno/brush/pull/501)) + +### 📚 Documentation + +- Update readme ([#481](https://github.com/reubeno/brush/pull/481)) +- Add discord invite to readme ([#494](https://github.com/reubeno/brush/pull/494)) + +### 🧪 Testing + +- *(extended-tests)* Explicitly set modified date on test files ([#453](https://github.com/reubeno/brush/pull/453)) +- Add compat test for $_ ([#480](https://github.com/reubeno/brush/pull/480)) +- Add known-failing tests to reproduce reported issues ([#483](https://github.com/reubeno/brush/pull/483)) + +### ⚙️ Miscellaneous Tasks + +- Enable (and fix) current checks across all workspace crates ([#457](https://github.com/reubeno/brush/pull/457)) +- *(extended_tests)* Add `-ef`, `-nt`, and `-ot` +- Upgrade dependencies: reedline, nix, clap, thiserror, etc. ([#456](https://github.com/reubeno/brush/pull/456)) +- Better log + connect unimpl functionality with GH issues ([#476](https://github.com/reubeno/brush/pull/476)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#461](https://github.com/reubeno/brush/pull/461)) +- *(deps)* Bump the cargo group with 2 updates ([#487](https://github.com/reubeno/brush/pull/487)) + +### Ref + +- *(Shell)* Turn `&Path` parameters into `AsRef` ([#448](https://github.com/reubeno/brush/pull/448)) + + +## [0.2.17] - 2025-04-24 + +### 🚀 Features + +- *(Shell)* Add `get_env_var` method for more generic variable returns (#438) + +### 🐛 Bug Fixes + +- Honor COMP_WORDBREAKS in completion tokenization (#407) +- Handle complete builtin run without options (#435) + +### 📚 Documentation + +- Add instructions for installing from the AUR (#433) + +### 🧪 Testing + +- Implement --skip in brush-compat-tests harness (#432) + +### ⚙️ Miscellaneous Tasks + +- *(Shell)* Use relaxed typing for string input (#437) +- Enable building for wasm32-unknown-unknown (#425) + +### Build + +- *(deps)* Bump rand from 0.9.0 to 0.9.1 in the cargo group (#431) +- *(deps)* Bump the cargo group with 3 updates (#426) +- *(deps)* Bump the cargo group with 2 updates (#427) + + +## [0.2.16] - 2025-03-25 + +### 🚀 Features + +- *(arithmetic)* Support explicit base#literal in arithmetic (#388) +- Implement `command -p` (#402) + +### 🐛 Bug Fixes + +- Default PS1 and PS2 in interactive mode (#390) +- *(builtins)* Implement command-less exec semantics with open fds (#384) +- *(builtins)* Correct read handling of IFS/space (#385) +- *(extglob)* Correct handling of extglobs with empty branches (#386) +- Correct path tests on empty strings (#391) +- Allow newline in empty array assignment (#405) +- Improve panic handling output (#409) +- *(regex)* Enable multiline mode for extended test regexes (#416) +- Parse '#' as char only if inside a variable expansion (#418) + +### 📚 Documentation + +- Symlink licenses under crate dirs (#400) + +### 🧪 Testing + +- Add more linux distros to test matrix (#412) +- Enable testing on nixos/nix container image (#413) + +### ⚙️ Miscellaneous Tasks + +- Upgrade cached crate (#398) +- Rewrite dir diffing test code to avoid deps + +### Build + +- *(deps)* Bump the cargo group with 2 updates (#393) +- *(deps)* Bump whoami from 1.5.2 to 1.6.0 in the cargo group (#423) +- *(deps)* Bump the cargo group with 2 updates (#389) +- *(deps)* Bump serde from 1.0.218 to 1.0.219 in the cargo group (#401) +- *(deps)* Bump the cargo group with 2 updates (#410) + + +## [0.2.15] - 2025-02-03 + +### 🚀 Features + +- *(continue)* Implement continue n for n >= 2 ([#326](https://github.com/reubeno/brush/pull/326)) +- *(options)* Implement dotglob semantics ([#332](https://github.com/reubeno/brush/pull/332)) +- *(options)* Implement "set -t" ([#333](https://github.com/reubeno/brush/pull/333)) +- *(options)* Implement "set -a" ([#336](https://github.com/reubeno/brush/pull/336)) +- *(env)* Introduce dynamic variables ([#360](https://github.com/reubeno/brush/pull/360)) + +### 🐛 Bug Fixes + +- *(builtins)* Skip unenumerable vars in set builtin ([#322](https://github.com/reubeno/brush/pull/322)) +- *(expansion)* Handle negative substring offset ([#372](https://github.com/reubeno/brush/pull/372)) +- *(completion)* Better handle native errors in completion funcs ([#373](https://github.com/reubeno/brush/pull/373)) +- *(builtins)* Correct parsing of bind positional arg ([#381](https://github.com/reubeno/brush/pull/381)) +- *(patterns)* Fix incorrect parse of char ranges ([#323](https://github.com/reubeno/brush/pull/323)) +- *(exit)* Correct exit semantics in various compund statements ([#347](https://github.com/reubeno/brush/pull/347)) +- *(for)* Correct semantics for "for" without "in" ([#348](https://github.com/reubeno/brush/pull/348)) +- Correct semantics of = in non-extended test commands ([#349](https://github.com/reubeno/brush/pull/349)) +- *(return)* Error if return used outside sourced script or function ([#350](https://github.com/reubeno/brush/pull/350)) +- *(arithmetic)* Recursively evaluate var references ([#351](https://github.com/reubeno/brush/pull/351)) +- *(arithmetic)* Fixes for nested parenthesis parsing in arithmetic ([#353](https://github.com/reubeno/brush/pull/353)) +- *(builtins)* Fix set builtin handling of - and -- ([#354](https://github.com/reubeno/brush/pull/354)) +- *(builtins)* Do not interpret --help in command builtin command args ([#355](https://github.com/reubeno/brush/pull/355)) +- *(builtins)* Correct more 'set' argument parsing ([#356](https://github.com/reubeno/brush/pull/356)) +- *(variables)* More correct handling of integer variables ([#357](https://github.com/reubeno/brush/pull/357)) +- *(redirection)* Make sure redirection fd + operator are contiguous ([#359](https://github.com/reubeno/brush/pull/359)) +- Better error when cwd is gone ([#370](https://github.com/reubeno/brush/pull/370)) +- *(builtins)* Fix read builtin ignoring tab chars ([#371](https://github.com/reubeno/brush/pull/371)) +- Propagate execution parameters more thoroughly ([#374](https://github.com/reubeno/brush/pull/374)) +- *(redirection)* Allow continuing past redir errors ([#375](https://github.com/reubeno/brush/pull/375)) + +### ⚡ Performance + +- Remove unneeded string cloning for arithmetic eval ([#324](https://github.com/reubeno/brush/pull/324)) +- Simplify export enumeration ([#363](https://github.com/reubeno/brush/pull/363)) +- Skip word parsing if no expansion required ([#365](https://github.com/reubeno/brush/pull/365)) +- Minor optimizations for shell create + command run ([#362](https://github.com/reubeno/brush/pull/362)) + +### 🧪 Testing + +- *(perf)* Update tokenizer/parser benchmarks ([#321](https://github.com/reubeno/brush/pull/321)) +- Resolve false errors about side effects in bash-completion tests ([#379](https://github.com/reubeno/brush/pull/379)) + +### ⚙️ Miscellaneous Tasks + +- Remove some unneeded `pub(crate)` visibility annotations ([#346](https://github.com/reubeno/brush/pull/346)) +- Remove unneeded result wrappings ([#367](https://github.com/reubeno/brush/pull/367)) +- Remove a few object clones ([#368](https://github.com/reubeno/brush/pull/368)) +- Update readme ([#331](https://github.com/reubeno/brush/pull/331)) +- Add pattern and expansion tests to track newly filed issues ([#330](https://github.com/reubeno/brush/pull/330)) +- Minor cleanups and test additions ([#364](https://github.com/reubeno/brush/pull/364)) +- Fix rng warnings ([#378](https://github.com/reubeno/brush/pull/378)) + +### Build + +- *(deps)* Bump indexmap from 2.7.0 to 2.7.1 in the cargo group ([#334](https://github.com/reubeno/brush/pull/334)) +- *(deps)* Bump the cargo group across 1 directory with 4 updates ([#320](https://github.com/reubeno/brush/pull/320)) +- *(deps)* Bump the cargo group with 3 updates ([#376](https://github.com/reubeno/brush/pull/376)) + + +## [0.2.14] - 2025-01-10 + +### 🚀 Features + +- *(prompts)* Enable PS0, custom right-side prompts, more ([#278](https://github.com/reubeno/brush/pull/278)) +- *(completion)* Programmable completion support for filters + commands +- *(non-posix)* Implement `time` keyword ([#310](https://github.com/reubeno/brush/pull/310)) +- *(builtins)* Implement suspend ([#311](https://github.com/reubeno/brush/pull/311)) +- *(set)* Implement nullglob option ([#279](https://github.com/reubeno/brush/pull/279)) +- *(set)* Implement nocaseglob + nocasematch options ([#282](https://github.com/reubeno/brush/pull/282)) +- *(builtins)* Add minimal mapfile + bind impls +- *(debug)* Improved function tracing capabilities +- *(options)* Implement lastpipe option +- Implement brace expansion ([#290](https://github.com/reubeno/brush/pull/290)) +- *(options)* Implement noclobber option (a.k.a. -C) ([#291](https://github.com/reubeno/brush/pull/291)) +- *(builtins)* Implement more of kill builtin ([#305](https://github.com/reubeno/brush/pull/305)) +- *(builtins)* Implement times builtin ([#309](https://github.com/reubeno/brush/pull/309)) + +### 🐛 Bug Fixes + +- Correct sh mode vs posix mode confusion for syntax extensions +- Assorted non-fatal clippy warnings ([#274](https://github.com/reubeno/brush/pull/274)) +- *(builtins)* Correct behavior of set builtin with no args +- More consistently honor shell options when invoking the tokenizer +- Update COMP_WORDBREAKS default value +- Honor extglob for expansion transformations +- Sync PWD with actual workdir on launch +- *(jobs)* Only report job status when job control option is enabled ([#306](https://github.com/reubeno/brush/pull/306)) +- Stop incorrectly parsing assignment as function def ([#273](https://github.com/reubeno/brush/pull/273)) +- Multiple issues blocking docker cmd completion ([#275](https://github.com/reubeno/brush/pull/275)) +- Improve substring ops with multi-byte chars ([#280](https://github.com/reubeno/brush/pull/280)) +- Better handle escape chars in pattern bracket exprs ([#281](https://github.com/reubeno/brush/pull/281)) +- *(completion)* Multiple fixes for compgen builtin usage +- *(regex)* Work around incompatibilities between shell + rust regexes +- *(extendedtests)* Add missing arithmetic eval in extended tests +- *(command)* Handle sending basic command errors to redirected stderr +- Improve accuracy of negative extglobs +- Implement date and time in prompts ([#298](https://github.com/reubeno/brush/pull/298)) +- *(completion)* Handle -o {default,dirnames,plusdirs} ([#300](https://github.com/reubeno/brush/pull/300)) +- *(expansion)* Correct length for 1-element arrays ([#316](https://github.com/reubeno/brush/pull/316)) +- Correct issues with `!` extglobs and compgen -X ([#317](https://github.com/reubeno/brush/pull/317)) + +### 📚 Documentation + +- Update README to reflect test expansion + +### ⚡ Performance + +- Cache parsing for arithmetic expressions ([#301](https://github.com/reubeno/brush/pull/301)) +- Remove unneeded async from arithmetic eval ([#312](https://github.com/reubeno/brush/pull/312)) +- Remove setup operations from microbenchmarks ([#307](https://github.com/reubeno/brush/pull/307)) +- Reimplement colon command as a "simple builtin" ([#315](https://github.com/reubeno/brush/pull/315)) + +### 🧪 Testing + +- *(completion)* Add another completion test +- *(completion)* Enable use of pexpect et al. with basic input backend + +### ⚙️ Miscellaneous Tasks + +- Update comments +- Improve tracing for completion function invocation +- Remove unneeded helper code +- Address warnings ([#313](https://github.com/reubeno/brush/pull/313)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#285](https://github.com/reubeno/brush/pull/285)) +- *(deps)* Bump the cargo group with 4 updates ([#289](https://github.com/reubeno/brush/pull/289)) +- *(deps)* Bump the cargo group with 3 updates ([#294](https://github.com/reubeno/brush/pull/294)) +- *(deps)* Bump anyhow from 1.0.94 to 1.0.95 in the cargo group ([#297](https://github.com/reubeno/brush/pull/297)) +- *(deps)* Bump the cargo group with 2 updates ([#299](https://github.com/reubeno/brush/pull/299)) +- *(deps)* Bump the cargo group with 2 updates ([#304](https://github.com/reubeno/brush/pull/304)) + + +## [0.2.13] - 2024-11-26 + +### 🚀 Features + +- *(ast)* Derive `PartialEq` and `Eq` for testing ([#259](https://github.com/reubeno/brush/pull/259)) + +### 🐛 Bug Fixes + +- Correct parsing of parens in arithmetic command ([#270](https://github.com/reubeno/brush/pull/270)) + +### ⚙️ Miscellaneous Tasks + +- Upgrade dependencies ([#271](https://github.com/reubeno/brush/pull/271)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#258](https://github.com/reubeno/brush/pull/258)) +- *(deps)* Bump the cargo group with 7 updates ([#267](https://github.com/reubeno/brush/pull/267)) + + +## [0.2.12] - 2024-11-03 + +### 🚀 Features + +- Implement support for ;;& and ;& in case items ([#223](https://github.com/reubeno/brush/pull/223)) +- Implement `|&` extension ([#240](https://github.com/reubeno/brush/pull/240)) +- Implement `kill -l` ([#221](https://github.com/reubeno/brush/pull/221)) +- Implement `|&` for function declarations ([#244](https://github.com/reubeno/brush/pull/244)) + +### 🐛 Bug Fixes + +- Omit dirs from executable searches ([#236](https://github.com/reubeno/brush/pull/236)) +- Handle PS2 prompts that require prompt-expansion ([#239](https://github.com/reubeno/brush/pull/239)) +- Allow usually-operator chars in regex parens ([#224](https://github.com/reubeno/brush/pull/224)) +- Assorted correctness issues in getopts builtin ([#225](https://github.com/reubeno/brush/pull/225)) +- Assorted completion-related issues ([#226](https://github.com/reubeno/brush/pull/226)) +- String replacement with slashes ([#231](https://github.com/reubeno/brush/pull/231)) +- Correct pattern removal expansions on arrays ([#232](https://github.com/reubeno/brush/pull/232)) +- *(completion)* Fix -- handling in getopts ([#235](https://github.com/reubeno/brush/pull/235)) +- *(completion)* Correct behavior of slice past end of array ([#237](https://github.com/reubeno/brush/pull/237)) +- Support here documents in command substitutions ([#255](https://github.com/reubeno/brush/pull/255)) + +### 🧪 Testing + +- Run completion tests using bash-completion 2.14.0 ([#238](https://github.com/reubeno/brush/pull/238)) +- Add os-targeted integration tests ([#241](https://github.com/reubeno/brush/pull/241)) + +### ⚙️ Miscellaneous Tasks + +- Upgrade crate dependencies ([#247](https://github.com/reubeno/brush/pull/247)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#220](https://github.com/reubeno/brush/pull/220)) + + +## [0.2.11] - 2024-10-18 + +### 🚀 Features + +- Experimentally enable reedline as an input backend ([#186](https://github.com/reubeno/brush/pull/186)) +- Default to reedline and add syntax highlighting support ([#187](https://github.com/reubeno/brush/pull/187)) +- Add a panic handler via human-panic ([#191](https://github.com/reubeno/brush/pull/191)) +- Several fixes for bash-completion + tests ([#192](https://github.com/reubeno/brush/pull/192)) +- Implement `cd -` ([#201](https://github.com/reubeno/brush/pull/201)) +- Implement command hashing ([#206](https://github.com/reubeno/brush/pull/206)) + +### 🐛 Bug Fixes + +- Deduplicate completion candidates ([#189](https://github.com/reubeno/brush/pull/189)) +- Cleanup transient completion variables ([#213](https://github.com/reubeno/brush/pull/213)) +- Allow newlines in extended test exprs ([#188](https://github.com/reubeno/brush/pull/188)) +- Fixes for short-circuit precedence + parameter expr replacement ([#193](https://github.com/reubeno/brush/pull/193)) +- Workarounds for edge word parsing cases ([#194](https://github.com/reubeno/brush/pull/194)) +- Assorted completion issues with ~ and vars ([#199](https://github.com/reubeno/brush/pull/199)) +- Slight compat improvements to set -x ([#205](https://github.com/reubeno/brush/pull/205)) +- Matching newline chars in glob patterns ([#207](https://github.com/reubeno/brush/pull/207)) +- Honor IFS in read builtin ([#208](https://github.com/reubeno/brush/pull/208)) +- Correct behavior of break in arithmetic for loop ([#210](https://github.com/reubeno/brush/pull/210)) +- Address issues with array unset ([#211](https://github.com/reubeno/brush/pull/211)) +- Handle expansion in here documents ([#212](https://github.com/reubeno/brush/pull/212)) + +### 📚 Documentation + +- Update readme ([#182](https://github.com/reubeno/brush/pull/182)) +- Update readme with new links ([#204](https://github.com/reubeno/brush/pull/204)) + +### 🧪 Testing + +- Enable setting min oracle version on tests ([#184](https://github.com/reubeno/brush/pull/184)) + +### ⚙️ Miscellaneous Tasks + +- Where possible replace `async-trait` with native async trait support in 1.75+ ([#197](https://github.com/reubeno/brush/pull/197)) + +### Build + +- *(deps)* Bump futures from 0.3.30 to 0.3.31 in the cargo group ([#190](https://github.com/reubeno/brush/pull/190)) +- Leave rustyline disabled by default ([#196](https://github.com/reubeno/brush/pull/196)) +- *(deps)* Bump the cargo group with 4 updates ([#203](https://github.com/reubeno/brush/pull/203)) +- Remove rustyline support ([#216](https://github.com/reubeno/brush/pull/216)) + + +## [0.2.10] - 2024-09-30 + +### 🐛 Bug Fixes + +- Allow source to be used with process substitution ([#175](https://github.com/reubeno/brush/pull/175)) +- Address multiple issues with foreground controls for pipeline commands ([#180](https://github.com/reubeno/brush/pull/180)) + +### 🧪 Testing + +- Move to cargo nextest ([#176](https://github.com/reubeno/brush/pull/176)) +- Correctly report skipped tests for nextest ([#178](https://github.com/reubeno/brush/pull/178)) +- Convert more test skips to known failures ([#179](https://github.com/reubeno/brush/pull/179)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#177](https://github.com/reubeno/brush/pull/177)) + + +## [0.2.9] - 2024-09-26 + +### 🚀 Features + +- Launch processes in their own process groups ([#166](https://github.com/reubeno/brush/pull/166)) + +### 🐛 Bug Fixes + +- Posix compliant argument parsing for `-c` mode ([#147](https://github.com/reubeno/brush/pull/147)) + +### 🧪 Testing + +- Add more basic interactive tests ([#168](https://github.com/reubeno/brush/pull/168)) +- Bring up macos testing ([#172](https://github.com/reubeno/brush/pull/172)) + +### Build + +- *(deps)* Bump thiserror from 1.0.63 to 1.0.64 in the cargo group ([#167](https://github.com/reubeno/brush/pull/167)) +- Temporarily disable failing test ([#170](https://github.com/reubeno/brush/pull/170)) +- Refactor PR workflow to better support multi-platform build + test ([#169](https://github.com/reubeno/brush/pull/169)) + + +## [0.2.8] - 2024-09-17 + +### 🐛 Bug Fixes + +- Implement ~USER syntax ([#160](https://github.com/reubeno/brush/pull/160)) +- Compgen needs to expand target arg ([#162](https://github.com/reubeno/brush/pull/162)) +- Do not invoke debug traps during completion funcs ([#163](https://github.com/reubeno/brush/pull/163)) +- Disable flaky test until it can be root-caused + +### 📚 Documentation + +- Generate man page via xtask ([#157](https://github.com/reubeno/brush/pull/157)) + +### ⚡ Performance + +- Short-term optimization for common-case printf + +### ⚙️ Miscellaneous Tasks + +- Extract InteractiveShell as trait + refactor ([#159](https://github.com/reubeno/brush/pull/159)) + +### Build + +- *(deps)* Bump tokio from 1.39.3 to 1.40.0 in the cargo group ([#156](https://github.com/reubeno/brush/pull/156)) +- *(deps)* Bump the cargo group with 6 updates ([#158](https://github.com/reubeno/brush/pull/158)) +- *(deps)* Bump the cargo group with 2 updates ([#161](https://github.com/reubeno/brush/pull/161)) + + +## [0.2.7] - 2024-09-01 + +### 🚀 Features + +- Move MSRV up to 1.75.0 ([#139](https://github.com/reubeno/brush/pull/139)) + +### 🐛 Bug Fixes + +- Correct echo -e escape expansion for \x sequences ([#143](https://github.com/reubeno/brush/pull/143)) +- Disable displaying tracing target ([#140](https://github.com/reubeno/brush/pull/140)) +- Correct multiple issues with process substitution + redirection ([#145](https://github.com/reubeno/brush/pull/145)) + +### Build + +- *(deps)* Bump tokio from 1.39.1 to 1.39.2 in the cargo group ([#141](https://github.com/reubeno/brush/pull/141)) +- *(deps)* Bump the cargo group with 3 updates ([#148](https://github.com/reubeno/brush/pull/148)) +- *(deps)* Bump serde from 1.0.204 to 1.0.206 in the cargo group ([#150](https://github.com/reubeno/brush/pull/150)) +- *(deps)* Bump the cargo group with 2 updates ([#152](https://github.com/reubeno/brush/pull/152)) +- *(deps)* Bump serde from 1.0.208 to 1.0.209 in the cargo group ([#154](https://github.com/reubeno/brush/pull/154)) + + +## [0.2.6] - 2024-07-23 + +### 🐛 Bug Fixes + +- Correct relative path resolution cases +- Relative path completion fixes ([#137](https://github.com/reubeno/brush/pull/137)) + + +## [0.2.5] - 2024-07-23 + +### 🐛 Bug Fixes + +- Build error outside git ([#134](https://github.com/reubeno/brush/pull/134)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#133](https://github.com/reubeno/brush/pull/133)) + + +## [0.2.4] - 2024-07-19 + +### 🚀 Features + +- Initial support for non-linux +- Enable simpler builtins implemented outside brush ([#130](https://github.com/reubeno/brush/pull/130)) +- Get building on windows and wasm-wasip1 targets ([#116](https://github.com/reubeno/brush/pull/116)) +- Add brushctl builtin, seed with event toggling support + +### 🐛 Bug Fixes + +- Absorb breaking change in homedir crate +- Clippy and check warnings ([#123](https://github.com/reubeno/brush/pull/123)) +- Correct completion fallback logic when spec matches but 0 results ([#125](https://github.com/reubeno/brush/pull/125)) +- Various build warnings on windows build ([#126](https://github.com/reubeno/brush/pull/126)) +- Exclude tags from git version info ([#115](https://github.com/reubeno/brush/pull/115)) + +### 📚 Documentation + +- Update readme ([#127](https://github.com/reubeno/brush/pull/127)) + +### ⚙️ Miscellaneous Tasks + +- Merge builtin and builtins modules +- Update comments ([#129](https://github.com/reubeno/brush/pull/129)) + +### Build + +- *(deps)* Bump the cargo group across 1 directory with 5 updates + + +## [0.2.3] - 2024-07-03 + +### 🚀 Features + +- Enable -O and +O on command line (#105) +- Start using cargo-fuzz for testing (#106) +- Enable fuzz-testing arithmetic eval (#108) +- Include more details in version info (#112) + +### 🐛 Bug Fixes + +- Correct expansion when PWD is / (#96) +- Ensure parser error actually impls Error (#98) +- Realign newline parsing with spec (#99) +- Correct handling of unterminated expansions (#101) +- Add &>> implementation (#103) +- Correct metadata for fuzz crate (#107) +- Resolve assorted arithmetic eval issues (#110) +- Correct ** overflow behavior (#111) + +### ⚙️ Miscellaneous Tasks + +- Update Cargo.lock (#113) +- Release + +### Build + +- Take targeted dependency updates (#93) +- Update config (#97) + +## [0.2.2] - 2024-06-19 + +### 🚀 Features + +- Implement 'command' builtin (#77) +- Add stubs for help man page generation +- Fill out read builtin impl +- Rework here doc files (#85) +- Set + validate intentional MSRV (1.72.0) (#86) +- Add basic changelog +- Add basic changelog (#87) + +### 🐛 Bug Fixes + +- Compgen -W expansion (#78) +- Don't split completions that aren't file paths (#79) +- Allow interrupting read builtin, run pipeline cmds in subshell (#81) +- Add missing flush calls +- Tweak manifests to work with release flow (#89) +- Ensure brush-core builds outside workspace (#90) + +### 📚 Documentation + +- Add crate shields to readme (#74) +- Add missing code documentation + +### ⚙️ Miscellaneous Tasks + +- *(release)* Bump version to 0.2.0 (#88) + +### Build + +- Update dependencies +- Adjust clippy warnings + +## [0.1.0] - 2024-06-11 + +### Build + +- Prepare for initial release (#68) +- Enable publishing (#71) + + diff --git a/userland/brush/source/CODE_OF_CONDUCT.md b/userland/brush/source/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4502527 --- /dev/null +++ b/userland/brush/source/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ + +# Contributor Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project administrators. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/userland/brush/source/CONTRIBUTING.md b/userland/brush/source/CONTRIBUTING.md new file mode 100644 index 0000000..5aeeae1 --- /dev/null +++ b/userland/brush/source/CONTRIBUTING.md @@ -0,0 +1,230 @@ + +# Contributing to brush + +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 + +> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: +> - Star the project +> - Send us feedback via issues or discussion +> - Refer this project in your project's readme +> - Post about it +> - Tell your friends/colleagues + + +## Table of Contents +- [Code of Conduct](#code-of-conduct) +- [I Have a Question](#i-have-a-question) + - [I Want To Contribute](#i-want-to-contribute) + - [Independent Implementation and Provenance](#independent-implementation-and-provenance) + - [AI Policy](#ai-policy) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) + - [Development Setup and Checks](#development-setup-and-checks) + - [Improving The Documentation](#improving-the-documentation) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) + +## Code of Conduct + +This project and everyone participating in it is governed by the +[brush Code of Conduct](CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. Please report unacceptable behavior +to project admins. + +## I Have a Question + +> If you want to ask a question, we assume that you have read the available [Documentation](docs). + +Before you ask a question, it is best to search for existing [Issues](https://github.com/reubeno/brush/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an [Issue](https://github.com/reubeno/brush/issues/new). +- Provide as much context as you can about what you're running into. +- Provide project and platform versions, depending on what seems relevant. + +We will then take care of the issue as soon as possible. + +## I Want To Contribute + +### Legal Notice + +> [!NOTE] +> By contributing, you confirm that you have the necessary rights to submit your +> contribution under the project's [MIT license](LICENSE). Identify any +> third-party material and its license, and preserve required notices. Using AI +> tools does not remove these obligations. + +### Independent Implementation and Provenance + +Brush is independently implemented. Matching Bash's observable behavior is a +project goal; deriving brush's implementation from Bash's implementation is not +an acceptable way to achieve it. + +#### Implementation boundaries + +- **Do not copy, translate, port, or adapt Bash implementation material into + brush.** This includes code, comments, implementation-specific tables, and + upstream tests from its code base. Changing languages, identifiers, + formatting, or structure, removing notices, or adding attribution does not + establish independent provenance or grant permission to use the material + under brush's license. +- **Do not inspect or retrieve Bash source implementation material for + implementation guidance.** This includes mirrors, patches, source excerpts in + issues or articles, and source packages installed locally. Do not ask another + person or agent to inspect it on your behalf. +- **Do not route that material through an intermediary.** AI-generated + translations, pseudocode, and implementation summaries derived from + Bash source are not independent inputs to a brush implementation. +- **Apply the same licensing discipline to other third-party material.** + Identify the source, license, and required notices before including it. If + the terms do not permit the proposed inclusion, or the provenance is unclear, + stop and ask maintainers rather than attempting to rewrite around the terms. + +#### Behavioral compatibility work + +Use documented interfaces, specifications, and independently written behavioral +reproductions. It's useful to run an installed Bash executable as a black-box +oracle; we've implemented a testing framework that simplifies passing original +input scripts through both Bash and brush and compare stdout, stderr, exit +status, filesystem effects, etc. We strongly encourage adding original compat +cases under `brush-shell/tests/cases/`. + +Our build and CI processes clone and run third-party test suites without +importing their sources into this repository. Use them as external tests, +not as source material or implementation guidance for brush. + +### AI Policy + +> [!NOTE] +> AI assistants have arrived. With them come many questions and much debate about policy, rights, appropriate usage, etc. Our evolving experiences show that they can be wielded as powerful tools, but we acknowledge that it is critical that they be used *responsibly* and thoughtfully. We set out the following principles for our project. + +* **Human supervision and review remain critical.** You, the human authors, are the contributors to this project. Regardless of which tools you use along the way, you are all fully accountable for your contributions. This means that you are still accountable for ensuring that you have the necessary rights to the content you submit to this project. It also remains your responsibility to review, test, understand, and vouch for all code and content that you submit. Accordingly, you must fully understand all code and other content in your contributions and be able to explain their behavior and purpose. + +* **Please be transparent about your use of AI.** When contributions have been significantly assisted by AI tools, note this in your PR description or commit message via an `Assisted-by:` trailer. + +* **Our policies and practices will evolve.** Things are moving quickly; we consider it far better to adapt as we go, learn, and iterate rather than to remain static. We remain open to your input and feedback on this policy as it evolves, and commit to engaged, respectful discussion regarding it. + +#### References + +We take inspiration (and direct language) from the emerging Fedora proposal for [policy regarding AI-assisted contributions](https://communityblog.fedoraproject.org/council-policy-proposal-policy-on-ai-assisted-contributions/) as well as from the Apache Software Foundation's [Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html). + +### Reporting Bugs + + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](docs). If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/reubeno/brush/issues?q=label%3Abug). +- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. +- Collect information about the bug: + - Stack trace (Traceback) + - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) + - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. + - Possibly your input and the output + - Can you reliably reproduce the issue? And can you also reproduce it with older versions? + + +#### How Do I Submit a Good Bug Report? + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/reubeno/brush/issues/new). +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). + + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for brush, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + + +#### Before Submitting an Enhancement + +- Make sure that you are using the latest version. +- Read the [documentation](docs) carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/reubeno/brush/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. + + +#### How Do I Submit a Good Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub issues](https://github.com/reubeno/brush/issues). + +- Use a **clear and descriptive title** for the issue to identify the suggestion. +- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. +- **Explain why this enhancement would be useful** to most brush users. You may also want to point out the other projects that solved it better and which could serve as inspiration. + +### Your First Code Contribution +We're excited to see your first contribution! We don't bite, so we'd much rather you publish something than not. If you submit your PR as a draft we're happy to give it a quick scan and provide some early input. Once the PR is marked "ready for review" we'll look through it more carefully. + +### Development Setup and Checks +Building brush needs nothing but [rustup](https://rustup.rs/). `rust-toolchain.toml` +pins the exact toolchain, along with the `rustfmt` and `clippy` components, and +rustup installs it automatically the first time you run `cargo` in the repository, +so a fresh clone is ready to go after `cargo build`. The pin keeps formatting and +lint results identical between your machine and CI; Dependabot bumps it as new +Rust releases ship. + +All development tasks run through `cargo xtask`, which is the single entry point +and is what CI invokes too: + +| Command | When | Needs | +|---|---|---| +| `cargo xtask ci quick` | Inner loop (~7s warm): fmt, crate structure, build, clippy, unit tests | Rust toolchain only | +| `cargo xtask ci full` | Before opening a PR (~60s warm): adds the pre-commit hooks, schemas, integration tests | also `prek` | +| `cargo xtask check ` | One check at a time; `cargo xtask check --help` lists them | varies | + +`cargo xtask ci full` also runs the third-party linters -- spelling (typos), +GitHub Actions analysis (zizmor), link checking (lychee), dependency auditing +(cargo-deny), structural lints (ast-grep), `release-plz.toml` schema validation +(check-jsonschema), and assorted file hygiene. Those are defined and version-pinned in +`.pre-commit-config.yaml` and executed by [prek](https://prek.j178.dev): + +```bash +cargo binstall prek # or: uv tool install prek / brew install prek +``` + +If you would rather not install it, `cargo xtask ci full --no-hooks` skips that +portion; CI will still run it. + +`cargo xtask ci full` reproduces every check CI performs that does not need a +different machine or toolchain. CI additionally covers the three-OS matrix, the +MSRV toolchain, cross-compilation, coverage, benchmarks, the bash-completion +suite, the nightly-only checks, and CodeQL -- so a green local run is a strong +signal, not a guarantee. + +#### Git hooks (optional) +Git hooks are entirely optional; the commands above are the supported path. If +you want them: + +```bash +prek install +``` + +That wires up formatting and hygiene fixes on commit, Conventional Commits +validation on the commit message, and `cargo xtask ci quick` on push -- the same +command you would otherwise run by hand. + +### Improving The Documentation +We appreciate documentation updates as much as code updates! We try to follow the [Diátaxis](https://diataxis.fr/) approach to technical docs. The short version is we recognize the need for tutorials, how-to guides, reference material, and sometimes longer-form explanation docs--*and* we recognize the value of being intentional about organizing by those categories. +## Styleguides +### Commit Messages +When you look through our repo history, you'll see commits prefixed with a tag like `feat:` or `fix:`. We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to maintain consistent readability of commits, both for humans and mechanical release processes. + +## Attribution +This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! diff --git a/userland/brush/source/LICENSE b/userland/brush/source/LICENSE new file mode 100644 index 0000000..3d3cb78 --- /dev/null +++ b/userland/brush/source/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 reuben olinsky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/userland/brush/source/README.md b/userland/brush/source/README.md new file mode 100644 index 0000000..90f9975 --- /dev/null +++ b/userland/brush/source/README.md @@ -0,0 +1,163 @@ +
+ + + brush + +
+ +

Bash-compatible. Embeddable. Extensible.

+ +

+ crates.io version + crates.io downloads + CI status + 2,500+ compatibility tests + MIT license + Discord +

+ +

+ Website · + Install · + Compatibility · + Release notes · + Discord +

+ +
+ +[brush](https://brush.sh) is a Bash-compatible shell written in Rust. + +***Run it as your everyday shell.*** It loads your existing `.bashrc`, aliases, functions, and completions, runs the scripts you already have, and adds some modern shell amenities: history-based suggestions and live syntax highlighting. + +***Build with it.*** [`brush-core`](https://docs.rs/brush-core) is a tested implementation of Bash semantics that your own Rust software can embed and extend. [`brush-parser`](https://docs.rs/brush-parser), which turns shell source into a syntax tree, is used on its own by other projects, including [Zed](https://github.com/zed-industries/zed), [Vite+](https://github.com/voidzero-dev/vite-plus), and [oh-my-pi](https://github.com/can1357/oh-my-pi), to parse shell commands. + +

+ brush in action: bash-completion, job control, and shell functions +

+ +## Why brush + +Bash is the shell most of us already know. It's in our fingers, our scripts, and our team's runbooks. brush keeps that behavior as it is, and adds suggestions, highlighting, and the other modern conveniences for those who want them. Keeping that promise means being faithful, so every change is tested against Bash itself. + +The brush shell and its libraries are the same code. Embedding `brush-core` gives your software the behavior the shell has, and an improvement to one is an improvement to the other. brush is written in Rust with extension points designed in: custom builtins already plug in alongside the standard ones, and we intend to open more of the shell's internals the same way, so that tools can observe and extend it natively. + +## Get started + +Install the latest release on Linux or macOS: + +```sh +curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh +``` + +Or with Homebrew or cargo: + +```sh +brew install brush # Homebrew, on macOS or Linux +cargo install --locked brush-shell # build from crates.io +cargo binstall brush-shell # prebuilt, via cargo-binstall +``` + +Packagers have also brought brush to [Homebrew](https://formulae.brew.sh/formula/brush), [Arch Linux](https://archlinux.org/packages/extra/x86_64/brush/), [Fedora via Terra](https://terrapkg.com/), [MSYS2](https://packages.msys2.org/base/mingw-w64-brush), [Nix](https://search.nixos.org/packages?channel=unstable&query=brush), and [more](https://repology.org/project/brush/versions). See [all install options](https://brush.sh/getting-started/install/) for details. + +Then run `brush`. It reads the same startup files Bash does, so it picks up your Bash setup as it is. To give brush a look of its own, add a `~/.brushrc`. + +## Use the shell + +- **Your configuration comes with you.** `.bashrc`, `.bash_profile`, aliases, functions, `PS1`, `PROMPT_COMMAND`, and prompt tools like [starship](https://starship.rs) all work as they do in Bash. +- **Programmable completion.** Works with the [bash-completion](https://github.com/scop/bash-completion) package you already have installed, so `git`, `docker`, `systemctl`, and the rest complete as usual. +- **Job control.** Background jobs, suspend and resume, `fg`, `bg`, and `jobs`. +- **Auto-suggestions.** History-based hints as you type, on by default. Powered by [reedline](https://github.com/nushell/reedline). +- **Syntax highlighting.** Live, as you type, one setting away: `brush --enable-highlighting` or `syntax-highlighting = true` under `[ui]` in brush's [TOML config file](https://brush.sh/reference/config-files/). +- **Scripts, too.** The builtins, expansions, arrays, redirections, and options your scripts already use, with `set -e`, `pipefail`, `extglob`, `globstar`, and friends. +- **Experimental extras.** zsh-style `precmd` and `preexec` hooks, and terminal shell integration for VS Code, iTerm2, and other supporting terminals. Both are off by default; see [experimental features](https://brush.sh/reference/experimental/). + +> Not everything is there yet. Most notably, `select`, `wait -n`, `disown`, some traps, and a set of edge cases are still missing. The [compatibility reference](https://brush.sh/reference/compatibility/) lists what works, what's partial, and what isn't implemented. If you spot something that doesn't look right, please let us know by filing an issue. + +## Build with the engine + +The same implementation that runs the shell is available as a set of crates. Create a shell, run Bash-compatible code in it, and inspect the result: + +```rust +let mut shell = brush_core::Shell::builder().build().await?; + +let result = shell + .run_string( + r#"greet() { echo "Hello, $1!"; }; greet world"#, + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await?; + +assert!(result.is_success()); +``` + +For more, see the examples: [register a builtin written in Rust](brush-clap-adapter/examples/custom-builtin.rs) alongside the standard ones, [call a shell function from Rust](brush-core/examples/call-func.rs) with control over its I/O, or [parse a script and serialize its syntax tree](brush-parser/examples/serde.rs). The [examples guide](docs/reference/examples.md) describes each one and when it's useful. + +| Crate | API docs | What it provides | +|---|---|---| +| [`brush-core`](brush-core) | [docs.rs](https://docs.rs/brush-core) | The shell runtime: expansion, execution, jobs, completion, and the `Shell` API. Start here to embed. | +| [`brush-parser`](brush-parser) | [docs.rs](https://docs.rs/brush-parser) | Tokenizer and parser for Bash and POSIX shell syntax, with an optional `serde` AST. Usable independently from the other crates. | +| [`brush-builtins`](brush-builtins) | [docs.rs](https://docs.rs/brush-builtins) | The standard builtins, usable as a set. Depends on `brush-core`. | +| [`brush-interactive`](brush-interactive) | [docs.rs](https://docs.rs/brush-interactive) | Line editing, highlighting, suggestions, and completion UI, built on [reedline](https://github.com/nushell/reedline). Depends on `brush-core`. | +| [`brush-shell`](brush-shell) | [docs.rs](https://docs.rs/brush-shell) | The `brush` binary and its command line. Depends on everything else. | + +Optional crates add bundled [coreutils builtins](brush-coreutils-builtins) and [experimental builtins](brush-experimental-builtins). + +## How we test it + +- **Compatibility suite.** More than 2,500 [test cases](brush-shell/tests/cases) run the same script under brush and Bash and compare stdout, stderr, exit status, and filesystem side effects. Every pull request runs them on Linux (x86_64 and aarch64) and macOS, and inside Arch Linux, Debian, Fedora, NixOS, openSUSE, and Azure Linux containers. +- **Real tools, real tests.** [End-to-end suites](e2e) exercise brush with the tools people pair with a shell: [fzf](https://github.com/junegunn/fzf), [atuin](https://github.com/atuinsh/atuin), [starship](https://github.com/starship/starship), [zoxide](https://github.com/ajeetdsouza/zoxide), [mise](https://github.com/jdx/mise), and others. Where a project has its own shell-integration tests, those run against brush; where it doesn't, we've added some. +- **Everything else.** CodeQL, dependency auditing, and benchmarks on every pull request, plus fuzz targets for the parser and the highlighter. +- **Verifiable releases.** Binaries are built by the release workflow with signed build provenance. The install script checks each download's SHA-256 checksum and, when the GitHub CLI is available, its attestation. + +## Platforms + +| Tier | Platforms | What that means | +|---|---|---| +| **Supported** | _Linux_: x86_64, aarch64 (glibc, musl)
macOS: aarch64 | Prebuilt binaries for every release, the full test suite on Linux x86_64 and aarch64 (glibc) and macOS aarch64, build checks for Linux musl and macOS x86_64, and daily-driver quality. | +| **Preview** | _Windows_: x86_64, aarch64 | Windows receives build and static checks in CI along with a small brush-specific test suite; prebuilt binaries are on the way. Parts of the shell are still missing or limited but it's functional for basic usage, particularly when paired with Microsoft's build of [coreutils for Windows](https://github.com/microsoft/coreutils). | +| **Experimental** | _WASI 0.3_ | WASI builds run under wasmtime in CI. There are *many* significant gaps; more of a starting point for further experimentation. | +| **Builds only** | _Android_, _FreeBSD_, _NetBSD_, _OpenBSD_ | Cross-compiled in CI so they keep compiling. No official tests, binaries, or validation. | + +## Community and contributing + + + + + + +brush started as a curiosity-driven project, and that curiosity is still what drives it. Most contributors arrived the same way: tried brush, hit something unexpected, and helped fix it. However much time you have, there's a way in. + +- **Try it and tell us what you find.** A script or command that behaves differently in Bash is the most useful report we get. [File a compatibility bug](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml), or a [feature request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) if brush could do more for you. +- **Say hello on [Discord](https://discord.gg/kPRgC9j3Tj)**, whether you have a question, an idea, or a shell setup you'd like to see work. Or just come to chat and hang out. +- **Pick up an issue.** Issues labeled ["good first issue"](https://github.com/reubeno/brush/labels/good%20first%20issue) are intended for newcomers, and ["help wanted"](https://github.com/reubeno/brush/labels/help%20wanted) tag additional items where an extra pair of hands would help. Draft pull requests are welcome; if you ask us, we'll take an early look before you polish. +- **Read the [contribution guidelines](CONTRIBUTING.md)** for the workflow, and the [technical docs](docs/README.md) for how brush is built and tested. Everyone here is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). + +A star, a mention in your own project's README, or a post about brush all help too. + +Curious how brush relates to other shells? See [related projects](docs/reference/related-projects.md). + +### Contributors + +brush is shaped by everyone who has given it their time: bug reports with a reproducer, reviews that caught what we missed, packages for distributions we'd never touched, and a good deal of patient chat. Thank you, all of you. It's appreciated more than a line in a README can say. + +brush contributors + +There's room here for you, too. + +## Credits + +brush stands on the shoulders of excellent open source projects, including (but not limited to): + +- [reedline](https://github.com/nushell/reedline) for line editing and interactive features +- [winnow-args](https://github.com/lu-zero/winnow-args) for command-line parsing +- [fancy-regex](https://github.com/fancy-regex/fancy-regex) for regular expressions +- [tokio](https://github.com/tokio-rs/tokio) as the async runtime +- [nix](https://github.com/nix-rust/nix) for accessing Unix and POSIX APIs +- [criterion.rs](https://github.com/bheisler/criterion.rs) for benchmarking +- [bash-completion](https://github.com/scop/bash-completion) for its completion test suite + +--- + +Released as open source under the [MIT license](LICENSE). Built in the open by the [brush community](https://github.com/reubeno/brush/graphs/contributors). diff --git a/userland/brush/source/benchmarks/.gitignore b/userland/brush/source/benchmarks/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/userland/brush/source/benchmarks/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/userland/brush/source/benchmarks/bench.py b/userland/brush/source/benchmarks/bench.py new file mode 100755 index 0000000..3dcb6d8 --- /dev/null +++ b/userland/brush/source/benchmarks/bench.py @@ -0,0 +1,974 @@ +#!/usr/bin/env python3 +""" +Shell benchmarking harness for brush. + +Runs shell script snippets against test and reference shells using +warmup, calibration, and timed execution phases. + +NOTE: This script was authored by an AI coding agent in conjunction + with human guidance. +""" + +import argparse +import json +import math +import os +import re +import shutil +import statistics +import subprocess +import sys +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class BenchmarkCase: + """ + A benchmark case measuring a specific shell operation. + + The benchmark runs `loop_body` in a tight loop, with optional `setup` + executed once before timing and `cleanup` after. + + Exit-code contract: + - The `setup` phase may validate preconditions and exit with non-zero + status to signal failure (e.g., missing external commands). + - The generated script always ends with `exit 0` to confirm success. + - Any non-zero exit from the shell is treated as a fatal benchmark error. + """ + + loop_body: str + setup: Optional[str] = None + cleanup: Optional[str] = None + + +@dataclass +class TimingResult: + """ + Timing data parsed from shell's `time` builtin output. + + Captures wall-clock time (real), user CPU time, and system CPU time. + """ + + real_seconds: float + user_seconds: float + sys_seconds: float + + +@dataclass +class BenchmarkResult: + """ + Aggregated results from running a benchmark across multiple samples. + + Contains per-iteration timing statistics (median, MAD, min, max) computed + from `sample_count` independent timed runs. Uses median and MAD (median + absolute deviation) instead of mean and stddev for robustness against + outliers from system jitter. + """ + + case_name: str + iterations_per_sample: int + sample_count: int + per_iteration_ns: float # median across samples + per_iteration_ns_mad: float # MAD scaled by 1.4826 for normal comparability + per_iteration_ns_min: float + per_iteration_ns_max: float + shell_path: str + + +@dataclass +class ComparisonResult: + """ + Performance comparison between test shell and reference shell. + + The `change` ratio is reference_time / test_time, so values > 1.0 + indicate the test shell is faster than the reference. + """ + + case_name: str + test_result: BenchmarkResult + reference_result: BenchmarkResult + change: float # ratio: ref_time / test_time (>1 = test is faster) + + +# ============================================================================= +# Built-in benchmark cases +# ============================================================================= + +BENCHMARK_CASES: dict[str, BenchmarkCase] = { + "assignment": BenchmarkCase( + loop_body="x=42", + ), + "colon": BenchmarkCase( + loop_body=":", + ), + "echo_builtin": BenchmarkCase( + loop_body="echo >/dev/null", + ), + "echo_cmd": BenchmarkCase( + setup="command -v /usr/bin/echo >/dev/null || exit 1", + loop_body="/usr/bin/echo >/dev/null", + ), + "increment": BenchmarkCase( + setup="x=0", + loop_body="((x++))", + ), + "subshell": BenchmarkCase( + loop_body="(:)", + ), + "cmdsubst": BenchmarkCase( + loop_body=': $(:)', + ), + "var_expand": BenchmarkCase( + setup='myvar="hello world"', + loop_body=': "$myvar"', + ), + "func_call": BenchmarkCase( + setup="myfunc() { :; }", + loop_body="myfunc", + ), + "array_access": BenchmarkCase( + setup='myarr=(a b c d e f g h i j)', + loop_body=': "${myarr[5]}"', + ), + "pattern_match": BenchmarkCase( + loop_body='[[ "hello world" == hello* ]]', + ), + "regex_match": BenchmarkCase( + loop_body='[[ "hello world" =~ ^hello.* ]]', + ), + "if_taken": BenchmarkCase( + loop_body="if [[ 1 -eq 1 ]]; then :; fi", + ), + "if_not_taken": BenchmarkCase( + loop_body="if [[ 0 -eq 1 ]]; then :; fi", + ), +} + + +# ============================================================================= +# Time output parsing +# ============================================================================= + + +def parse_time_output(stderr: str) -> TimingResult: + """ + Parse bash's default time output format. + + Expected format (on stderr): + real 0m0.123s + user 0m0.045s + sys 0m0.012s + + The generated benchmark scripts explicitly set TIMEFORMAT to match bash's + default format, ensuring consistent parsing across shells that support + this variable. Locale settings (LC_NUMERIC) could theoretically affect + decimal separators, but this is rare in practice. + """ + # Pattern matches: real/user/sys followed by XmY.ZZZs format + pattern = r"(real|user|sys)\s+(\d+)m([\d.]+)s" + matches = re.findall(pattern, stderr) + + if len(matches) < 3: + raise ValueError(f"Failed to parse time output: {stderr!r}") + + results = {} + for label, minutes, seconds in matches: + total_seconds = int(minutes) * 60 + float(seconds) + results[label] = total_seconds + + return TimingResult( + real_seconds=results.get("real", 0.0), + user_seconds=results.get("user", 0.0), + sys_seconds=results.get("sys", 0.0), + ) + + +# ============================================================================= +# Script generation +# ============================================================================= + + +def generate_benchmark_script(case: BenchmarkCase, iterations: int) -> str: + """ + Generate a complete benchmark script for the given case. + + The script: + 1. Sets TIMEFORMAT to match bash's default output format + 2. Runs optional setup (which may exit non-zero to abort) + 3. Times the loop body for the specified iterations + 4. Runs optional cleanup + 5. Exits with 0 to confirm successful completion + """ + lines = [] + + # Pin TIMEFORMAT to bash's default format for consistent parsing + # Format: real\t%lR\nuser\t%lU\nsys\t%lS (where %l uses Mm.SSSs format) + lines.append(r"TIMEFORMAT=$'real\t%lR\nuser\t%lU\nsys\t%lS'") + + # Setup (optional) - may exit non-zero to signal precondition failure + if case.setup: + lines.append(case.setup) + + # Timed loop (no braces needed around for loop) + lines.append( + f"time for ((i=0; i<{iterations}; i++)); do {case.loop_body}; done" + ) + + # Cleanup (optional) + if case.cleanup: + lines.append(case.cleanup) + + # Explicit success exit to confirm script completed without error + lines.append("exit 0") + + return "\n".join(lines) + + +# ============================================================================= +# Shell execution +# ============================================================================= + + +def get_shell_flags(shell_path: str) -> list[str]: + """Get appropriate flags for running a shell in benchmark mode.""" + # Check if this looks like brush + shell_name = shell_path.split("/")[-1] + + if "brush" in shell_name: + return [ + "--norc", + "--noprofile", + "--input-backend=basic", + "--disable-bracketed-paste", + "--disable-color", + ] + else: + # Assume bash-like shell + return ["--norc", "--noprofile"] + + +def run_shell_script(shell_path: str, script: str) -> subprocess.CompletedProcess: + """Run a script using the specified shell.""" + flags = get_shell_flags(shell_path) + cmd = [shell_path] + flags + ["-c", script] + + return subprocess.run( + cmd, + capture_output=True, + text=True, + ) + + +# ============================================================================= +# Benchmark execution +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Unit conversion constants +# ----------------------------------------------------------------------------- + +# Nanoseconds per second, used for timing conversions. +NS_PER_SECOND = 1_000_000_000 + +# ----------------------------------------------------------------------------- +# Warmup phase constants +# ----------------------------------------------------------------------------- + +# Default warmup duration in seconds. Warmup ensures CPU caches are hot and +# the shell's internal state is stable before measurement begins. +# Set to 0 to skip warmup entirely. +DEFAULT_WARMUP_DURATION_SECONDS = 0.5 + +# Number of iterations for the quick probe used to estimate per-iteration time +# during warmup. This should be small enough to complete quickly but large +# enough to get a rough timing estimate. +WARMUP_PROBE_ITERATIONS = 100 + +# Multiplier applied to WARMUP_PROBE_ITERATIONS when the probe completes too +# fast to measure (real_seconds == 0). This is a fallback for extremely fast +# operations where even 100 iterations complete in sub-millisecond time. +WARMUP_FALLBACK_MULTIPLIER = 10 + +# ----------------------------------------------------------------------------- +# Calibration phase constants +# ----------------------------------------------------------------------------- + +# Starting iteration count for calibration. We double this until we get a +# measurable time (>= CALIBRATION_MIN_TIME_SECONDS). +CALIBRATION_MIN_ITERATIONS = 100 + +# Minimum elapsed time in seconds for reliable calibration measurement. +# Below this threshold, timer resolution and process startup overhead +# dominate the measurement. +CALIBRATION_MIN_TIME_SECONDS = 0.1 + +# Maximum iterations to attempt during calibration. Prevents runaway loops +# if something is fundamentally broken. +CALIBRATION_MAX_ITERATIONS = 100_000_000 + +# Maximum iterations per sample. Prevents extremely long-running samples +# even if per-iteration time is very small. +MAX_TARGET_ITERATIONS = 1_000_000_000 + +# ----------------------------------------------------------------------------- +# Statistical sampling constants +# ----------------------------------------------------------------------------- + +# Default number of independent samples to collect. More samples improve +# statistical reliability but increase total benchmark time. +DEFAULT_SAMPLES = 10 + +# Minimum duration per sample in seconds. Samples shorter than this are +# unreliable due to timer resolution and system noise. +MIN_SAMPLE_DURATION_SECONDS = 0.5 + +# Scale factor to convert MAD (median absolute deviation) to an estimate +# comparable to standard deviation for normally distributed data. +# For a normal distribution, MAD * 1.4826 ≈ stddev. +MAD_NORMAL_SCALE_FACTOR = 1.4826 + +# Coefficient of variation threshold (as percentage) above which we flag +# results as having high variance, indicating unreliable measurements. +HIGH_VARIANCE_THRESHOLD_PCT = 10.0 + +# ----------------------------------------------------------------------------- +# Performance comparison constants +# ----------------------------------------------------------------------------- + +# Default benchmark duration in seconds (total time budget per benchmark). +DEFAULT_DURATION_SECONDS = 10.0 + +# Threshold for classifying performance change as "faster" (test outperforms +# reference). A value of 1.1 means test must be at least 10% faster. +CHANGE_FASTER_THRESHOLD = 1.1 + +# Threshold for classifying performance change as "similar" (no significant +# difference). A value of 0.95 means test can be up to 5% slower and still +# be considered similar. Below this is classified as "slower". +CHANGE_SIMILAR_THRESHOLD = 0.95 + + +def run_benchmark_phase( + shell_path: str, case: BenchmarkCase, iterations: int +) -> TimingResult: + """ + Run a single benchmark phase and return timing. + + Raises RuntimeError if the shell exits with non-zero status, which + indicates either a setup precondition failure or an execution error. + """ + script = generate_benchmark_script(case, iterations) + result = run_shell_script(shell_path, script) + + if result.returncode != 0: + raise RuntimeError( + f"Benchmark failed (exit {result.returncode}). This may indicate " + f"a setup precondition failure or shell execution error.\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + return parse_time_output(result.stderr) + + +def run_benchmark( + shell_path: str, + case_name: str, + case: BenchmarkCase, + target_duration: float, + num_samples: int = DEFAULT_SAMPLES, + warmup_duration: float = DEFAULT_WARMUP_DURATION_SECONDS, + verbose: bool = False, +) -> BenchmarkResult: + """ + Run a complete benchmark with warmup, calibration, and multiple sample phases. + + Args: + shell_path: Path to shell executable + case_name: Name of the benchmark case + case: Benchmark case to run + target_duration: Total time budget in seconds (divided among samples) + num_samples: Number of independent samples to collect + warmup_duration: Time in seconds to spend warming up (default: 0.5s; 0 to skip) + verbose: Print progress information + + Returns: + BenchmarkResult with timing statistics (median, MAD) across all samples + """ + # Determine actual sample count based on duration constraints + sample_duration = target_duration / num_samples + actual_samples = num_samples + + if sample_duration < MIN_SAMPLE_DURATION_SECONDS: + actual_samples = max(1, int(target_duration / MIN_SAMPLE_DURATION_SECONDS)) + sample_duration = target_duration / actual_samples + # Always warn when we reduce samples (even in non-verbose mode) + print( + f" ⚠️ Duration too short for {num_samples} samples; " + f"using {actual_samples} sample(s) of {sample_duration:.1f}s instead", + file=sys.stderr, + ) + + # Phase 1: Time-based warmup (skip if warmup_duration is 0) + if warmup_duration > 0: + if verbose: + print(f" 🔥 Warming up ({warmup_duration}s)...", file=sys.stderr) + + warmup_probe = run_benchmark_phase(shell_path, case, WARMUP_PROBE_ITERATIONS) + warmup_per_iter = warmup_probe.real_seconds / WARMUP_PROBE_ITERATIONS + if warmup_per_iter > 0: + warmup_iterations = max(1, int(warmup_duration / warmup_per_iter)) + else: + # Probe was too fast to measure - use fallback iteration count + warmup_iterations = WARMUP_PROBE_ITERATIONS * WARMUP_FALLBACK_MULTIPLIER + if verbose: + print( + f" (warmup probe too fast to measure, using {warmup_iterations} iterations)", + file=sys.stderr, + ) + + run_benchmark_phase(shell_path, case, warmup_iterations) + elif verbose: + print(" 🔥 Skipping warmup (duration=0)", file=sys.stderr) + + if verbose: + print(" 📏 Calibrating...", file=sys.stderr) + + # Phase 2: Adaptive calibration + # Keep doubling iterations until we get a measurable time + calibration_iterations = CALIBRATION_MIN_ITERATIONS + calibration_timing: Optional[TimingResult] = None + while calibration_iterations <= CALIBRATION_MAX_ITERATIONS: + calibration_timing = run_benchmark_phase(shell_path, case, calibration_iterations) + + if calibration_timing.real_seconds >= CALIBRATION_MIN_TIME_SECONDS: + # We have enough time for reliable measurement + break + + if verbose: + print( + f" (calibration too fast: {calibration_timing.real_seconds:.4f}s " + f"for {calibration_iterations} iters, doubling...)", + file=sys.stderr, + ) + + calibration_iterations *= 2 + + assert calibration_timing is not None # Loop always runs at least once + if calibration_timing.real_seconds < CALIBRATION_MIN_TIME_SECONDS: + # Even at max iterations, still too fast - use what we have + if verbose: + print( + f" (warning: calibration capped at {calibration_iterations} iterations)", + file=sys.stderr, + ) + + # Calculate iterations needed for each sample + per_iteration_seconds = calibration_timing.real_seconds / calibration_iterations + if per_iteration_seconds <= 0: + # Fallback if calibration was too fast: use minimum measurable time + # divided by max iterations as a conservative lower bound + per_iteration_seconds = CALIBRATION_MIN_TIME_SECONDS / CALIBRATION_MAX_ITERATIONS + + iterations_per_sample = max(1, int(sample_duration / per_iteration_seconds)) + iterations_per_sample = min(iterations_per_sample, MAX_TARGET_ITERATIONS) + + if verbose: + print( + f" ⏱️ Running {actual_samples} samples of {iterations_per_sample} iterations " + f"(est. {sample_duration:.1f}s each)...", + file=sys.stderr, + ) + + # Phase 3: Collect multiple samples + sample_ns_values: list[float] = [] + for sample_idx in range(actual_samples): + sample_timing = run_benchmark_phase(shell_path, case, iterations_per_sample) + per_iter_ns = (sample_timing.real_seconds / iterations_per_sample) * NS_PER_SECOND + sample_ns_values.append(per_iter_ns) + + if verbose: + print( + f" Sample {sample_idx + 1}/{actual_samples}: " + f"{format_duration_ns(per_iter_ns)}/iter", + file=sys.stderr, + ) + + # Compute statistics using median and MAD for robustness against outliers + median_ns = statistics.median(sample_ns_values) + if len(sample_ns_values) > 1: + # MAD = median(|x_i - median(x)|), scaled to be comparable to stddev + deviations = [abs(x - median_ns) for x in sample_ns_values] + mad_ns = statistics.median(deviations) * MAD_NORMAL_SCALE_FACTOR + else: + mad_ns = 0.0 + min_ns = min(sample_ns_values) + max_ns = max(sample_ns_values) + + return BenchmarkResult( + case_name=case_name, + iterations_per_sample=iterations_per_sample, + sample_count=actual_samples, + per_iteration_ns=median_ns, + per_iteration_ns_mad=mad_ns, + per_iteration_ns_min=min_ns, + per_iteration_ns_max=max_ns, + shell_path=shell_path, + ) + + +# ============================================================================= +# Output formatting +# ============================================================================= + + +def format_duration_ns(ns: float) -> str: + """Format a duration in nanoseconds to a human-readable string.""" + if ns >= NS_PER_SECOND: + return f"{ns / NS_PER_SECOND:.3f} s" + elif ns >= 1e6: + return f"{ns / 1e6:.3f} ms" + elif ns >= 1e3: + return f"{ns / 1e3:.3f} µs" + else: + return f"{ns:.3f} ns" + + +def format_result_with_variance(result: BenchmarkResult) -> str: + """ + Format a benchmark result with variance indicator. + + Uses MAD-based coefficient of variation for robustness against outliers. + """ + median_str = format_duration_ns(result.per_iteration_ns) + + if result.sample_count <= 1 or result.per_iteration_ns == 0: + return f"{median_str}/iter" + + # Coefficient of variation using MAD (scaled to be stddev-comparable) + cv_pct = (result.per_iteration_ns_mad / result.per_iteration_ns) * 100 + + # Flag high variance with warning indicator + if cv_pct > HIGH_VARIANCE_THRESHOLD_PCT: + return f"{median_str}/iter (±{cv_pct:.1f}% ⚠️)" + else: + return f"{median_str}/iter (±{cv_pct:.1f}%)" + + +def format_change(change: float) -> str: + """ + Format performance change ratio with emoji indicator and percentage. + + Args: + change: Ratio of reference_time / test_time. Values > 1.0 mean + the test shell is faster than the reference. + + Returns: + Formatted string with emoji and percentage change. + """ + # Calculate percentage change (negative = faster, positive = slower) + # change > 1 means test is faster, so pct_change should be negative + pct_change = (1.0 / change - 1.0) * 100 + + if change >= CHANGE_FASTER_THRESHOLD: + emoji = "🚀" + text = f"{pct_change:.1f}% (faster)" + elif change >= CHANGE_SIMILAR_THRESHOLD: + emoji = "⚖️ " + text = f"{pct_change:+.1f}% (similar)" + else: + emoji = "🐢" + text = f"+{pct_change:.1f}% (slower)" + return f"{emoji} {text}" + + +def print_human_single_shell_results(results: list[BenchmarkResult], shell: str): + """Print results for a single shell (no comparison).""" + print() + print("=" * 70) + print("📊 Shell Benchmark Results") + print("=" * 70) + print(f" Shell: {shell}") + print("=" * 70) + print() + + for result in results: + print(f"🧪 {result.case_name}") + print(f" {format_result_with_variance(result)}") + print(f" ({result.sample_count} samples, {result.iterations_per_sample} iters/sample)") + print() + + +def compute_geometric_mean_change(comparisons: list[ComparisonResult]) -> float: + """ + Compute the geometric mean of change ratios across comparisons. + + Geometric mean is the correct way to average ratios/rates. Returns 1.0 + if the list is empty or if any change ratio is non-positive (which would + indicate a measurement error). + """ + if not comparisons: + return 1.0 + + # Filter out any non-positive values (shouldn't happen, but guard against it) + valid_changes = [comp.change for comp in comparisons if comp.change > 0] + if not valid_changes: + return 1.0 + + log_sum = sum(math.log(change) for change in valid_changes) + return math.exp(log_sum / len(valid_changes)) + + +def print_human_results( + comparisons: list[ComparisonResult], test_shell: str, ref_shell: str +): + """Print comparison results in human-readable format with emoji.""" + print() + print("=" * 70) + print("📊 Shell Benchmark Results") + print("=" * 70) + print(f" Test shell: {test_shell}") + print(f" Reference shell: {ref_shell}") + print("=" * 70) + print() + + for comp in comparisons: + print(f"🧪 {comp.case_name}") + print(f" Test: {format_result_with_variance(comp.test_result)}") + print(f" Reference: {format_result_with_variance(comp.reference_result)}") + print(f" Change: {format_change(comp.change)}") + print() + + # Summary - use geometric mean for averaging ratios (more statistically sound) + print("-" * 70) + avg_change = compute_geometric_mean_change(comparisons) + print(f"📈 Overall change (geometric mean): {format_change(avg_change)}") + print() + + +def _result_to_json(result: BenchmarkResult) -> dict: + """ + Convert a BenchmarkResult to a JSON-serializable dict. + + Note: per_iteration_ns is the median, and per_iteration_ns_mad is the + MAD (median absolute deviation) scaled by 1.4826 for normal comparability. + """ + return { + "iterations_per_sample": result.iterations_per_sample, + "sample_count": result.sample_count, + "per_iteration_ns": result.per_iteration_ns, + "per_iteration_ns_mad": result.per_iteration_ns_mad, + "per_iteration_ns_min": result.per_iteration_ns_min, + "per_iteration_ns_max": result.per_iteration_ns_max, + } + + +def print_json_single_shell_results(results: list[BenchmarkResult], shell: str): + """Print results for a single shell in JSON format.""" + output = { + "shell": shell, + "results": [ + { + "name": result.case_name, + **_result_to_json(result), + } + for result in results + ], + } + print(json.dumps(output, indent=2)) + + +def print_json_results( + comparisons: list[ComparisonResult], test_shell: str, ref_shell: str +): + """Print comparison results in JSON format.""" + avg_change = compute_geometric_mean_change(comparisons) + output = { + "test_shell": test_shell, + "reference_shell": ref_shell, + "results": [ + { + "name": comp.case_name, + "test": _result_to_json(comp.test_result), + "reference": _result_to_json(comp.reference_result), + "change": comp.change, + } + for comp in comparisons + ], + "overall_change": avg_change, + } + print(json.dumps(output, indent=2)) + + +# ============================================================================= +# Main entry point +# ============================================================================= + + +def resolve_shell_path(shell: str) -> str: + """ + Resolve shell path, checking it exists and is executable. + + Args: + shell: Shell name or path (e.g., 'bash' or '/usr/bin/bash') + + Returns: + Resolved absolute path to the shell executable. + + Exits with error if shell is not found or not executable. + """ + # If it's an absolute or relative path, use as-is + if "/" in shell: + resolved = shell + else: + # Search PATH + resolved = shutil.which(shell) + if resolved is None: + print(f"Error: Shell '{shell}' not found in PATH", file=sys.stderr) + sys.exit(1) + + # Verify it exists and is executable + if not os.path.isfile(resolved): + print(f"Error: Shell path '{resolved}' does not exist or is not a file", file=sys.stderr) + sys.exit(1) + if not os.access(resolved, os.X_OK): + print(f"Error: Shell '{resolved}' is not executable", file=sys.stderr) + sys.exit(1) + + return resolved + + +def _positive_float(value: str) -> float: + """Argparse type validator for positive float values.""" + try: + fval = float(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid float value: '{value}'") + if fval < 0: + raise argparse.ArgumentTypeError(f"value must be non-negative: {fval}") + return fval + + +def _positive_int(value: str) -> int: + """Argparse type validator for positive integer values.""" + try: + ival = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid integer value: '{value}'") + if ival <= 0: + raise argparse.ArgumentTypeError(f"value must be positive: {ival}") + return ival + + +def main(): + parser = argparse.ArgumentParser( + description="Shell benchmarking harness for brush", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Available benchmarks: + assignment - Variable assignment (x=42) + colon - No-op builtin (:) + echo_builtin - Echo builtin to /dev/null + echo_cmd - External /usr/bin/echo to /dev/null + increment - Arithmetic increment ((x++)) + subshell - Subshell fork ((:)) + cmdsubst - Command substitution ($(...)) + var_expand - Variable expansion ("$var") + func_call - Function invocation + array_access - Array element access (${arr[i]}) + pattern_match - Glob pattern match in [[ ]] + regex_match - Regex match in [[ =~ ]] + if_taken - If conditional (branch taken) + if_not_taken - If conditional (branch not taken) + +Examples: + %(prog)s --shell ./target/release/brush + %(prog)s --shell brush --reference-shell bash --benchmarks colon increment + %(prog)s --shell brush --duration 5 --json +""", + ) + + parser.add_argument( + "-s", + "--shell", + dest="shell", + type=str, + required=True, + help="Path to test shell binary", + ) + + parser.add_argument( + "-r", + "--reference-shell", + dest="reference_shell", + type=str, + default=None, + help="Path to reference shell for comparison (optional; if omitted, only test shell is benchmarked)", + ) + + parser.add_argument( + "-b", + "--benchmarks", + dest="benchmarks", + type=str, + nargs="+", + default=list(BENCHMARK_CASES.keys()), + choices=list(BENCHMARK_CASES.keys()), + help="Name(s) of benchmark(s) to run (default: all)", + ) + + parser.add_argument( + "--samples", + dest="samples", + type=_positive_int, + default=DEFAULT_SAMPLES, + help=f"Number of samples to collect per benchmark (default: {DEFAULT_SAMPLES})", + ) + + parser.add_argument( + "-d", + "--duration", + dest="duration", + type=_positive_float, + default=DEFAULT_DURATION_SECONDS, + help=f"Number of seconds to run each test (default: {DEFAULT_DURATION_SECONDS})", + ) + + parser.add_argument( + "-w", + "--warmup-duration", + dest="warmup_duration", + type=_positive_float, + default=DEFAULT_WARMUP_DURATION_SECONDS, + help=f"Warmup duration in seconds; 0 to skip (default: {DEFAULT_WARMUP_DURATION_SECONDS})", + ) + + parser.add_argument( + "-j", + "--json", + dest="json_output", + action="store_true", + help="Output results in JSON format", + ) + + parser.add_argument( + "-v", + "--verbose", + dest="verbose", + action="store_true", + help="Print progress information", + ) + + args = parser.parse_args() + + # Resolve shell paths + test_shell = resolve_shell_path(args.shell) + ref_shell = resolve_shell_path(args.reference_shell) if args.reference_shell else None + + if not args.json_output: + print("🐚 Shell Benchmark Harness", file=sys.stderr) + print(f" Test shell: {test_shell}", file=sys.stderr) + if ref_shell: + print(f" Reference shell: {ref_shell}", file=sys.stderr) + print(f" Duration: {args.duration}s per benchmark", file=sys.stderr) + print(f" Warmup: {args.warmup_duration}s", file=sys.stderr) + print(f" Samples: {args.samples}", file=sys.stderr) + print(f" Benchmarks: {', '.join(args.benchmarks)}", file=sys.stderr) + print(file=sys.stderr) + + # Single-shell mode (no reference) + if ref_shell is None: + results = [] + for bench_name in args.benchmarks: + case = BENCHMARK_CASES[bench_name] + + if not args.json_output: + print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr) + print(f" Testing: {test_shell}", file=sys.stderr) + + result = run_benchmark( + test_shell, + bench_name, + case, + args.duration, + args.samples, + args.warmup_duration, + verbose=args.verbose, + ) + results.append(result) + + if not args.json_output: + print(file=sys.stderr) + + if args.json_output: + print_json_single_shell_results(results, test_shell) + else: + print_human_single_shell_results(results, test_shell) + return + + # Comparison mode (test vs reference) + comparisons = [] + + for bench_name in args.benchmarks: + case = BENCHMARK_CASES[bench_name] + + if not args.json_output: + print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr) + + # Run on test shell + if not args.json_output: + print(f" Testing: {test_shell}", file=sys.stderr) + test_result = run_benchmark( + test_shell, + bench_name, + case, + args.duration, + args.samples, + args.warmup_duration, + verbose=args.verbose, + ) + + # Run on reference shell + if not args.json_output: + print(f" Testing: {ref_shell}", file=sys.stderr) + ref_result = run_benchmark( + ref_shell, + bench_name, + case, + args.duration, + args.samples, + args.warmup_duration, + verbose=args.verbose, + ) + + # Calculate performance change (reference time / test time) + # > 1 means test shell is faster + # Note: per_iteration_ns is the median value + if test_result.per_iteration_ns > 0: + change = ref_result.per_iteration_ns / test_result.per_iteration_ns + else: + # Test result was unmeasurably fast; treat as equivalent + change = 1.0 + + comparisons.append( + ComparisonResult( + case_name=bench_name, + test_result=test_result, + reference_result=ref_result, + change=change, + ) + ) + + if not args.json_output: + print(file=sys.stderr) + + # Output results + if args.json_output: + print_json_results(comparisons, test_shell, ref_shell) + else: + print_human_results(comparisons, test_shell, ref_shell) + + +if __name__ == "__main__": + main() diff --git a/userland/brush/source/brush-builtins/Cargo.toml b/userland/brush/source/brush-builtins/Cargo.toml new file mode 100644 index 0000000..925ada7 --- /dev/null +++ b/userland/brush/source/brush-builtins/Cargo.toml @@ -0,0 +1,181 @@ +[package] +name = "brush-builtins" +description = "Builtins for a POSIX/bash shell (used by brush-shell)" +version = "0.2.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[features] +default = [ + 'builtin.alias', + 'builtin.bg', + 'builtin.bind', + 'builtin.break', + 'builtin.builtin', + 'builtin.caller', + 'builtin.cd', + 'builtin.colon', + 'builtin.command', + 'builtin.compgen', + 'builtin.compopt', + 'builtin.complete', + 'builtin.continue', + 'builtin.declare', + 'builtin.dirs', + 'builtin.dot', + 'builtin.echo', + 'builtin.enable', + 'builtin.eval', + 'builtin.exec', + 'builtin.exit', + 'builtin.export', + 'builtin.false', + 'builtin.fc', + 'builtin.fg', + 'builtin.getopts', + 'builtin.hash', + 'builtin.help', + 'builtin.history', + 'builtin.jobs', + 'builtin.kill', + 'builtin.let', + 'builtin.mapfile', + 'builtin.popd', + 'builtin.printf', + 'builtin.pushd', + 'builtin.pwd', + 'builtin.read', + 'builtin.return', + 'builtin.set', + 'builtin.shift', + 'builtin.shopt', + 'builtin.suspend', + 'builtin.test', + 'builtin.times', + 'builtin.trap', + 'builtin.true', + 'builtin.type', + 'builtin.ulimit', + 'builtin.umask', + 'builtin.unalias', + 'builtin.unset', + 'builtin.wait', +] + +'builtin.alias' = [] +'builtin.bg' = [] +'builtin.bind' = [] +'builtin.break' = [] +'builtin.builtin' = [] +'builtin.caller' = [] +'builtin.cd' = [] +'builtin.colon' = [] +'builtin.command' = [] +'builtin.compgen' = [] +'builtin.complete' = [] +'builtin.compopt' = [] +'builtin.continue' = [] +'builtin.declare' = [] +'builtin.dirs' = [] +'builtin.dot' = [] +'builtin.echo' = [] +'builtin.enable' = [] +'builtin.eval' = [] +'builtin.exec' = ['builtin.command'] +'builtin.exit' = [] +'builtin.export' = [] +'builtin.false' = [] +'builtin.fc' = [] +'builtin.fg' = [] +'builtin.getopts' = [] +'builtin.hash' = [] +'builtin.help' = [] +'builtin.history' = [] +'builtin.jobs' = [] +'builtin.kill' = [] +'builtin.let' = [] +'builtin.mapfile' = [] +'builtin.popd' = [] +'builtin.printf' = [] +'builtin.pushd' = [] +'builtin.pwd' = [] +'builtin.read' = [] +'builtin.return' = [] +'builtin.set' = [] +'builtin.shift' = [] +'builtin.shopt' = [] +'builtin.suspend' = [] +'builtin.test' = [] +'builtin.times' = [] +'builtin.trap' = [] +'builtin.true' = [] +'builtin.type' = [] +'builtin.ulimit' = [] +'builtin.umask' = [] +'builtin.unalias' = [] +'builtin.unset' = [] +'builtin.wait' = [] + +[lints] +workspace = true + +[dependencies] +brush-winnow-args-adapter = { version = "^0.1.0", path = "../brush-winnow-args-adapter" } +brush-core = { version = "^0.5.0", path = "../brush-core" } +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +cfg-if = "1.0.4" +chrono = "0.4.44" +fancy-regex = "0.19.0" +itertools = "0.15.0" +strum = "0.28.0" +thiserror = "2.0.18" +tracing = "0.1.44" +winnow-args = { version = "0.6.0", default-features = false, features = ["derive", "help-text"] } +utf8-chars = "3.0.6" + +[target.'cfg(target_family = "wasm")'.dependencies] +tokio = { version = "1.52.3", features = ["io-util", "macros", "rt"] } + +[target.'cfg(any(unix, windows))'.dependencies] +tokio = { version = "1.52.3", features = [ + "io-util", + "macros", + "process", + "rt", + "rt-multi-thread", + "signal", + "sync", +] } +uucore = { version = "0.12.0", default-features = false, features = [ + "format", + "signals", +] } + +[target.'cfg(target_os = "wasi")'.dependencies] +uucore = { version = "0.12.0", default-features = false, features = ["format"] } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = [ + "fs", + "process", + "resource", + "signal", + "term", + "user", +] } +rlimit = "0.11.0" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +procfs = "0.18.0" + +[dev-dependencies] +anyhow = "1.0.102" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } diff --git a/userland/brush/source/brush-builtins/LICENSE b/userland/brush/source/brush-builtins/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-builtins/src/alias.rs b/userland/brush/source/brush-builtins/src/alias.rs new file mode 100644 index 0000000..6f8707a --- /dev/null +++ b/userland/brush/source/brush-builtins/src/alias.rs @@ -0,0 +1,61 @@ +use itertools::Itertools; +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +use crate::write_alias_definition; + +/// Manage aliases within the shell. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct AliasCommand { + /// Print all defined aliases in a reusable format. + #[arg(short = 'p')] + print: bool, + + /// List of aliases to display or update. + #[arg(positional, value_name = "name[=value]")] + aliases: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(AliasCommand); + +impl builtins::Builtin for AliasCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut exit_code = ExecutionResult::success(); + + if self.print || self.aliases.is_empty() { + // Aliases are stored unordered; bash lists them sorted by name. + for (name, value) in context.shell.aliases().iter().sorted() { + write_alias_definition(context.stdout(), name, value)?; + } + } else { + for alias in &self.aliases { + if let Some((name, unexpanded_value)) = alias.split_once('=') + && !name.is_empty() + { + context + .shell + .aliases_mut() + .insert(name.to_owned(), unexpanded_value.to_owned()); + } else if let Some(value) = context.shell.aliases().get(alias) { + write_alias_definition(context.stdout(), alias, value)?; + } else { + writeln!( + context.stderr(), + "{}: {alias}: not found", + context.command_name + )?; + exit_code = ExecutionResult::general_error(); + } + } + } + + Ok(exit_code) + } +} diff --git a/userland/brush/source/brush-builtins/src/bg.rs b/userland/brush/source/brush-builtins/src/bg.rs new file mode 100644 index 0000000..9456ef4 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/bg.rs @@ -0,0 +1,75 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, jobs}; + +/// Moves a job to run in the background. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct BgCommand { + /// List of job specs to move to background. + #[arg(positional)] + job_specs: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(BgCommand); + +impl builtins::Builtin for BgCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut exit_code = ExecutionResult::success(); + + if !self.job_specs.is_empty() { + for job_spec in &self.job_specs { + if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) { + let running = running_job_id(job); + job.move_to_background()?; + warn_if_running(&context, running)?; + } else { + writeln!( + context.stderr(), + "{}: {}: no such job", + context.command_name, + job_spec + )?; + exit_code = ExecutionResult::general_error(); + } + } + } else { + if let Some(job) = context.shell.jobs_mut().current_job_mut() { + let running = running_job_id(job); + job.move_to_background()?; + warn_if_running(&context, running)?; + } else { + writeln!(context.stderr(), "{}: no current job", context.command_name)?; + exit_code = ExecutionResult::general_error(); + } + } + + Ok(exit_code) + } +} + +/// Returns the ID of the job if it isn't known to be stopped. (`bg` continues such a job +/// anyway, as it may have been stopped without the shell's noticing.) +fn running_job_id(job: &jobs::Job) -> Option { + (!matches!(job.state, jobs::JobState::Stopped)).then_some(job.id) +} + +/// Warns, as bash does, that a job not known to be stopped is already in the background. +fn warn_if_running( + context: &brush_core::ExecutionContext<'_, SE>, + running_job_id: Option, +) -> Result<(), brush_core::Error> { + if let Some(id) = running_job_id { + writeln!( + context.stderr(), + "{}: job {id} already in background", + context.command_name + )?; + } + Ok(()) +} diff --git a/userland/brush/source/brush-builtins/src/bind.rs b/userland/brush/source/brush-builtins/src/bind.rs new file mode 100644 index 0000000..cc1b6a2 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/bind.rs @@ -0,0 +1,575 @@ +use itertools::Itertools as _; +use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc}; +use strum::IntoEnumIterator; +use tokio::sync::Mutex; + +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + interfaces::{self, InputFunction, KeyAction, KeySequence}, + sys, trace_categories, +}; + +impl BindKeyMap { + const fn is_vi(&self) -> bool { + matches!(self, Self::ViCommand | Self::ViInsert) + } + + #[expect(dead_code)] + const fn is_emacs(&self) -> bool { + matches!( + self, + Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx + ) + } +} + +/// Inspect and modify key bindings and other input configuration. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct BindCommand { + /// Name of key map to use. + #[arg(short = 'm')] + keymap: Option, + /// List functions. + #[arg(short = 'l')] + list_funcs: bool, + /// List functions and bindings. + #[arg(short = 'P')] + list_funcs_and_bindings: bool, + /// List functions and bindings in a format suitable for use as input. + #[arg(short = 'p')] + list_funcs_and_bindings_reusable: bool, + /// List key sequences that invoke macros. + #[arg(short = 'S')] + list_key_seqs_that_invoke_macros: bool, + /// List key sequences that invoke macros in a format suitable for use as input. + #[arg(short = 's')] + list_key_seqs_that_invoke_macros_reusable: bool, + /// List variables. + #[arg(short = 'V')] + list_vars: bool, + /// List variables in a format suitable for use as input. + #[arg(short = 'v')] + list_vars_reusable: bool, + /// Find the keys bound to the given named function. + #[arg(short = 'q', value_name = "FUNC_NAME")] + query_func_bindings: Option, + /// Remove all bindings for the given named function. + #[arg(short = 'u', value_name = "FUNC_NAME")] + remove_func_bindings: Option, + /// Remove the binding for the given key sequence. + #[arg(short = 'r', value_name = "KEY_SEQ")] + remove_key_seq_binding: Option, + /// Import bindings from the given file. + #[arg(short = 'f', value_name = "PATH")] + bindings_file: Option, + /// Bind key sequence to command. + #[arg(short = 'x', value_name = "BINDING")] + key_seq_bindings: Vec, + /// List key sequence bindings. + #[arg(short = 'X')] + list_key_seq_bindings: bool, + /// Key sequence binding to readline function or command. + #[arg(positional)] + key_sequence: Option, +} + +/// Identifier for a keymap +#[derive(Clone, winnow_args::ValueEnum)] +pub(crate) enum BindKeyMap { + #[arg(name = "emacs-standard", alias = "emacs")] + EmacsStandard, + #[arg(name = "emacs-meta")] + EmacsMeta, + #[arg(name = "emacs-ctlx")] + EmacsCtlx, + #[arg(name = "vi-command", alias("vi", "vi-move"))] + ViCommand, + #[arg(name = "vi-insert")] + ViInsert, +} + +brush_winnow_args_adapter::impl_parse_args!(BindCommand); + +#[derive(Debug, thiserror::Error)] +pub(crate) enum BindError { + /// Unknown function specified. + #[error("unknown function: {0}")] + UnknownFunction(String), + + /// Unknown key binding function. + #[error("unknown key binding function: {0}")] + UnknownKeyBindingFunction(String), + + /// Unimplemented functionality. + #[error("unimplemented: {0}")] + Unimplemented(&'static str), + + /// An I/O error occurred. + #[error("I/O error occurred")] + IoError(#[from] std::io::Error), + + /// A binding parse error occurred. + #[error(transparent)] + BindingParseError(#[from] brush_parser::BindingParseError), +} + +impl brush_core::BuiltinError for BindError {} + +impl From<&BindError> for brush_core::ExecutionExitCode { + fn from(_err: &BindError) -> Self { + Self::GeneralError + } +} + +impl builtins::Builtin for BindCommand { + type Error = BindError; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if let Some(key_bindings) = context.shell.key_bindings() { + Ok(self.execute_impl(key_bindings, &context).await?) + } else { + tracing::debug!(target: trace_categories::INPUT, + "bind: key bindings not supported in this config"); + + // Silently succeed when key bindings are unavailable (e.g., in + // non-interactive mode or with an input backend that doesn't + // yet support them). + Ok(ExecutionExitCode::Success.into()) + } + } +} + +impl BindCommand { + #[allow(clippy::too_many_lines)] + async fn execute_impl( + &self, + bindings: &Arc>, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let mut bindings = bindings.lock().await; + + if self.list_funcs { + for func in interfaces::InputFunction::iter() { + writeln!(context.stdout(), "{func}")?; + } + } + + if self.list_funcs_and_bindings { + display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?; + } + + if self.list_funcs_and_bindings_reusable { + display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?; + } + + if self.list_key_seqs_that_invoke_macros { + display_macros(&*bindings, context, false /* reusable? */)?; + } + + if self.list_key_seqs_that_invoke_macros_reusable { + display_macros(&*bindings, context, true /* reusable? */)?; + } + + if self.list_vars { + let options = &context.shell.completion_config().fallback_options; + + // For now we'll just display a few items and show defaults. + writeln!( + context.stdout(), + "mark-directories is set to `{}'", + to_onoff(options.mark_directories) + )?; + writeln!( + context.stdout(), + "mark-symlinked-directories is set to `{}'", + to_onoff(options.mark_symlinked_directories) + )?; + } + + if self.list_vars_reusable { + let options = &context.shell.completion_config().fallback_options; + + // For now we'll just display a few items and show defaults. + writeln!( + context.stdout(), + "set mark-directories {}", + to_onoff(options.mark_directories) + )?; + writeln!( + context.stdout(), + "set mark-symlinked-directories {}", + to_onoff(options.mark_symlinked_directories) + )?; + } + + if let Some(func_str) = &self.query_func_bindings { + let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?; + + if !seqs.is_empty() { + writeln!( + context.stdout(), + "{func_str} can be invoked via {}.", + seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ") + )?; + } else { + writeln!(context.stdout(), "{func_str} is not bound to any keys.")?; + return Ok(ExecutionResult::general_error()); + } + } + + if let Some(func_str) = &self.remove_func_bindings { + let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?; + + for seq in found_seqs { + let _ = bindings.try_unbind(seq); + } + } + + if let Some(key_seq_str) = &self.remove_key_seq_binding { + let key_seq = parse_key_sequence(key_seq_str)?; + let _ = bindings.try_unbind(key_seq); + } + + if self.bindings_file.is_some() { + return Err(BindError::Unimplemented("bind -f")); + } + + if self.list_key_seq_bindings { + for (seq, action) in &bindings.get_current() { + let KeyAction::ShellCommand(cmd) = action else { + continue; + }; + + writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?; + } + } + + if !self.key_seq_bindings.is_empty() { + if self.keymap.as_ref().is_some_and(|k| k.is_vi()) { + // NOTE(vi): Quietly ignore since we don't support vi mode. + return Ok(ExecutionResult::success()); + } + + for key_seq_and_command in &self.key_seq_bindings { + let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?; + bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?; + } + } + + if let Some(key_sequence) = &self.key_sequence { + if self.keymap.as_ref().is_some_and(|k| k.is_vi()) { + // NOTE(vi): Quietly ignore since we don't support vi mode. + return Ok(ExecutionResult::success()); + } + + let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?; + bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?; + } + + drop(bindings); + + Ok(ExecutionResult::success()) + } +} + +fn parse_key_sequence(input: &str) -> Result { + // First trim any whitespace. + let input = input.trim(); + + let parsed = brush_parser::readline_binding::parse_key_sequence(input)?; + let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?; + + Ok(abstract_seq) +} + +fn parse_key_sequence_and_shell_command( + input: &str, +) -> Result<(interfaces::KeySequence, String), BindError> { + tracing::debug!(target: trace_categories::INPUT, + "parsing key binding entry: '{input}'" + ); + + // First trim any whitespace. + let input = input.trim(); + + // This should be something of the form: + // "KEY-SEQUENCE": SHELL-COMMAND + let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?; + let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?; + + Ok((abstract_seq, binding.shell_cmd)) +} + +#[derive(Debug)] +#[allow(dead_code, reason = "not all variants implemented yet")] +enum BindableReadlineTarget { + Function(interfaces::InputFunction), + Macro(interfaces::KeySequence), +} + +fn parse_key_sequence_and_readline_target( + input: &str, +) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> { + tracing::debug!(target: trace_categories::INPUT, + "parsing key binding entry: '{input}'" + ); + + // First trim any whitespace. + let input = input.trim(); + + // This should be of one of these forms: + // "KEY-SEQUENCE":function-name + // "KEY-SEQUENCE":readline-command + let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?; + let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?; + + match binding.target { + brush_parser::readline_binding::ReadlineTarget::Function(func_name) => { + let func = parse_readline_function(func_name.as_str())?; + Ok((abstract_seq, BindableReadlineTarget::Function(func))) + } + brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => { + let parsed_target = + brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?; + let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?; + Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target))) + } + } +} + +fn bind_key_sequence_to_shell_cmd( + bindings: &mut dyn interfaces::KeyBindings, + key_sequence: interfaces::KeySequence, + command: String, +) -> Result<(), BindError> { + tracing::debug!(target: trace_categories::INPUT, + "binding key sequence: '{key_sequence}' => command '{command}'" + ); + + bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?; + + Ok(()) +} + +fn bind_key_sequence_to_readline_target( + bindings: &mut dyn interfaces::KeyBindings, + key_sequence: interfaces::KeySequence, + target: BindableReadlineTarget, +) -> Result<(), BindError> { + match target { + BindableReadlineTarget::Function(func) => { + tracing::debug!(target: trace_categories::INPUT, + "binding key sequence: '{key_sequence}' => readline function '{func}'" + ); + + if matches!(func, interfaces::InputFunction::ViEditingMode) { + // NOTE(vi): We don't support vi mode; silently ignore. + return Ok(()); + } + + bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?; + } + BindableReadlineTarget::Macro(cmd_macro) => { + tracing::debug!(target: trace_categories::INPUT, + "binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'" + ); + + bindings.define_macro(key_sequence, cmd_macro)?; + } + } + + Ok(()) +} + +fn key_sequence_to_abstract_strokes( + seq: &brush_parser::readline_binding::KeySequence, +) -> Result { + let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?; + + // Lift from key codes to abstract keys. + let mut abstract_strokes = vec![]; + let mut key_code_bytes = vec![]; + let mut uninterpretable = false; + for mut phys_stroke in phys_strokes { + let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice()); + + // If we couldn't interpret it directly but we see it starts with the escape character, + // try to see if we can parse it as an Alt+ sequence. + if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' { + key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]); + if key.is_some() { + phys_stroke.meta = true; + } + } + + // When storing as bytes, apply control modifier to the key code. + let mut raw_bytes = phys_stroke.key_code.clone(); + if phys_stroke.control { + for byte in &mut raw_bytes { + // Control characters are computed by ANDing with 0x1F + *byte &= 0x1F; + } + } + key_code_bytes.push(raw_bytes); + + if let Some(key) = key { + abstract_strokes.push(interfaces::KeyStroke { + alt: phys_stroke.meta, + control: phys_stroke.control, + shift: false, + key, + }); + } else { + uninterpretable = true; + } + } + + if uninterpretable { + Ok(interfaces::KeySequence::Bytes(key_code_bytes)) + } else { + Ok(interfaces::KeySequence::Strokes(abstract_strokes)) + } +} + +fn parse_readline_function(func_name: &str) -> Result { + interfaces::InputFunction::from_str(func_name) + .map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned())) +} + +const fn to_onoff(value: bool) -> &'static str { + if value { "on" } else { "off" } +} + +fn display_funcs_and_bindings( + bindings: &dyn interfaces::KeyBindings, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + reusable: bool, +) -> Result<(), BindError> { + let mut sequences_by_func: HashMap> = HashMap::new(); + for (seq, action) in &bindings.get_current() { + let KeyAction::DoInputFunction(func) = action else { + continue; + }; + + sequences_by_func + .entry(func.clone()) + .or_default() + .push(seq.clone()); + } + + let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string()); + + for func in sorted_funcs { + match sequences_by_func.get(&func) { + Some(seqs) if reusable => { + for seq in seqs { + writeln!(context.stdout(), "\"{seq}\": {func}")?; + } + } + Some(seqs) => { + writeln!( + context.stdout(), + "{func} can be found on {}.", + seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ") + )?; + } + None if reusable => { + writeln!(context.stdout(), "# {func} (not bound)")?; + } + None => { + writeln!(context.stdout(), "{func} is not bound to any keys")?; + } + } + } + + Ok(()) +} + +fn display_macros( + bindings: &dyn interfaces::KeyBindings, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + reusable: bool, +) -> Result<(), BindError> { + for (left, right) in bindings.get_macros() { + if reusable { + writeln!(context.stdout(), "\"{left}\": \"{right}\"")?; + } else { + writeln!(context.stdout(), "{left} outputs {right}")?; + } + } + + Ok(()) +} + +fn find_key_seqs_bound_to_function( + bindings: &dyn interfaces::KeyBindings, + func_str: &str, +) -> Result, BindError> { + let Ok(func_to_find) = InputFunction::from_str(func_str) else { + return Err(BindError::UnknownFunction(func_str.to_owned())); + }; + + let mut found_seqs = vec![]; + + for (seq, action) in &bindings.get_current() { + if let KeyAction::DoInputFunction(func) = action + && *func == func_to_find + { + found_seqs.push(seq.clone()); + } + } + + Ok(found_seqs) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::{assert_eq, assert_matches}; + + #[test] + fn parse_example_key_sequence_and_readline_func() { + let (key_seq, target) = + parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap(); + + assert_eq!( + key_seq, + interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke { + alt: false, + control: true, + shift: false, + key: interfaces::Key::Character('a'), + }]) + ); + + assert_matches!( + target, + BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine) + ); + } + + #[test] + fn parse_escape_char_key_binding() { + let (key_seq, target) = + parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap(); + + assert_eq!( + key_seq, + interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke { + alt: true, + control: false, + shift: false, + key: interfaces::Key::Character('r'), + }]) + ); + + assert_matches!( + target, + BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars) + ); + } +} diff --git a/userland/brush/source/brush-builtins/src/break_.rs b/userland/brush/source/brush-builtins/src/break_.rs new file mode 100644 index 0000000..d6d6547 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/break_.rs @@ -0,0 +1,43 @@ +use std::io::Write; + +use brush_core::{ExecutionControlFlow, ExecutionResult, builtins}; + +/// Breaks out of a control-flow loop. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct BreakCommand { + /// If specified, indicates which nested loop to break out of. + #[arg(positional, default = "1", allow_negative_numbers)] + which_loop: i8, +} + +brush_winnow_args_adapter::impl_parse_args!(BreakCommand); + +impl builtins::Builtin for BreakCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // A count that isn't positive is reported. + if self.which_loop <= 0 { + writeln!( + context.stderr(), + "{}: {}: loop count out of range", + context.command_name, + self.which_loop + )?; + return Ok(ExecutionResult::general_error()); + } + + let mut result = ExecutionResult::success(); + + result.next_control_flow = ExecutionControlFlow::BreakLoop { + #[expect(clippy::cast_sign_loss)] + levels: (self.which_loop - 1) as usize, + }; + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/builder.rs b/userland/brush/source/brush-builtins/src/builder.rs new file mode 100644 index 0000000..291ecc6 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/builder.rs @@ -0,0 +1,20 @@ +use crate::BuiltinSet; + +/// Extension trait that simplifies adding default builtins to a shell builder. +pub trait ShellBuilderExt { + /// Add default builtins to the shell being built. + /// + /// # Arguments + /// + /// * `set` - The well-known set of built-ins to add. + #[must_use] + fn default_builtins(self, set: BuiltinSet) -> Self; +} + +impl ShellBuilderExt + for brush_core::ShellBuilder +{ + fn default_builtins(self, set: BuiltinSet) -> Self { + self.builtins(crate::default_builtins(set)) + } +} diff --git a/userland/brush/source/brush-builtins/src/builtin_.rs b/userland/brush/source/brush-builtins/src/builtin_.rs new file mode 100644 index 0000000..f83fc2b --- /dev/null +++ b/userland/brush/source/brush-builtins/src/builtin_.rs @@ -0,0 +1,67 @@ +use brush_core::{ExecutionResult, builtins}; + +/// Directly invokes a built-in, without going through typical search order. +pub(crate) struct BuiltinCommand { + args: Vec, +} + +impl builtins::ParseArgs for BuiltinCommand { + const DECLARATION_ROLE: builtins::DeclarationRole = builtins::DeclarationRole::Utility; + + fn content( + name: &str, + content_type: builtins::ContentType, + _options: &builtins::ContentOptions, + ) -> Result { + match content_type { + builtins::ContentType::DetailedHelp => Ok( + "Execute SHELL-BUILTIN with arguments ARGs without performing command lookup.\n" + .into(), + ), + builtins::ContentType::ShortUsage => { + Ok(format!("{name}: {name} [shell-builtin [arg ...]]\n")) + } + builtins::ContentType::ShortDescription => { + Ok(format!("{name} - Execute shell builtins\n")) + } + builtins::ContentType::ManPage => { + brush_core::error::unimp("man page not yet implemented") + } + } + } + + fn parse(invocation: builtins::Invocation<'_>) -> Result { + Ok(Self { + args: invocation.into_args(), + }) + } +} + +impl builtins::Builtin for BuiltinCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let args = self.args; + if args.is_empty() { + return Ok(ExecutionResult::success()); + } + + let builtin_name = args[0].to_string(); + + if let Some(builtin) = context + .shell + .builtins() + .get(&builtin_name) + .filter(|builtin| builtin.is_enabled()) + .cloned() + { + context.command_name = builtin_name; + builtin.execute(context, args).await + } else { + Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into()) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/caller.rs b/userland/brush/source/brush-builtins/src/caller.rs new file mode 100644 index 0000000..207123f --- /dev/null +++ b/userland/brush/source/brush-builtins/src/caller.rs @@ -0,0 +1,58 @@ +use brush_core::{ExecutionResult, builtins, callstack}; +use std::io::Write; + +/// Return the context of the current subroutine call. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct CallerCommand { + /// The number of call frames to go back. + #[arg(positional)] + expr: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(CallerCommand); + +impl builtins::Builtin for CallerCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let stack = context.shell.call_stack(); + + // See how far back we need to look. Frame N represents the Nth caller + // (e.g., 0 = immediate caller, 1 = caller's caller, etc.). + let expr = self.expr.unwrap_or(0); + + // Get all frames into a vector we can easily index into. + let frames: Vec<_> = stack + .iter() + .filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script()) + .collect(); + + // Look for the last-known location in the parent of frame N. + let Some(calling_frame) = frames.get(expr + 1) else { + return Ok(ExecutionResult::general_error()); + }; + + let line = calling_frame.current_line().unwrap_or(1); + let filename = &calling_frame.source_info.source; + + // When the expr is provided, we display "LINE FUNCTION_NAME FILENAME" + // When the expr is omitted, we only display "LINE FILENAME" + if self.expr.is_some() { + let function_name = match &calling_frame.frame_type { + callstack::FrameType::Function(func_call) => func_call.name(), + callstack::FrameType::Script(..) => "source".into(), + _ => "".into(), + }; + + writeln!(context.stdout(), "{line} {function_name} {filename}")?; + } else { + writeln!(context.stdout(), "{line} {filename}")?; + } + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/cd.rs b/userland/brush/source/brush-builtins/src/cd.rs new file mode 100644 index 0000000..48241c0 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/cd.rs @@ -0,0 +1,99 @@ +use std::io::Write; +use std::path::PathBuf; + +use brush_core::{ExecutionResult, builtins, error}; + +/// Change the current shell working directory. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct CdCommand { + /// Force following symlinks. + #[arg(short = 'L', overrides = "-P")] + force_follow_symlinks: bool, + + /// Use physical dir structure without following symlinks. + #[arg(short = 'P', overrides = "-L")] + use_physical_dir: bool, + + /// Exit with non zero exit status if current working directory resolution fails. + #[arg(short = 'e')] + exit_on_failed_cwd_resolution: bool, + + /// Show file with extended attributes as a dir with extended + /// attributes. + #[arg(short = '@')] + file_with_xattr_as_dir: bool, + + /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is + /// converted to $OLDPWD. + #[arg(positional)] + target_dir: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(CdCommand); + +impl builtins::Builtin for CdCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // TODO(cd): implement 'cd -@' + if self.file_with_xattr_as_dir { + return error::unimp("cd -@"); + } + + let mut should_print = false; + let mut target_dir = if let Some(target_dir) = &self.target_dir { + // `cd -', equivalent to `cd $OLDPWD' + if target_dir.as_os_str() == "-" { + should_print = true; + if let Some(oldpwd) = context.shell.env_str("OLDPWD") { + PathBuf::from(oldpwd.to_string()) + } else { + writeln!(context.stderr(), "OLDPWD not set")?; + return Ok(ExecutionResult::general_error()); + } + } else { + // TODO(cd): remove clone, and use temporary lifetime extension after rust 1.75 + target_dir.clone() + } + // `cd' without arguments is equivalent to `cd $HOME' + } else { + if let Some(home_var) = context.shell.env_str("HOME") { + PathBuf::from(home_var.to_string()) + } else { + writeln!(context.stderr(), "HOME not set")?; + return Ok(ExecutionResult::general_error()); + } + }; + + if self.use_physical_dir + || context + .shell + .options() + .do_not_resolve_symlinks_when_changing_dir + { + // -e is only relevant in physical mode. + if self.exit_on_failed_cwd_resolution { + return error::unimp("cd -e"); + } + + target_dir = context.shell.absolute_path(target_dir).canonicalize()?; + } + + context.shell.set_working_dir(&target_dir)?; + + // Bash compatibility + // https://www.gnu.org/software/bash/manual/bash.html#index-cd + // If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and + // the directory change is successful, the absolute pathname of the new working + // directory is written to the standard output. + if should_print { + writeln!(context.stdout(), "{}", target_dir.display())?; + } + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/colon.rs b/userland/brush/source/brush-builtins/src/colon.rs new file mode 100644 index 0000000..d35ee51 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/colon.rs @@ -0,0 +1,36 @@ +use brush_core::{ExecutionResult, builtins, error}; + +/// No-op command. +pub(crate) struct ColonCommand {} + +impl builtins::ParseArgs for ColonCommand { + fn content( + name: &str, + content_type: builtins::ContentType, + _options: &builtins::ContentOptions, + ) -> Result { + match content_type { + builtins::ContentType::DetailedHelp => { + Ok("Null command; always returns success.".into()) + } + builtins::ContentType::ShortUsage => Ok(format!("{name}: {name}\n")), + builtins::ContentType::ShortDescription => Ok(format!("{name} - Null command\n")), + builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + } + } + + fn parse(_invocation: builtins::Invocation<'_>) -> Result { + Ok(Self {}) + } +} + +impl builtins::Builtin for ColonCommand { + type Error = brush_core::Error; + + async fn execute( + self, + _context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/command.rs b/userland/brush/source/brush-builtins/src/command.rs new file mode 100644 index 0000000..42c09f8 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/command.rs @@ -0,0 +1,155 @@ +use std::io::Write; +use std::path::PathBuf; + +use brush_core::{ExecutionResult, builtins, commands, sys}; + +use crate::lookup::{self, Resolved}; +use crate::write_alias_definition; + +/// Directly invokes an external command, without going through typical search order. +#[derive(Default, winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct CommandCommand { + /// Use default PATH value. + #[arg(short = 'p')] + pub(crate) use_default_path: bool, + + /// Display a more verbose description of the command. + #[arg(short = 'V', overrides = "-v")] + pub(crate) print_verbose_description: bool, + + /// Display a short description of the command. + #[arg(short = 'v', overrides = "-V")] + pub(crate) print_description: bool, + + /// Command and arguments. + #[arg(positional, double_dash = "automatic")] + pub(crate) command_and_args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(CommandCommand); + +impl CommandCommand { + fn command_word(&self) -> Option<&str> { + self.command_and_args.first().map(|s| s.as_str()) + } + + fn path_dirs(&self) -> Option> { + self.use_default_path + .then(sys::fs::get_default_standard_utils_paths) + } + + /// Describes every name given, as `-v`/`-V` do; succeeds if any name was found. + fn describe_names( + &self, + context: &brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // With no names to look up there is nothing to fail to find, so this still succeeds. + if self.command_and_args.is_empty() { + return Ok(ExecutionResult::success()); + } + + let options = lookup::Options { + path_dirs: self.path_dirs(), + ..Default::default() + }; + + let mut any_found = false; + for name in &self.command_and_args { + let Some(mut found) = lookup::resolve(context.shell, name, &options) + .into_iter() + .next() + else { + if self.print_verbose_description { + writeln!(context.stderr(), "command: {name}: not found")?; + } + continue; + }; + + any_found = true; + if self.print_description { + // Display in a form that could be reused as shell input. + match &found { + Resolved::Alias(target) => { + write_alias_definition(context.stdout(), name, target)?; + } + Resolved::Keyword | Resolved::Function(_) | Resolved::Builtin => { + writeln!(context.stdout(), "{name}")?; + } + Resolved::File { path, .. } => { + writeln!(context.stdout(), "{}", path.to_string_lossy())?; + } + } + } else { + // A command found by searching PATH is reported with an absolute path, even + // when the PATH entry it came from was relative. Explicit paths and hashed + // entries are reported exactly as they were given. Like bash, this only + // prefixes the working directory; it doesn't otherwise normalize the path, + // except to drop the leading `./` a `.` entry in PATH contributes. + if let Resolved::File { + path, + hashed: false, + } = &mut found + && !sys::fs::contains_path_separator(name) + { + let relative = path.strip_prefix(".").unwrap_or(&*path); + *path = context.shell.absolute_path(relative); + } + + lookup::describe(context.stdout(), name, &found)?; + } + } + + if any_found { + Ok(ExecutionResult::success()) + } else { + Ok(ExecutionResult::general_error()) + } + } + + async fn execute_command( + &self, + mut context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + command_name: &str, + ) -> Result { + command_name.clone_into(&mut context.command_name); + let command_and_args = self + .command_and_args + .iter() + .map(brush_core::CommandArg::from); + + let mut cmd = commands::SimpleCommand::new( + commands::ShellForCommand::ParentShell(context.shell), + context.params, + context.command_name, + command_and_args, + ); + cmd.use_functions = false; + cmd.path_dirs = self.path_dirs(); + + let spawn_result = cmd.execute().await?; + let wait_result = spawn_result.wait().await?; + + Ok(wait_result.into()) + } +} + +impl builtins::Builtin for CommandCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.print_description || self.print_verbose_description { + return self.describe_names(&context); + } + + // Silently exit if no command was provided. + let Some(command_name) = self.command_word() else { + return Ok(ExecutionResult::success()); + }; + + self.execute_command(context, command_name).await + } +} diff --git a/userland/brush/source/brush-builtins/src/complete.rs b/userland/brush/source/brush-builtins/src/complete.rs new file mode 100644 index 0000000..2a04650 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/complete.rs @@ -0,0 +1,788 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::io::Write; +use strum::IntoEnumIterator; + +use brush_core::completion::{self, CompleteAction, CompleteOption, Spec, SpecName, SpecialSpec}; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, escape}; + +/// A completion option or action name (`-o nospace`, `-A arrayvar`), parsed by +/// its strum name. `CompleteAction` and `CompleteOption` live in brush-core, +/// which does not depend on winnow-args, so they cannot implement `FromArg`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Choice(T); + +impl winnow_args::FromArg for Choice +where + T: std::str::FromStr + strum::VariantNames + strum::EnumMessage, +{ + const CHOICES: &'static [&'static str] = T::VARIANTS; + const DESCRIBES_CHOICES: bool = true; + + /// The text written in `choices(…)`, which the pages show too, else the + /// variant's doc comment in brush-core, without its final period. + fn choice_help(index: usize, default: &'static str) -> &'static str { + if !default.is_empty() { + return default; + } + let variant = T::VARIANTS + .get(index) + .and_then(|name| name.parse::().ok()); + let doc = variant.and_then(|variant| variant.get_documentation()); + doc.map_or("", |doc| doc.trim_end().trim_end_matches('.')) + } + + fn from_arg(value: &winnow_args::BStr) -> Result { + std::str::from_utf8(value) + .ok() + .and_then(|name| name.parse().ok()) + .map(Choice) + .ok_or_else(|| { + Box::new(winnow_args::ChoiceError { + choices: T::VARIANTS, + }) as winnow_args::error::BoxError + }) + } +} + +#[derive(winnow_args::Args)] +struct CommonCompleteCommandArgs { + /// Options governing the behavior of completions. + // The values are stated for the generated documentation, which cannot + // read them from the type; the tests check them against it. + #[arg( + short = 'o', + choices( + "bashdefault" = "Perform rest of default completions if no completions are generated", + "default" = "Use default filename completion if no completions are generated", + "dirnames" = "Treat completions as directory names", + "filenames" = "Treat completions as filenames", + "fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect", + "noquote" = "Suppress default auto-quotation of completions", + "nosort" = "Do not sort completions", + "nospace" = "Do not append a trailing space to completions at the end of the input line", + "plusdirs" = "Also generate directory completions" + ) + )] + options: Vec>, + + /// Actions to apply to generate completions. + #[arg( + short = 'A', + choices( + "alias" = "Complete with valid aliases", + "arrayvar" = "Complete with names of array shell variables", + "binding" = "Complete with names of key bindings", + "builtin" = "Complete with names of shell builtins", + "command" = "Complete with names of executable commands", + "directory" = "Complete with directory names", + "disabled" = "Complete with names of disabled shell builtins", + "enabled" = "Complete with names of enabled shell builtins", + "export" = "Complete with names of exported shell variables", + "file" = "Complete with filenames", + "function" = "Complete with names of shell functions", + "group" = "Complete with valid user groups", + "helptopic" = "Complete with names of valid shell help topics", + "hostname" = "Complete with the system's hostname(s)", + "job" = "Complete with the command names of shell-managed jobs", + "keyword" = "Complete with valid shell keywords", + "running" = "Complete with the command names of running shell-managed jobs", + "service" = "Complete with names of system services", + "setopt" = "Complete with the names of options settable via set -o", + "shopt" = "Complete with the names of options settable via shopt", + "signal" = "Complete with the names of trappable signals", + "stopped" = "Complete with the command names of stopped shell-managed jobs", + "user" = "Complete with valid usernames", + "variable" = "Complete with names of shell variables" + ) + )] + actions: Vec>, + + /// File glob pattern to be expanded to generate completions. + #[arg(short = 'G', allow_hyphen_values, value_name = "GLOB")] + glob_pattern: Option, + + /// List of words that will be considered as completions. + #[arg(short = 'W', allow_hyphen_values)] + word_list: Option, + + /// Name of a shell function to invoke to generate completions. + #[arg(short = 'F', allow_hyphen_values, value_name = "FUNC_NAME")] + function_name: Option, + + /// Command to execute to generate completions. + #[arg(short = 'C', allow_hyphen_values)] + command: Option, + + /// Pattern used as filter for completions. + #[arg(short = 'X', allow_hyphen_values, value_name = "PATTERN")] + filter_pattern: Option, + + /// Prefix pattern used as filter for completions. + #[arg(short = 'P', allow_hyphen_values)] + prefix: Option, + + /// Suffix pattern used as filter for completions. + #[arg(short = 'S', allow_hyphen_values)] + suffix: Option, + + /// Complete with valid aliases. + #[arg(short = 'a')] + action_alias: bool, + + /// Complete with names of shell builtins. + #[arg(short = 'b')] + action_builtin: bool, + + /// Complete with names of executable commands. + #[arg(short = 'c')] + action_command: bool, + + /// Complete with directory names. + #[arg(short = 'd')] + action_directory: bool, + + /// Complete with names of exported shell variables. + #[arg(short = 'e')] + action_exported: bool, + + /// Complete with filenames. + #[arg(short = 'f')] + action_file: bool, + + /// Complete with valid user groups. + #[arg(short = 'g')] + action_group: bool, + + /// Complete with the command names of shell-managed jobs. + #[arg(short = 'j')] + action_job: bool, + + /// Complete with valid shell keywords. + #[arg(short = 'k')] + action_keyword: bool, + + /// Complete with names of system services. + #[arg(short = 's')] + action_service: bool, + + /// Complete with valid usernames. + #[arg(short = 'u')] + action_user: bool, + + /// Complete with names of shell variables. + #[arg(short = 'v')] + action_variable: bool, +} + +/// The actions with flags of their own in `complete` and `compgen`, and those flags (e.g. +/// `a`, for `-a`, which is short for `-A alias`), in the order bash shows them. +const ACTION_FLAGS: [(char, CompleteAction); 12] = [ + ('a', CompleteAction::Alias), + ('b', CompleteAction::Builtin), + ('c', CompleteAction::Command), + ('d', CompleteAction::Directory), + ('e', CompleteAction::Export), + ('f', CompleteAction::File), + ('g', CompleteAction::Group), + ('j', CompleteAction::Job), + ('k', CompleteAction::Keyword), + ('s', CompleteAction::Service), + ('u', CompleteAction::User), + ('v', CompleteAction::Variable), +]; + +impl CommonCompleteCommandArgs { + fn create_spec(&self) -> completion::Spec { + completion::Spec { + options: self.options.iter().map(|o| o.0).collect(), + actions: self.resolve_actions(), + glob_pattern: self.glob_pattern.clone(), + word_list: self.word_list.clone(), + function_name: self.function_name.clone(), + command: self.command.clone(), + filter_pattern: self.filter_pattern.clone(), + prefix: self.prefix.clone(), + suffix: self.suffix.clone(), + } + } + + /// Returns the actions selected, like bash, each once, in the fixed order bash runs them + /// in (that of [`CompleteAction`]'s variants), however they were given. + fn resolve_actions(&self) -> Vec { + // In the order of `ACTION_FLAGS`. + let flags = [ + self.action_alias, + self.action_builtin, + self.action_command, + self.action_directory, + self.action_exported, + self.action_file, + self.action_group, + self.action_job, + self.action_keyword, + self.action_service, + self.action_user, + self.action_variable, + ]; + let selected: Vec = self + .actions + .iter() + .map(|a| a.0) + .chain( + ACTION_FLAGS + .iter() + .zip(flags) + .filter_map(|((_, action), given)| given.then_some(*action)), + ) + .collect(); + CompleteAction::iter() + .filter(|action| selected.contains(action)) + .collect() + } +} + +/// Configure programmable command completion. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct CompleteCommand { + /// Display registered completion settings. + #[arg(short = 'p')] + print: bool, + + /// Remove the completion settings associated with the given command. + #[arg(short = 'r')] + remove: bool, + + /// Apply these settings to the default completion scenario. + #[arg(short = 'D')] + use_as_default: bool, + + /// Apply these settings to completion of empty lines. + #[arg(short = 'E')] + use_for_empty_line: bool, + + /// Apply these settings to completion of the initial word of the input line. + #[arg(short = 'I')] + use_for_initial_word: bool, + + #[arg(flatten)] + common_args: CommonCompleteCommandArgs, + + #[arg(positional)] + names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(CompleteCommand); + +/// Returns the flag that selects a special completion spec in `complete` and `compopt`. +const fn special_spec_flag(special: SpecialSpec) -> &'static str { + match special { + SpecialSpec::Default => "-D", + SpecialSpec::EmptyLine => "-E", + SpecialSpec::InitialWord => "-I", + } +} + +/// Returns the operand that names the spec `name` in a `complete` or `compopt` command that +/// `complete -p` or `compopt` prints: a special spec's flag, or the command's name, quoted +/// if needed. +fn spec_name_operand(name: SpecName<'_>) -> Cow<'_, str> { + match name { + SpecName::Special(special) => special_spec_flag(special).into(), + SpecName::Command(command) => { + escape::quote_if_needed(command, escape::QuoteMode::SingleQuote) + } + } +} + +impl builtins::Builtin for CompleteCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Like bash, -D, -E, or -I name a special spec in place of any names given; they + // take precedence in that order. + let special = [ + (self.use_as_default, SpecialSpec::Default), + (self.use_for_empty_line, SpecialSpec::EmptyLine), + (self.use_for_initial_word, SpecialSpec::InitialWord), + ] + .into_iter() + .find_map(|(selected, special)| selected.then_some(special)); + let names: Vec> = match special { + Some(special) => vec![SpecName::Special(special)], + None => self + .names + .iter() + .map(|name| SpecName::parse(name)) + .collect(), + }; + + // With no spec named, list them all (as `complete` with no options does too), or + // with `-r`, remove them all. + if names.is_empty() { + if self.remove && !self.print { + context.shell.completion_config_mut().clear(); + } else { + // Sort, so the listing is stable; the special specs come last. + let mut specs: Vec<_> = context.shell.completion_config().iter().collect(); + specs.sort_by_key(|(name, _)| *name); + for (name, spec) in specs { + Self::display_spec(&context, name, spec)?; + } + } + return Ok(ExecutionResult::success()); + } + + let mut result = ExecutionResult::success(); + for name in names { + if !self.try_process_for_command(&mut context, name)? { + result = ExecutionResult::general_error(); + } + } + + Ok(result) + } +} + +impl CompleteCommand { + fn try_display_spec_for_command( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + name: SpecName<'_>, + ) -> Result { + if let Some(spec) = context.shell.completion_config().get(name) { + Self::display_spec(context, name, spec)?; + Ok(true) + } else { + writeln!( + context.stderr(), + "complete: {}: no completion specification", + name.as_str() + )?; + Ok(false) + } + } + + /// Displays `spec`, registered under `name`, as the `complete` command that recreates + /// it, formatted as bash does. + fn display_spec( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + name: SpecName<'_>, + spec: &Spec, + ) -> Result<(), brush_core::Error> { + let mut s = String::from("complete"); + + // Options, in the order bash shows them. + let options = &spec.options; + for option in CompleteOption::iter().filter(|option| options.get(*option)) { + write!(s, " -o {}", <&str>::from(option))?; + } + + // Like bash, show each action once: those with their own flag first, then the rest + // with `-A`, each in the order bash lists them. + for (flag, action) in &ACTION_FLAGS { + if spec.actions.contains(action) { + write!(s, " -{flag}")?; + } + } + for action in CompleteAction::iter() { + if spec.actions.contains(&action) && !ACTION_FLAGS.iter().any(|(_, a)| *a == action) { + write!(s, " -A {}", <&str>::from(action))?; + } + } + + for (flag, arg) in [ + ("-G", spec.glob_pattern.as_ref()), + ("-W", spec.word_list.as_ref()), + ("-P", spec.prefix.as_ref()), + ("-S", spec.suffix.as_ref()), + ("-X", spec.filter_pattern.as_ref()), + ("-C", spec.command.as_ref()), + ] { + if let Some(arg) = arg { + let arg = escape::force_quote(arg, escape::QuoteMode::SingleQuote); + write!(s, " {flag} {arg}")?; + } + } + if let Some(function_name) = &spec.function_name { + let function_name = + escape::quote_if_needed(function_name, escape::QuoteMode::SingleQuote); + write!(s, " -F {function_name}")?; + } + + // Like bash, the spec's name comes last. + writeln!(context.stdout(), "{s} {}", spec_name_operand(name))?; + + Ok(()) + } + + fn try_process_for_command( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + name: SpecName<'_>, + ) -> Result { + if self.print { + return Self::try_display_spec_for_command(context, name); + } else if self.remove { + let mut result = context.shell.completion_config_mut().remove(name); + + if !result { + if context.shell.options().interactive { + writeln!(context.stderr(), "complete: {}: not found", name.as_str())?; + } else { + // For some reason, this is not supposed to be treated as a failure + // in non-interactive execution. + result = true; + } + } + + return Ok(result); + } + + let config = self.common_args.create_spec(); + + context.shell.completion_config_mut().set(name, config); + + Ok(true) + } +} + +/// Generate command completions. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct CompGenCommand { + #[arg(flatten)] + common_args: CommonCompleteCommandArgs, + + // N.B. The word can only start with a hyphen if it's after a --. + #[arg(positional)] + word: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(CompGenCommand); + +impl builtins::Builtin for CompGenCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let spec = self.common_args.create_spec(); + let word = self.word.as_deref().unwrap_or_default(); + let candidates = spec.generate(context.shell, word).await?; + + // We are expected to return 1 if there are no candidates, even if no errors + // occurred along the way. + if candidates.is_empty() { + return Ok(ExecutionResult::general_error()); + } + + for candidate in candidates { + writeln!(context.stdout(), "{candidate}")?; + } + + Ok(ExecutionResult::success()) + } +} + +/// Set programmable command completion options. +#[derive(winnow_args::Args)] +#[arg( + plus_options, + disable_help_short, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct CompOptCommand { + /// Update the default completion settings. + #[arg(short = 'D')] + update_default: bool, + + /// Update the completion settings for empty lines. + #[arg(short = 'E')] + update_empty: bool, + + /// Update the completion settings for the initial word of the input line. + #[arg(short = 'I')] + update_initial_word: bool, + + /// Enable the specified option for selected completion scenarios. + #[arg( + short = 'o', + value_name = "OPT", + choices( + "bashdefault" = "Perform rest of default completions if no completions are generated", + "default" = "Use default filename completion if no completions are generated", + "dirnames" = "Treat completions as directory names", + "filenames" = "Treat completions as filenames", + "fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect", + "noquote" = "Suppress default auto-quotation of completions", + "nosort" = "Do not sort completions", + "nospace" = "Do not append a trailing space to completions at the end of the input line", + "plusdirs" = "Also generate directory completions" + ) + )] + enabled_options: Vec>, + + /// Disable the specified option for selected completion scenarios. + #[arg( + plus = 'o', + value_name = "OPT", + choices( + "bashdefault" = "Perform rest of default completions if no completions are generated", + "default" = "Use default filename completion if no completions are generated", + "dirnames" = "Treat completions as directory names", + "filenames" = "Treat completions as filenames", + "fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect", + "noquote" = "Suppress default auto-quotation of completions", + "nosort" = "Do not sort completions", + "nospace" = "Do not append a trailing space to completions at the end of the input line", + "plusdirs" = "Also generate directory completions" + ) + )] + disabled_options: Vec>, + + /// If specified, scopes updates to completions of the named commands. + #[arg(positional)] + names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(CompOptCommand); + +impl builtins::Builtin for CompOptCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Like bash, an option given with both `-o` and `+o` is disabled, whatever their + // order. + let mut options = + HashMap::with_capacity(self.disabled_options.len() + self.enabled_options.len()); + for option in &self.enabled_options { + options.insert(option.0, true); + } + for option in &self.disabled_options { + options.insert(option.0, false); + } + + if !self.names.is_empty() + && (self.update_default || self.update_empty || self.update_initial_word) + { + writeln!( + context.stderr(), + "compopt: cannot specify names with -D, -E, or -I" + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + // -D, -E, and -I select a special spec, which, like bash, messages name by bash's name + // for it. + let special = [ + (self.update_default, SpecialSpec::Default), + (self.update_empty, SpecialSpec::EmptyLine), + (self.update_initial_word, SpecialSpec::InitialWord), + ] + .into_iter() + .find_map(|(selected, special)| selected.then_some(special)); + let names: Vec> = match special { + Some(special) => vec![SpecName::Special(special)], + None => self + .names + .iter() + .map(|name| SpecName::parse(name)) + .collect(), + }; + + if !names.is_empty() { + // Like bash, a spec that doesn't exist is an error, rather than being created. + // With no options to set, each spec's options are shown instead. + let mut result = ExecutionResult::success(); + for name in names { + let found = if options.is_empty() { + match context.shell.completion_config().get(name) { + Some(spec) => { + Self::display_options(&context, name, &spec.options)?; + true + } + None => false, + } + } else if let Some(spec) = context.shell.completion_config_mut().get_mut(name) { + Self::set_options_for_spec(spec, &options); + true + } else { + false + }; + if !found { + writeln!( + context.stderr(), + "compopt: {}: no completion specification", + name.as_str() + )?; + result = ExecutionResult::general_error(); + } + } + return Ok(result); + } + + // With no names, apply to the completion in progress, if there is one, or with no + // options to set, show its options. + if options.is_empty() { + if let Some(in_progress) = context.shell.in_progress_completion() { + Self::display_options(&context, in_progress.spec_name(), in_progress.options())?; + return Ok(ExecutionResult::success()); + } + } else if let Some(in_progress_options) = context.shell.in_progress_completion_options_mut() + { + Self::set_options(in_progress_options, &options); + return Ok(ExecutionResult::success()); + } + + writeln!( + context.stderr(), + "compopt: not currently executing completion function" + )?; + Ok(ExecutionResult::general_error()) + } +} + +impl CompOptCommand { + /// Displays `options`, those of the spec `name`, as the `compopt` command that sets + /// them: each option enabled (`-o`) or disabled (`+o`), formatted as bash does. + fn display_options( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + name: SpecName<'_>, + options: &completion::GenerationOptions, + ) -> Result<(), brush_core::Error> { + let mut s = String::from("compopt"); + for option in CompleteOption::iter() { + let sign = if options.get(option) { '-' } else { '+' }; + write!(s, " {sign}o {}", <&str>::from(option))?; + } + writeln!(context.stdout(), "{s} {}", spec_name_operand(name))?; + + Ok(()) + } + + fn set_options_for_spec<'a, I>(spec: &mut Spec, options: I) + where + I: IntoIterator, + { + Self::set_options(&mut spec.options, options); + } + + fn set_options<'a, I>(target_options: &mut completion::GenerationOptions, options: I) + where + I: IntoIterator, + { + for (option, value) in options { + target_options.set(*option, *value); + } + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use brush_core::builtins::{Invocation, ParseArgs, UsageError}; + use strum::VariantNames; + + fn parse(name: &str, words: [&str; 2]) -> Result { + T::parse(Invocation::new(name, words)) + } + + #[test] + fn every_action_name_parses_to_its_action() -> Result<()> { + for name in CompleteAction::VARIANTS { + let args = parse::("complete", ["-A", name])?; + let parsed: Vec<_> = args + .common_args + .actions + .iter() + .map(|a| <&str>::from(a.0)) + .collect(); + assert_eq!(parsed, [*name]); + } + Ok(()) + } + + /// What `choices(…)` says each value means, which the pages show, is + /// what brush-core's doc comment says, which help would show without it. + #[test] + fn each_choice_says_what_its_docs_say() { + use winnow_args::FromArg; + let items = ::HELP.items.iter(); + let items = items.chain(::HELP.items); + let mut checked = 0; + for item in items.filter(|item| !item.choices.is_empty()) { + // Every value brush-core has is stated, in its order. + let actions = item.choices[0] == "alias"; + let variants = if actions { + CompleteAction::VARIANTS + } else { + CompleteOption::VARIANTS + }; + assert_eq!(item.choices, variants); + // Without a function, each says nothing, and the docs disagree. + let help = item.choice_help.unwrap_or(|_| ""); + for (index, choice) in item.choices.iter().enumerate() { + let from_docs = if actions { + Choice::::choice_help(index, "") + } else { + Choice::::choice_help(index, "") + }; + let from_docs = from_docs.split_whitespace().collect::>().join(" "); + assert_eq!(help(index), from_docs, "{choice}"); + } + checked += 1; + } + assert_eq!(checked, 4); + } + + #[test] + fn every_option_name_parses_to_its_option() -> Result<()> { + for name in CompleteOption::VARIANTS { + let option: CompleteOption = name.parse()?; + let args = parse::("complete", ["-o", name])?; + assert_eq!(args.common_args.options, [Choice(option)]); + + let args = parse::("compopt", ["+o", name])?; + assert_eq!(args.disabled_options, [Choice(option)]); + } + Ok(()) + } + + #[test] + fn invalid_names_list_the_accepted_ones() { + let Err(err) = parse::("complete", ["-A", "bogus"]) else { + unreachable!("`bogus` is not an action name"); + }; + let message = err.to_string(); + assert!( + message.contains("bogus") && message.contains("arrayvar"), + "{message}" + ); + } + + #[test] + fn help_lists_accepted_names() { + let help = ::content( + "complete", + builtins::ContentType::DetailedHelp, + &builtins::ContentOptions::default(), + ) + .unwrap_or_default(); + assert!( + help.contains("arrayvar") && help.contains("nospace"), + "{help}" + ); + } +} diff --git a/userland/brush/source/brush-builtins/src/continue_.rs b/userland/brush/source/brush-builtins/src/continue_.rs new file mode 100644 index 0000000..94d9ac8 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/continue_.rs @@ -0,0 +1,43 @@ +use std::io::Write; + +use brush_core::{ExecutionControlFlow, ExecutionResult, builtins}; + +/// Continue to the next iteration of a control-flow loop. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ContinueCommand { + /// If specified, indicates which nested loop to continue to the next iteration of. + #[arg(positional, default = "1", allow_negative_numbers)] + which_loop: i8, +} + +brush_winnow_args_adapter::impl_parse_args!(ContinueCommand); + +impl builtins::Builtin for ContinueCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // A count that isn't positive is reported. + if self.which_loop <= 0 { + writeln!( + context.stderr(), + "{}: {}: loop count out of range", + context.command_name, + self.which_loop + )?; + return Ok(ExecutionResult::general_error()); + } + + let mut result = ExecutionResult::success(); + + result.next_control_flow = ExecutionControlFlow::ContinueLoop { + #[expect(clippy::cast_sign_loss)] + levels: (self.which_loop - 1) as usize, + }; + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/declare.rs b/userland/brush/source/brush-builtins/src/declare.rs new file mode 100644 index 0000000..184f2b4 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/declare.rs @@ -0,0 +1,713 @@ +use itertools::Itertools; +use std::{io::Write, sync::LazyLock}; + +use brush_core::{ + ErrorKind, ExecutionResult, builtins, + env::{self, EnvironmentLookup, EnvironmentScope}, + parser::ast, + variables::{ + self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, + ShellVariableUpdateTransform, + }, +}; + +/// Display or update variables and their attributes. +#[derive(Default, winnow_args::Args)] +#[arg( + usage = "declare [OPTIONS] [DECLARATIONS]...", + plus_options, + disable_help_short, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct DeclareCommand { + /// Constrain to function names or definitions. + #[arg(short = 'f')] + function_names_or_defs_only: bool, + + /// Constrain to function names only. + #[arg(short = 'F')] + function_names_only: bool, + + /// Create global variable, if applicable. + #[arg(short = 'g')] + create_global: bool, + + /// When creating a local variable that shadows another variable of the same name, + /// then initialize it with the contents and attributes of the variable being shadowed. + #[arg(short = 'I')] + locals_inherit_from_prev_scope: bool, + + /// Display each item's attributes and values. + #[arg(short = 'p')] + print: bool, + + /// Make the variable an indexed array. + #[arg(short = 'a', plus = 'a')] + make_indexed_array: Vec, + + /// Make the variable an associative array. + #[arg(short = 'A', plus = 'A')] + make_associative_array: Vec, + + /// Enable capitalize-on-assignment for the variable. + #[arg(short = 'c', plus = 'c')] + capitalize_value_on_assignment: Vec, + + /// Mark the variable as integer-typed. + #[arg(short = 'i', plus = 'i')] + make_integer: Vec, + + /// Enable lowercase-on-assignment for the variable. + #[arg(short = 'l', plus = 'l')] + lowercase_value_on_assignment: Vec, + + /// Mark the variable as a name reference. + #[arg(short = 'n', plus = 'n')] + make_nameref: Vec, + + /// Mark the variable as read-only. + #[arg(short = 'r', plus = 'r')] + make_readonly: Vec, + + /// Enable tracing for the variable. + #[arg(short = 't', plus = 't')] + make_traced: Vec, + + /// Enable uppercase-on-assignment for the variable. + #[arg(short = 'u', plus = 'u')] + uppercase_value_on_assignment: Vec, + + /// Mark the variable for export. + #[arg(short = 'x', plus = 'x')] + make_exported: Vec, + + // + // Declarations + // + // N.B. These are skipped by winnow-args, but filled in by `impl_parse_args!`. + #[arg(skip)] + declarations: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(DeclareCommand, declarations = declarations); + +/// The change an attribute's `-X` (`true`) and `+X` (`false`) options make: +/// `+X` wins wherever it is given. +fn change(options: &[bool]) -> Option { + if options.contains(&false) { + Some(false) + } else if options.contains(&true) { + Some(true) + } else { + None + } +} + +#[derive(Clone, Copy)] +enum DeclareVerb { + Declare, + Local, + Readonly, +} + +impl builtins::Builtin for DeclareCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let verb = match context.command_name.as_str() { + "local" => DeclareVerb::Local, + "readonly" => DeclareVerb::Readonly, + _ => DeclareVerb::Declare, + }; + + if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() { + writeln!(context.stderr(), "can only be used in a function")?; + return Ok(ExecutionResult::general_error()); + } + + let mut result = ExecutionResult::success(); + if !self.declarations.is_empty() { + for declaration in &self.declarations { + if self.print && !matches!(verb, DeclareVerb::Readonly) { + if !self.try_display_declaration(&context, declaration, verb)? { + result = ExecutionResult::general_error(); + } + } else { + if !self.process_declaration(&mut context, declaration, verb)? { + result = ExecutionResult::general_error(); + } + } + } + } else { + // Display matching declarations from the variable environment. + if !self.function_names_only && !self.function_names_or_defs_only { + self.display_matching_env_declarations(&context, verb)?; + } + + // Do the same for functions. + if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly) + && (!self.print || self.function_names_only || self.function_names_or_defs_only) + { + self.display_matching_functions(&context)?; + } + } + + Ok(result) + } +} + +impl DeclareCommand { + fn try_display_declaration( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + declaration: &brush_core::CommandArg, + verb: DeclareVerb, + ) -> Result { + let name = match declaration { + brush_core::CommandArg::String(s) => s, + brush_core::CommandArg::Assignment(_) => { + writeln!(context.stderr(), "declare: {declaration}: not found")?; + return Ok(false); + } + }; + + let lookup = if matches!(verb, DeclareVerb::Local) { + EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local) + } else { + EnvironmentLookup::Anywhere + }; + + if self.function_names_only || self.function_names_or_defs_only { + if let Some(func_registration) = context.shell.funcs().get(name) { + if self.function_names_only { + if self.print { + writeln!(context.stdout(), "declare -f {name}")?; + } else { + writeln!(context.stdout(), "{name}")?; + } + } else { + writeln!(context.stdout(), "{}", func_registration.definition())?; + } + Ok(true) + } else { + // For some reason, bash does not print an error message in this case. + Ok(false) + } + } else if let Some(variable) = context.shell.env().get_using_policy(name, lookup) { + let mut cs = variable.attribute_flags(context.shell); + if cs.is_empty() { + cs.push('-'); + } + + let resolved_value = variable.resolve_value(context.shell); + let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) { + "" + } else { + "=" + }; + + writeln!( + context.stdout(), + "declare -{cs} {name}{separator_str}{}", + resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)? + )?; + + Ok(true) + } else { + writeln!(context.stderr(), "declare: {name}: not found")?; + Ok(false) + } + } + + /// `declare -f` with attribute flags applies them to the named function rather than + /// displaying it (e.g. `declare -ft name`, `declare -fx name`). + fn apply_function_attributes( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + declaration: &brush_core::CommandArg, + ) -> bool { + let func = match declaration { + brush_core::CommandArg::String(name) => context.shell.func_mut(name), + brush_core::CommandArg::Assignment(_) => None, + }; + + // As with display, bash reports failure without printing an error message here. + let Some(func) = func else { + return false; + }; + + match change(&self.make_exported) { + Some(true) => func.export(), + Some(false) => func.unexport(), + None => (), + } + + // TODO(declare): function tracing (-t) isn't tracked; it's accepted silently. + true + } + + fn process_declaration( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + declaration: &brush_core::CommandArg, + verb: DeclareVerb, + ) -> Result { + let create_var_local = matches!(verb, DeclareVerb::Local) + || (matches!(verb, DeclareVerb::Declare) + && context.shell.in_function() + && !self.create_global); + + if (self.function_names_or_defs_only || self.function_names_only) + && (change(&self.make_traced).is_some() || change(&self.make_exported).is_some()) + { + return Ok(self.apply_function_attributes(context, declaration)); + } + + if self.function_names_or_defs_only || self.function_names_only { + return self.try_display_declaration(context, declaration, verb); + } + + // Extract the variable name and the initial value being assigned (if any). + let (name, assigned_index, initial_value, name_is_array, append) = + Self::declaration_to_name_and_value(declaration)?; + + // Special-case: `local -` + if name == "-" && matches!(verb, DeclareVerb::Local) { + // TODO(local): `local -` allows shadowing the current `set` options (i.e., $-), with + // subsequent updates getting discarded when the current local scope is popped. + tracing::warn!("not yet implemented: local -"); + return Ok(true); + } + + // Make sure it's a valid name. + if !env::valid_variable_name(name.as_str()) { + writeln!( + context.stderr(), + "{}: `{name}': not a valid identifier", + context.command_name + )?; + return Ok(false); + } + + // Figure out where we should look. + let lookup = if create_var_local { + EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local) + } else { + EnvironmentLookup::Anywhere + }; + + // `local -I x[=v]` / `declare -I` (bash 5.0+): the new local inherits + // value and attributes from the nearest same-name variable in an + // enclosing scope instead of starting unset; `+=` appends to the + // inherited value. With no same-name variable anywhere, fall through + // to ordinary creation. + if self.locals_inherit_from_prev_scope && create_var_local { + let inherited = context + .shell + .env() + .get_using_policy(name.as_str(), EnvironmentLookup::Anywhere) + .cloned(); + + if let Some(mut var) = inherited { + self.apply_attributes_before_update(&mut var)?; + + if let Some(initial_value) = initial_value { + var.assign(initial_value, append || assigned_index.is_some())?; + } + + if context.shell.options().export_variables_on_modification + && !var.value().is_array() + { + var.export(); + } + + self.apply_attributes_after_update(&mut var, verb)?; + + context + .shell + .env_mut() + .add(name, var, EnvironmentScope::Local)?; + return Ok(true); + } + } + + // Look up the variable. + if let Some(var) = context + .shell + .env_mut() + .get_mut_using_policy(name.as_str(), lookup) + { + if change(&self.make_associative_array).is_some() { + var.convert_to_associative_array()?; + } + if change(&self.make_indexed_array).is_some() { + var.convert_to_indexed_array()?; + } + + self.apply_attributes_before_update(var)?; + + if let Some(initial_value) = initial_value { + // We append for `name+=value`, or if the declaration included + // an explicit index. + var.assign(initial_value, append || assigned_index.is_some())?; + } + + self.apply_attributes_after_update(var, verb)?; + } else { + let unset_type = if change(&self.make_indexed_array).is_some() { + ShellValueUnsetType::IndexedArray + } else if change(&self.make_associative_array).is_some() { + ShellValueUnsetType::AssociativeArray + } else if name_is_array { + ShellValueUnsetType::IndexedArray + } else { + ShellValueUnsetType::Untyped + }; + + let mut var = ShellVariable::new(ShellValue::Unset(unset_type)); + + self.apply_attributes_before_update(&mut var)?; + + if let Some(initial_value) = initial_value { + var.assign(initial_value, append)?; + } + + if context.shell.options().export_variables_on_modification && !var.value().is_array() { + var.export(); + } + + self.apply_attributes_after_update(&mut var, verb)?; + + let scope = if create_var_local { + EnvironmentScope::Local + } else { + EnvironmentScope::Global + }; + + context.shell.env_mut().add(name, var, scope)?; + } + + Ok(true) + } + + #[expect(clippy::type_complexity)] + fn declaration_to_name_and_value( + declaration: &brush_core::CommandArg, + ) -> Result< + ( + String, + Option, + Option, + bool, + bool, + ), + brush_core::Error, + > { + let name; + let assigned_index; + let initial_value; + let name_is_array; + let append; + + match declaration { + brush_core::CommandArg::String(s) => { + // We need to handle the case of someone invoking `declare array[index]`. + // In such case, we ignore the index and treat it as a declaration of + // the array. + #[allow( + clippy::unwrap_in_result, + clippy::unwrap_used, + reason = "regex is valid and should not fail" + )] + static ARRAY_AND_INDEX_RE: LazyLock = + LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap()); + + if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? { + name = captures + .get(1) + .ok_or_else(|| { + brush_core::ErrorKind::InternalError("declaration parse error".into()) + })? + .as_str() + .to_owned(); + + assigned_index = captures.get(2).map(|m| m.as_str().to_owned()); + name_is_array = true; + } else { + name = s.clone(); + assigned_index = None; + name_is_array = false; + } + initial_value = None; + append = false; + } + brush_core::CommandArg::Assignment(assignment) => { + match &assignment.name { + ast::AssignmentName::VariableName(var_name) => { + name = var_name.to_owned(); + assigned_index = None; + } + ast::AssignmentName::ArrayElementName(var_name, index) => { + if matches!(assignment.value, ast::AssignmentValue::Array(_)) { + return Err(ErrorKind::AssigningListToArrayMember.into()); + } + + name = var_name.to_owned(); + assigned_index = Some(index.to_owned()); + } + } + + append = assignment.append; + + match &assignment.value { + ast::AssignmentValue::Scalar(s) => { + if let Some(index) = &assigned_index { + initial_value = Some(ShellValueLiteral::Array(ArrayLiteral::new( + vec![(Some(index.to_owned()), s.value.clone())], + ))); + name_is_array = true; + } else { + initial_value = Some(ShellValueLiteral::Scalar(s.value.clone())); + name_is_array = false; + } + } + ast::AssignmentValue::Array(a) => { + initial_value = Some(ShellValueLiteral::Array(ArrayLiteral::new( + a.iter() + .map(|(i, v)| { + (i.as_ref().map(|w| w.value.clone()), v.value.clone()) + }) + .collect(), + ))); + name_is_array = true; + } + } + } + } + + Ok((name, assigned_index, initial_value, name_is_array, append)) + } + + fn display_matching_env_declarations( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + verb: DeclareVerb, + ) -> Result<(), brush_core::Error> { + // + // Dump all declarations. Use attribute flags to filter which variables are dumped. + // + + // We start by excluding all variables that are not enumerable. + #[expect(clippy::type_complexity)] + let mut filters: Vec bool>> = + vec![Box::new(|(_, v)| v.is_enumerable())]; + + // Add filters depending on verb. + if matches!(verb, DeclareVerb::Readonly) { + filters.push(Box::new(|(_, v)| v.is_readonly())); + } + + // Add filters depending on attribute flags. Only `-X` filters; `+X` doesn't. + let given = |options: &[bool]| options.contains(&true); + if given(&self.make_indexed_array) { + filters.push(Box::new(|(_, v)| { + matches!(v.value(), ShellValue::IndexedArray(_)) + })); + } + if given(&self.make_associative_array) { + filters.push(Box::new(|(_, v)| { + matches!(v.value(), ShellValue::AssociativeArray(_)) + })); + } + if given(&self.make_integer) { + filters.push(Box::new(|(_, v)| v.is_treated_as_integer())); + } + if given(&self.capitalize_value_on_assignment) { + filters.push(Box::new(|(_, v)| { + matches!( + v.get_update_transform(), + ShellVariableUpdateTransform::Capitalize + ) + })); + } + if given(&self.lowercase_value_on_assignment) { + filters.push(Box::new(|(_, v)| { + matches!( + v.get_update_transform(), + ShellVariableUpdateTransform::Lowercase + ) + })); + } + if given(&self.make_nameref) { + filters.push(Box::new(|(_, v)| v.is_treated_as_nameref())); + } + if given(&self.make_readonly) { + filters.push(Box::new(|(_, v)| v.is_readonly())); + } + if given(&self.make_traced) { + filters.push(Box::new(|(_, v)| v.is_trace_enabled())); + } + if given(&self.uppercase_value_on_assignment) { + filters.push(Box::new(|(_, v)| { + matches!( + v.get_update_transform(), + ShellVariableUpdateTransform::Uppercase + ) + })); + } + if given(&self.make_exported) { + filters.push(Box::new(|(_, v)| v.is_exported())); + } + + let iter_policy = if matches!(verb, DeclareVerb::Local) { + EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local) + } else { + EnvironmentLookup::Anywhere + }; + + // Iterate through an ordered list of all matching declarations tracked in the + // environment. + for (name, variable) in context + .shell + .env() + .iter_using_policy(iter_policy) + .filter(|pair| filters.iter().all(|f| f(*pair))) + .sorted_by_key(|v| v.0) + { + if self.print { + let mut cs = variable.attribute_flags(context.shell); + if cs.is_empty() { + cs.push('-'); + } + + let separator_str = if matches!(variable.value(), ShellValue::Unset(_)) { + "" + } else { + "=" + }; + + writeln!( + context.stdout(), + "declare -{cs} {name}{separator_str}{}", + variable + .value() + .format(variables::FormatStyle::DeclarePrint, context.shell)? + )?; + } else { + writeln!( + context.stdout(), + "{name}={}", + variable + .value() + .format(variables::FormatStyle::Basic, context.shell)? + )?; + } + } + + Ok(()) + } + + fn display_matching_functions( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result<(), brush_core::Error> { + for (name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) { + if self.function_names_only { + writeln!(context.stdout(), "declare -f {name}")?; + } else { + writeln!(context.stdout(), "{}", registration.definition())?; + } + } + + Ok(()) + } + + #[expect(clippy::unnecessary_wraps)] + fn apply_attributes_before_update( + &self, + var: &mut ShellVariable, + ) -> Result<(), brush_core::Error> { + if let Some(value) = change(&self.make_integer) { + if value { + var.treat_as_integer(); + } else { + var.unset_treat_as_integer(); + } + } + if let Some(value) = change(&self.capitalize_value_on_assignment) { + if value { + var.set_update_transform(ShellVariableUpdateTransform::Capitalize); + } else if matches!( + var.get_update_transform(), + ShellVariableUpdateTransform::Capitalize + ) { + var.set_update_transform(ShellVariableUpdateTransform::None); + } + } + if let Some(value) = change(&self.lowercase_value_on_assignment) { + if value { + var.set_update_transform(ShellVariableUpdateTransform::Lowercase); + } else if matches!( + var.get_update_transform(), + ShellVariableUpdateTransform::Lowercase + ) { + var.set_update_transform(ShellVariableUpdateTransform::None); + } + } + if let Some(value) = change(&self.make_nameref) { + if value { + var.treat_as_nameref(); + } else { + var.unset_treat_as_nameref(); + } + } + if let Some(value) = change(&self.make_traced) { + if value { + var.enable_trace(); + } else { + var.disable_trace(); + } + } + if let Some(value) = change(&self.uppercase_value_on_assignment) { + if value { + var.set_update_transform(ShellVariableUpdateTransform::Uppercase); + } else if matches!( + var.get_update_transform(), + ShellVariableUpdateTransform::Uppercase + ) { + var.set_update_transform(ShellVariableUpdateTransform::None); + } + } + if let Some(value) = change(&self.make_exported) { + if value { + var.export(); + } else { + var.unexport(); + } + } + + Ok(()) + } + + fn apply_attributes_after_update( + &self, + var: &mut ShellVariable, + verb: DeclareVerb, + ) -> Result<(), brush_core::Error> { + if matches!(verb, DeclareVerb::Readonly) { + var.set_readonly(); + } else if let Some(value) = change(&self.make_readonly) { + if value { + var.set_readonly(); + } else { + var.unset_readonly()?; + } + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-builtins/src/dirs.rs b/userland/brush/source/brush-builtins/src/dirs.rs new file mode 100644 index 0000000..2a22dfc --- /dev/null +++ b/userland/brush/source/brush-builtins/src/dirs.rs @@ -0,0 +1,106 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DirError { + /// Directory stack is empty. + #[error("directory stack is empty")] + DirStackEmpty, + + /// There is no other directory to exchange with the current one. + #[error("no other directory")] + NoOtherDirectory, + + /// A shell error occurred. + #[error(transparent)] + ShellError(#[from] brush_core::Error), +} + +impl From<&DirError> for brush_core::ExecutionExitCode { + fn from(value: &DirError) -> Self { + match value { + DirError::DirStackEmpty | DirError::NoOtherDirectory => Self::GeneralError, + DirError::ShellError(e) => e.into(), + } + } +} + +impl brush_core::BuiltinError for DirError {} + +/// Manage the current directory stack. +#[derive(Default, winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct DirsCommand { + /// Clear the directory stack. + #[arg(short = 'c')] + clear: bool, + + /// Don't tilde-shorten paths. + #[arg(short = 'l')] + tilde_long: bool, + + /// Print one directory per line instead of all on one line. + #[arg(short = 'p')] + print_one_per_line: bool, + + /// Print one directory per line with its index. + #[arg(short = 'v')] + print_one_per_line_with_index: bool, + // TODO(dirs): implement +N and -N +} + +brush_winnow_args_adapter::impl_parse_args!(DirsCommand); + +impl builtins::Builtin for DirsCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.clear { + context.shell.directory_stack_mut().clear(); + } else { + let dirs = vec![context.shell.working_dir()] + .into_iter() + .chain( + context + .shell + .directory_stack() + .iter() + .rev() + .map(|p| p.as_path()), + ) + .collect::>(); + + let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index; + + for (i, dir) in dirs.iter().enumerate() { + if !one_per_line && i > 0 { + write!(context.stdout(), " ")?; + } + + if self.print_one_per_line_with_index { + write!(context.stdout(), "{i:2} ")?; + } + + let mut dir_str = dir.to_string_lossy().to_string(); + + if !self.tilde_long { + dir_str = context.shell.tilde_shorten(dir_str); + } + + write!(context.stdout(), "{dir_str}")?; + + if one_per_line || i == dirs.len() - 1 { + writeln!(context.stdout())?; + } + } + + return Ok(ExecutionResult::success()); + } + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/dot.rs b/userland/brush/source/brush-builtins/src/dot.rs new file mode 100644 index 0000000..410fb52 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/dot.rs @@ -0,0 +1,37 @@ +use std::path::Path; + +use brush_core::builtins; + +/// Evaluate the provided script in the current shell environment. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct DotCommand { + /// Path to the script to evaluate. + #[arg(positional, double_dash = "automatic")] + script_path: String, + + /// Any arguments to be passed as positional parameters to the script. + #[arg(positional, allow_negative_numbers)] + script_args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(DotCommand); + +impl builtins::Builtin for DotCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // TODO(dot): Handle trap inheritance. + context + .shell + .source_script( + Path::new(&self.script_path), + self.script_args.iter(), + &context.params, + ) + .await + } +} diff --git a/userland/brush/source/brush-builtins/src/echo.rs b/userland/brush/source/brush-builtins/src/echo.rs new file mode 100644 index 0000000..9774213 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/echo.rs @@ -0,0 +1,73 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, escape}; + +/// Echo text to standard output. +#[derive(winnow_args::Args)] +#[arg( + disable_help_flag, + disable_version_flag, + disable_help_subcommand, + unknown_flags = "value" +)] +pub(crate) struct EchoCommand { + /// Suppress the trailing newline from the output. + #[arg(short = 'n')] + no_trailing_newline: bool, + + /// Interpret backslash escapes in the provided text. + #[arg(short = 'e', overrides = "-E")] + interpret_backslash_escapes: bool, + + /// Do not interpret backslash escapes in the provided text. + #[arg(short = 'E', overrides = "-e")] + no_interpret_backslash_escapes: bool, + + /// Tokens to echo to standard output. + #[arg(positional, double_dash = "preserve", stop_flags)] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(EchoCommand, keep_double_dash = args); + +impl builtins::Builtin for EchoCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut trailing_newline = !self.no_trailing_newline; + let mut s; + if self.interpret_backslash_escapes { + s = String::new(); + for (i, arg) in self.args.iter().enumerate() { + if i > 0 { + s.push(' '); + } + + let (expanded_arg, keep_going) = escape::expand_backslash_escapes( + arg.as_str(), + escape::EscapeExpansionMode::EchoBuiltin, + )?; + s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice())); + + if !keep_going { + trailing_newline = false; + break; + } + } + } else { + s = self.args.join(" "); + } + + if trailing_newline { + s.push('\n'); + } + + write!(context.stdout(), "{s}")?; + context.stdout().flush()?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/enable.rs b/userland/brush/source/brush-builtins/src/enable.rs new file mode 100644 index 0000000..beb40f3 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/enable.rs @@ -0,0 +1,87 @@ +use brush_core::ExecutionResult; +use std::io::Write; + +use brush_core::builtins; +use brush_core::error; + +/// Enable, disable, or display built-in commands. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct EnableCommand { + /// Print a list of built-in commands. + #[arg(short = 'a')] + print_list: bool, + + /// Remove the built-in commands loaded from the indicated object path. + #[arg(short = 'd')] + remove_loaded_builtin: bool, + + /// Disables the specified built-in commands. + #[arg(short = 'n')] + disable: bool, + + /// Print a list of built-in commands with reusable output. + #[arg(short = 'p')] + print_reusably: bool, + + /// Only operate on special built-in commands. + #[arg(short = 's')] + special_only: bool, + + /// Path to a shared object from which built-in commands will be loaded. + #[arg(short = 'f', value_name = "PATH")] + shared_object_path: Option, + + /// Names of built-in commands to operate on. + #[arg(positional)] + names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(EnableCommand); + +impl builtins::Builtin for EnableCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + + if self.shared_object_path.is_some() { + return error::unimp("enable -f"); + } + if self.remove_loaded_builtin { + return error::unimp("enable -d"); + } + + if !self.names.is_empty() { + for name in &self.names { + if let Some(builtin) = context.shell.builtins_mut().get_mut(name) { + builtin.set_enabled(!self.disable); + } else { + writeln!(context.stderr(), "{name}: not a shell builtin")?; + result = ExecutionResult::general_error(); + } + } + } else { + for (builtin_name, builtin) in context.shell.builtins().iter() { + // With -a, every builtin is listed. Otherwise -n lists just the disabled + // ones and the default (with or without -p) lists just the enabled ones. + if !self.print_list && builtin.is_enabled() == self.disable { + continue; + } + + if self.special_only && !builtin.is_special() { + continue; + } + + let prefix = if !builtin.is_enabled() { "-n " } else { "" }; + + writeln!(context.stdout(), "enable {prefix}{builtin_name}")?; + } + } + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/eval.rs b/userland/brush/source/brush-builtins/src/eval.rs new file mode 100644 index 0000000..a32f6cf --- /dev/null +++ b/userland/brush/source/brush-builtins/src/eval.rs @@ -0,0 +1,44 @@ +use brush_core::{ExecutionResult, builtins}; + +/// Evaluate the given string as script. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct EvalCommand { + /// The script to evaluate. + #[arg(positional, double_dash = "automatic")] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(EvalCommand); + +impl builtins::Builtin for EvalCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if !self.args.is_empty() { + let args_concatenated = self.args.join(" "); + + tracing::debug!("Applying eval to: {:?}", args_concatenated); + + // Our new source context is relative to the current position because we are only + // providing the raw string being eval'd. + // TODO(source-info): Provide the location of the specific tokens that make up + // `self.args`. + let source_info = context.shell.call_stack().current_pos_as_source_info(); + + // Return the direct result of running the string; we intentionally + // pass through the result and honor its requested control flow. eval + // executes in the current environment, so all control flow (return, + // exit, break, continue) should propagate. + context + .shell + .run_string(args_concatenated, &source_info, &context.params) + .await + } else { + Ok(ExecutionResult::success()) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/exec.rs b/userland/brush/source/brush-builtins/src/exec.rs new file mode 100644 index 0000000..f09d2c0 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/exec.rs @@ -0,0 +1,97 @@ +use std::borrow::Cow; + +use brush_core::{ + ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands, + extensions::ProcessLauncher as _, +}; + +/// Exec the provided command. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ExecCommand { + /// Exec command with an empty environment. + #[arg(short = 'c')] + empty_environment: bool, + + /// Exec command as a login shell. + #[arg(short = 'l')] + exec_as_login: bool, + + /// Pass given name as zeroth argument to command. + #[arg(short = 'a', value_name = "NAME", allow_hyphen_values)] + name_for_argv0: Option, + + /// Command and args. + #[arg(positional, double_dash = "automatic")] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(ExecCommand); + +impl builtins::Builtin for ExecCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.args.is_empty() { + // When no arguments are present, then there's nothing for us to execute -- but we need + // to ensure that any redirections setup for this builtin get applied to the calling + // shell instance. + #[allow(clippy::needless_collect)] + let fds: Vec<_> = context.iter_fds().collect(); + + context.shell.replace_open_files(fds.into_iter()); + return Ok(ExecutionResult::success()); + } + + // If we know we're already running in a subshell, then `exec`ing is actually + // unsafe, since it would also replace the *parent* shell instance. We instead + // delegate to the `command` builtin to perform the execution, with an expectation + // of returning. + if context.shell.is_subshell() { + if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() { + return brush_core::error::unimp("exec with options in subshell not yet supported"); + } + + let cmd_cmd = crate::command::CommandCommand { + command_and_args: self.args.clone(), + ..Default::default() + }; + + return cmd_cmd.execute(context).await; + } + + let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0])); + + if self.exec_as_login { + argv0 = Cow::Owned(std::format!("-{argv0}")); + } + + // Resolve the program as running it as a command would. + let Some(program) = context.shell.resolve_external_command(&self.args[0]) else { + return Ok(ExecutionExitCode::NotFound.into()); + }; + + let request = commands::compose_launch_request( + &context, + &program, + argv0.as_str(), + &self.args[1..], + if self.empty_environment { + commands::ProgramEnvironment::Empty + } else { + commands::ProgramEnvironment::Exported + }, + ); + + let exec_error = context.shell.process_launcher().exec(request); + + if exec_error.kind() == std::io::ErrorKind::NotFound { + Ok(ExecutionExitCode::NotFound.into()) + } else { + Err(ErrorKind::from(exec_error).into()) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/exit.rs b/userland/brush/source/brush-builtins/src/exit.rs new file mode 100644 index 0000000..804080b --- /dev/null +++ b/userland/brush/source/brush-builtins/src/exit.rs @@ -0,0 +1,33 @@ +use brush_core::{ExecutionControlFlow, ExecutionResult, builtins}; + +/// Exit the shell. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ExitCommand { + /// The exit code to return. + #[arg(positional, allow_negative_numbers)] + code: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(ExitCommand); + +impl builtins::Builtin for ExitCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + #[expect(clippy::cast_sign_loss)] + let code_8bit = if let Some(code_32bit) = &self.code { + (code_32bit & 0xFF) as u8 + } else { + context.shell.last_exit_status() + }; + + let mut result = ExecutionResult::new(code_8bit); + result.next_control_flow = ExecutionControlFlow::ExitShell; + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/export.rs b/userland/brush/source/brush-builtins/src/export.rs new file mode 100644 index 0000000..a665f82 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/export.rs @@ -0,0 +1,207 @@ +use itertools::Itertools; +use std::io::Write; + +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + env::{self, EnvironmentLookup, EnvironmentScope}, + parser::ast, + variables, +}; + +/// Add or update exported shell variables. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ExportCommand { + /// Names are treated as function names. + #[arg(short = 'f')] + names_are_functions: bool, + + /// Un-export the names. + #[arg(short = 'n')] + unexport: bool, + + /// Display all exported names. + #[arg(short = 'p')] + display_exported_names: bool, + + // + // Declarations + // + // N.B. These are skipped by winnow-args, but filled in by `impl_parse_args!`. + #[arg(skip)] + declarations: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(ExportCommand, declarations = declarations); + +impl builtins::Builtin for ExportCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.declarations.is_empty() { + display_all_exported_vars(&context)?; + return Ok(ExecutionResult::success()); + } + + let mut result = ExecutionResult::success(); + for decl in &self.declarations { + let current_result = self.process_decl(&mut context, decl)?; + if !current_result.is_success() { + result = current_result; + } + } + + Ok(result) + } +} + +impl ExportCommand { + fn process_decl( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + decl: &brush_core::CommandArg, + ) -> Result { + match decl { + brush_core::CommandArg::String(s) => { + // See if this is supposed to be a function name. + if self.names_are_functions { + // Try to find the function already present; if we find it, then mark it + // exported. + if let Some(func) = context.shell.func_mut(s) { + if self.unexport { + func.unexport(); + } else { + func.export(); + } + } else { + writeln!(context.stderr(), "{s}: not a function")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + // A word that expanded into an assignment (`export ${v}=x`): the + // parser can't see it as one, so it is split here, as bash does. + else if let Some((name, value)) = s.split_once('=') { + let (name, append) = name + .strip_suffix('+') + .map_or((name, false), |name| (name, true)); + if !env::valid_variable_name(name) { + return Self::invalid_identifier(context, s); + } + + let value = variables::ShellValueLiteral::Scalar(value.to_owned()); + self.assign_and_mark(context, name, value, append)?; + } else if !env::valid_variable_name(s) { + return Self::invalid_identifier(context, s); + } + // Try to find the variable already present; if we find it, then mark it + // exported. + else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) { + if self.unexport { + variable.unexport(); + } else { + variable.export(); + } + } + } + brush_core::CommandArg::Assignment(assignment) => { + let name = match &assignment.name { + ast::AssignmentName::VariableName(name) => name, + ast::AssignmentName::ArrayElementName(name, index) => { + return Self::invalid_identifier(context, &format!("{name}[{index}]")); + } + }; + + let value = match &assignment.value { + ast::AssignmentValue::Scalar(s) => { + variables::ShellValueLiteral::Scalar(s.flatten()) + } + ast::AssignmentValue::Array(a) => { + variables::ShellValueLiteral::Array(variables::ArrayLiteral::new( + a.iter() + .map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten())) + .collect(), + )) + } + }; + + self.assign_and_mark(context, name, value, assignment.append)?; + } + } + + Ok(ExecutionResult::success()) + } + + /// Reports `word` as an invalid name, as bash does, and returns the failure. + fn invalid_identifier( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + word: &str, + ) -> Result { + writeln!( + context.stderr(), + "{}: `{word}': not a valid identifier", + context.command_name + )?; + Ok(ExecutionResult::general_error()) + } + + /// Assigns `value` to `name`, appending to it for `name+=value`, and marks + /// the variable exported (or not, for `-n`). + fn assign_and_mark( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + name: &str, + value: variables::ShellValueLiteral, + append: bool, + ) -> Result<(), brush_core::Error> { + // `export name+=value` appends to the existing value, exactly like a + // bare `name+=value`. update_or_add always replaces, so when the + // variable already exists honor the append here. A missing variable + // falls through: appending to nothing is a plain assignment. + if append && let Some((_, variable)) = context.shell.env_mut().get_mut(name) { + variable.assign(value, true)?; + if self.unexport { + variable.unexport(); + } else { + variable.export(); + } + return Ok(()); + } + + // Update the variable with the provided value and then mark it exported. + context.shell.env_mut().update_or_add( + name, + value, + |var| { + if self.unexport { + var.unexport(); + } else { + var.export(); + } + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + ) + } +} + +fn display_all_exported_vars( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, +) -> Result<(), brush_core::Error> { + // Enumerate variables, sorted by key. + for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) { + if variable.is_exported() { + let value = variable.value().try_get_cow_str(context.shell); + if let Some(value) = value { + writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?; + } else { + writeln!(context.stdout(), "declare -x {name}")?; + } + } + } + + Ok(()) +} diff --git a/userland/brush/source/brush-builtins/src/factory.rs b/userland/brush/source/brush-builtins/src/factory.rs new file mode 100644 index 0000000..c915db9 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/factory.rs @@ -0,0 +1,352 @@ +use std::collections::HashMap; + +#[allow(clippy::wildcard_imports)] +use super::*; + +#[allow(unused_imports, reason = "not all builtins are used in all configs")] +use brush_core::builtins; + +/// Identifies well-known sets of builtins. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum BuiltinSet { + /// Identifies builtins appropriate for POSIX `sh` compatibility. + ShMode, + /// Identifies builtins appropriate for a more full-featured `bash`-compatible shell. + BashMode, +} + +/// Returns the default set of built-in commands. +/// +/// # Arguments +/// +/// * `set` - The set of built-ins to return. +#[allow(clippy::too_many_lines)] +pub fn default_builtins( + set: BuiltinSet, +) -> HashMap> { + let mut m = HashMap::>::new(); + + // + // POSIX special builtins + // + // N.B. There seems to be some inconsistency as to whether 'times' + // should be a special built-in. + // + + #[cfg(feature = "builtin.break")] + m.insert( + "break".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.colon")] + m.insert( + ":".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.continue")] + m.insert( + "continue".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.dot")] + m.insert( + ".".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.eval")] + m.insert( + "eval".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.exec")] + m.insert( + "exec".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.exit")] + m.insert( + "exit".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.export")] + m.insert( + "export".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.return")] + m.insert( + "return".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.set")] + m.insert( + "set".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.shift")] + m.insert( + "shift".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.trap")] + m.insert( + "trap".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.unset")] + m.insert( + "unset".into(), + builtins::Registration::of::().special(), + ); + + #[cfg(feature = "builtin.declare")] + m.insert( + "readonly".into(), + builtins::Registration::of::().special(), + ); + #[cfg(feature = "builtin.times")] + m.insert( + "times".into(), + builtins::Registration::of::().special(), + ); + + // + // Non-special builtins + // + + #[cfg(feature = "builtin.alias")] + m.insert( + "alias".into(), + builtins::Registration::of::(), + ); // TODO(alias): should be exec_declaration_builtin + #[cfg(feature = "builtin.bg")] + m.insert("bg".into(), builtins::Registration::of::()); + #[cfg(feature = "builtin.cd")] + m.insert("cd".into(), builtins::Registration::of::()); + #[cfg(feature = "builtin.command")] + m.insert( + "command".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.false")] + m.insert( + "false".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.fg")] + m.insert("fg".into(), builtins::Registration::of::()); + #[cfg(feature = "builtin.getopts")] + m.insert( + "getopts".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.hash")] + m.insert( + "hash".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.help")] + m.insert( + "help".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.jobs")] + m.insert( + "jobs".into(), + builtins::Registration::of::(), + ); + #[cfg(all(feature = "builtin.kill", unix))] + m.insert( + "kill".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.declare")] + m.insert( + "local".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.pwd")] + m.insert( + "pwd".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.read")] + m.insert( + "read".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.true")] + m.insert( + "true".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.type")] + m.insert( + "type".into(), + builtins::Registration::of::(), + ); + #[cfg(all(feature = "builtin.ulimit", unix))] + m.insert( + "ulimit".into(), + builtins::Registration::of::(), + ); + #[cfg(all(feature = "builtin.umask", unix))] + m.insert( + "umask".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.unalias")] + m.insert( + "unalias".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.wait")] + m.insert( + "wait".into(), + builtins::Registration::of::(), + ); + + #[cfg(feature = "builtin.fc")] + m.insert("fc".into(), builtins::Registration::of::()); + + if matches!(set, BuiltinSet::BashMode) { + #[cfg(feature = "builtin.builtin")] + m.insert( + "builtin".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.declare")] + m.insert( + "declare".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.echo")] + m.insert( + "echo".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.enable")] + m.insert( + "enable".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.let")] + m.insert( + "let".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.mapfile")] + m.insert( + "mapfile".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.mapfile")] + m.insert( + "readarray".into(), + builtins::Registration::of::(), + ); + #[cfg(all(feature = "builtin.printf", any(unix, windows, target_os = "wasi")))] + m.insert( + "printf".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.shopt")] + m.insert( + "shopt".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.dot")] + m.insert( + "source".into(), + builtins::Registration::of::().special(), + ); + #[cfg(all(feature = "builtin.suspend", unix))] + m.insert( + "suspend".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.test")] + m.insert( + "test".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.test")] + m.insert( + "[".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.declare")] + m.insert( + "typeset".into(), + builtins::Registration::of::(), + ); + + // Completion builtins + #[cfg(feature = "builtin.complete")] + m.insert( + "complete".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.compgen")] + m.insert( + "compgen".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.compopt")] + m.insert( + "compopt".into(), + builtins::Registration::of::(), + ); + + // Dir stack builtins + #[cfg(feature = "builtin.dirs")] + m.insert( + "dirs".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.popd")] + m.insert( + "popd".into(), + builtins::Registration::of::(), + ); + #[cfg(feature = "builtin.pushd")] + m.insert( + "pushd".into(), + builtins::Registration::of::(), + ); + + // Input configuration builtins + #[cfg(feature = "builtin.bind")] + m.insert( + "bind".into(), + builtins::Registration::of::(), + ); + + // History + #[cfg(feature = "builtin.history")] + m.insert( + "history".into(), + builtins::Registration::of::(), + ); + + #[cfg(feature = "builtin.caller")] + m.insert( + "caller".into(), + builtins::Registration::of::(), + ); + + // TODO(disown): implement disown builtin + m.insert( + "disown".into(), + builtins::Registration::of::(), + ); + + // TODO(logout): implement logout builtin + m.insert( + "logout".into(), + builtins::Registration::of::(), + ); + } + + m +} diff --git a/userland/brush/source/brush-builtins/src/false_.rs b/userland/brush/source/brush-builtins/src/false_.rs new file mode 100644 index 0000000..a2f5380 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/false_.rs @@ -0,0 +1,34 @@ +use brush_core::{ExecutionResult, builtins, error}; + +/// Return exit code 1. +pub(crate) struct FalseCommand {} + +impl builtins::ParseArgs for FalseCommand { + fn content( + name: &str, + content_type: builtins::ContentType, + _options: &builtins::ContentOptions, + ) -> Result { + match content_type { + builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()), + builtins::ContentType::ShortUsage => Ok(format!("{name}: {name}\n")), + builtins::ContentType::ShortDescription => Ok(format!("{name} - fail\n")), + builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + } + } + + fn parse(_invocation: builtins::Invocation<'_>) -> Result { + Ok(Self {}) + } +} + +impl builtins::Builtin for FalseCommand { + type Error = brush_core::Error; + + async fn execute( + self, + _context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + Ok(ExecutionResult::general_error()) + } +} diff --git a/userland/brush/source/brush-builtins/src/fc.rs b/userland/brush/source/brush-builtins/src/fc.rs new file mode 100644 index 0000000..e528bf7 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/fc.rs @@ -0,0 +1,303 @@ +use brush_core::{ExecutionResult, builtins, error, history}; +use std::io::Write; + +/// Process command history list. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct FcCommand { + /// Editor to use (only relevant when not listing or substituting). + #[arg(short = 'e', value_name = "ENAME", allow_hyphen_values)] + editor: Option, + + /// List commands instead of editing them. + #[arg(short = 'l')] + list: bool, + + /// Suppress line numbers when listing. + #[arg(short = 'n', requires = "-l")] + no_line_numbers: bool, + + /// Reverse the order of commands. + #[arg(short = 'r')] + reverse: bool, + + /// Re-execute command after substitution (old=new format). + #[arg(short = 's')] + substitute: bool, + + /// First command in range (number or string prefix). + #[arg(positional, value_name = "FIRST", allow_negative_numbers)] + first: Option, + + /// Last command in range (number or string prefix). + #[arg(positional, value_name = "LAST", allow_negative_numbers)] + last: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(FcCommand); + +impl builtins::Builtin for FcCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.substitute { + return self.do_execute(context).await; + } + + if self.list { + return self.do_list(&context); + } + + error::unimp("fc editor mode is not yet implemented") + } +} + +impl FcCommand { + fn do_list( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + let (first_idx, last_idx, reverse) = self.resolve_range(history)?; + + // Determine the order of iteration + let indices: Vec = if reverse { + (first_idx..=last_idx).rev().collect() + } else { + (first_idx..=last_idx).collect() + }; + + for idx in indices { + if let Some(item) = history.get(idx) { + if self.no_line_numbers { + // With -n, bash still outputs a tab before the command + writeln!(context.stdout(), "\t {}", item.command_line)?; + } else { + // Match bash's fc format: number, tab, command + writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?; + } + } + } + + Ok(ExecutionResult::success()) + } + + async fn do_execute( + &self, + context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + // Parse the first argument for pattern=replacement + let (pattern, replacement) = self + .first + .as_ref() + .and_then(|s| s.split_once('=')) + .map_or((None, None), |(p, r)| (Some(p), Some(r))); + + // Determine which command to re-execute + let cmd_spec = if pattern.is_some() { + // If we have a pattern, the command spec is in 'last' if present + self.last.as_deref() + } else { + // Otherwise, it's in 'first' + self.first.as_deref() + }; + + // Find the command + let cmd_line = if let Some(spec) = cmd_spec { + Self::find_command_by_specifier(history, spec)? + } else { + // No spec means use the previous command (excluding the fc command itself) + let effective_count = effective_history_count(history); + history + .get(effective_count.saturating_sub(1)) + .map(|item| item.command_line.clone()) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))? + }; + + // Apply substitution if present + let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) { + cmd_line.replace(pat, rep) + } else { + cmd_line + }; + + // Echo the command to stderr. + writeln!(context.stderr(), "{final_cmd}")?; + + // Remove the fc command from history before executing the substituted command + // This matches bash behavior where the fc command is replaced by the executed command + let history_mut = context + .shell + .history_mut() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + history_mut.remove_nth_item(history_mut.count().saturating_sub(1)); + + let source_info = brush_core::SourceInfo::from("(history)"); + + // Execute the command + let result = context + .shell + .run_string(final_cmd.clone(), &source_info, &context.params) + .await?; + + // Add the executed command to history. + context.shell.add_to_history(&final_cmd)?; + + Ok(result) + } + + fn resolve_range( + &self, + history: &history::History, + ) -> Result<(usize, usize, bool), brush_core::Error> { + let effective_count = effective_history_count(history); + let max_idx = effective_count.saturating_sub(1); + + // Resolve first index + let first_idx = self + .first + .as_ref() + .map(|s| Self::resolve_position(history, s)) + .transpose()? + .unwrap_or_else(|| { + if self.list { + effective_count.saturating_sub(16) // Default for listing: -16 + } else { + max_idx // Default for editing: previous command + } + }); + + // Resolve last index (default depends on mode and first_idx) + let default_last = if self.list { max_idx } else { first_idx }; + let last_idx = self + .last + .as_ref() + .map(|s| Self::resolve_position(history, s)) + .transpose()? + .unwrap_or(default_last); + + // If first > last, swap them and indicate reversal + let (first_idx, last_idx, force_reverse) = if first_idx > last_idx { + (last_idx, first_idx, true) + } else { + (first_idx, last_idx, false) + }; + + // Clamp both indices to valid range + Ok(( + first_idx.min(max_idx), + last_idx.min(max_idx), + force_reverse || self.reverse, + )) + } + + /// Resolves a position specifier (number or string prefix) to a history index. + /// NOTE: The returned index may still be out of range if the history is empty. + /// + /// # Arguments + /// + /// * `history` - The history to resolve against. + /// * `spec` - The position specifier (number or string prefix). + fn resolve_position( + history: &history::History, + spec: &str, + ) -> Result { + // Try to parse it as a number. If it's not parseable, then we need to assume + // it's a string prefix we need to search for. + let Ok(num) = spec.parse::() else { + // Not a number, treat as string prefix + return Self::find_command_by_prefix(history, spec); + }; + + let effective_count = effective_history_count(history); + + #[expect(clippy::cast_sign_loss)] + #[expect(clippy::cast_possible_truncation)] + let result = match num.cmp(&0) { + std::cmp::Ordering::Equal => { + // 0 means -1 for listing (relative to effective count) + effective_count.saturating_sub(1) + } + std::cmp::Ordering::Greater => { + // Positive: 1-based index + let idx = (num - 1) as usize; + if idx < effective_count { + idx + } else { + // Out of range - use 0 (first item) + 0 + } + } + std::cmp::Ordering::Less => { + // Negative: offset from end (relative to effective count) + let offset = (-num) as usize; + effective_count.saturating_sub(offset) + } + }; + + Ok(result) + } + + /// Finds the command matching the given specifier (number or string prefix). Returns + /// the command line. Returns an error if no such command can be found in the history. + /// + /// # Arguments + /// + /// * `history` - The history to search. + /// * `spec` - The position spec + fn find_command_by_specifier( + history: &history::History, + spec: &str, + ) -> Result { + let idx = Self::resolve_position(history, spec)?; + history + .get(idx) + .map(|item| item.command_line.clone()) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound)) + } + + /// Finds the most recent command starting with the given prefix. Returns + /// the index of the command in the history. Returns an error if no such + /// command can be found in the history. + /// + /// # Arguments + /// + /// * `history` - The history to search. + /// * `prefix` - The command prefix to search for. + fn find_command_by_prefix( + history: &history::History, + prefix: &str, + ) -> Result { + // Search backwards for a command starting with the prefix (excluding fc command itself) + let effective_count = effective_history_count(history); + + for idx in (0..effective_count).rev() { + if let Some(item) = history.get(idx) { + if item.command_line.starts_with(prefix) { + return Ok(idx); + } + } + } + + Err(brush_core::Error::from( + error::ErrorKind::HistoryItemNotFound, + )) + } +} + +/// Returns the effective history count (excluding the fc command itself). +fn effective_history_count(history: &history::History) -> usize { + history.count().saturating_sub(1) +} diff --git a/userland/brush/source/brush-builtins/src/fg.rs b/userland/brush/source/brush-builtins/src/fg.rs new file mode 100644 index 0000000..a369028 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/fg.rs @@ -0,0 +1,77 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, jobs, sys}; + +/// Move a specified job to the foreground. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct FgCommand { + /// Job spec for the job to move to the foreground; if not specified, the current job is moved. + #[arg(positional)] + job_spec: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(FgCommand); + +impl builtins::Builtin for FgCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut stdout = context.stdout(); + + // Read interactive option before taking mutable borrow on jobs + let is_interactive = context.shell.options().interactive; + + if let Some(job_spec) = &self.job_spec { + if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) { + job.move_to_foreground()?; + writeln!(stdout, "{}", job.command_line)?; + + let result = job.wait().await?; + if is_interactive { + sys::terminal::move_self_to_foreground()?; + } + + if matches!(job.state, jobs::JobState::Stopped) { + // N.B. We use the '\r' to overwrite any ^Z output. + let formatted = job.to_string(); + writeln!(context.stderr(), "\r{formatted}")?; + } + + Ok(result) + } else { + writeln!( + context.stderr(), + "{}: {}: no such job", + context.command_name, + job_spec + )?; + Ok(ExecutionResult::general_error()) + } + } else { + if let Some(job) = context.shell.jobs_mut().current_job_mut() { + job.move_to_foreground()?; + writeln!(stdout, "{}", job.command_line)?; + + let result = job.wait().await?; + if is_interactive { + sys::terminal::move_self_to_foreground()?; + } + + if matches!(job.state, jobs::JobState::Stopped) { + // N.B. We use the '\r' to overwrite any ^Z output. + let formatted = job.to_string(); + writeln!(context.stderr(), "\r{formatted}")?; + } + + Ok(result) + } else { + writeln!(context.stderr(), "{}: no current job", context.command_name)?; + Ok(ExecutionResult::general_error()) + } + } + } +} diff --git a/userland/brush/source/brush-builtins/src/getopts.rs b/userland/brush/source/brush-builtins/src/getopts.rs new file mode 100644 index 0000000..0e0a4c2 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/getopts.rs @@ -0,0 +1,373 @@ +use std::{collections::HashMap, io::Write}; + +use brush_core::{ExecutionResult, builtins, env, variables}; + +/// Parse command options. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct GetOptsCommand { + /// Specification for options + #[arg(positional, stop_flags)] + options_string: String, + + /// Name of variable to receive next option + #[arg(positional)] + variable_name: String, + + /// Arguments to parse + #[arg(positional, double_dash = "preserve")] + args: Vec, +} + +/// Where `getopts` is in the arguments, between invocations; kept in the +/// shell, so it follows subshell rules as `OPTIND` does. +#[derive(Clone, Debug, Default)] +struct GetOptsState { + /// How far into the current argument's bundled options it is, if partway. + next_char_index: Option, + /// The `OPTIND` it last set, to notice the script resetting it. + last_optind: Option, +} +const DEFAULT_NEXT_CHAR_INDEX: usize = 1; + +/// The result of processing one option from the argument list. +struct GetOptsResult { + /// The value to assign to the target variable (the option char, `?`, or `:`). + variable_value: String, + /// The value for OPTARG, if any (option's argument or, on error, the offending char). + optarg: Option, + /// The new value for OPTIND after this call. + optind: usize, + /// Exit code: success (0) if an option was found, general error (1) when done. + exit_code: ExecutionResult, +} + +/// Parsed representation of the optstring (e.g., `":a:bc"`). +struct OptionSpec { + /// Maps each option character to whether it requires an argument. + defs: HashMap, + /// True when the optstring has a leading `:`, suppressing error messages + /// and reporting errors via `?`/`:` in the variable and OPTARG instead. + silent_errors: bool, +} + +/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error +/// mode. Each letter defines an option; a `:` immediately after a letter means it +/// takes an argument. Duplicate letters are ignored (first definition wins). +fn parse_option_spec(spec: &str) -> OptionSpec { + let mut defs = HashMap::::new(); + let mut silent_errors = false; + + let mut last_char = None; + for c in spec.chars() { + if c == ':' { + if let Some(last_char) = last_char { + defs.insert(last_char, true); + } else if defs.is_empty() { + // First character is ':' — request silent error reporting. + silent_errors = true; + } + continue; + } + + if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) { + e.insert(false); + last_char = Some(c); + } else { + // Duplicate option char; first definition wins. + // Clear last_char so a trailing colon doesn't modify the first definition. + last_char = None; + } + } + + OptionSpec { + defs, + silent_errors, + } +} + +brush_winnow_args_adapter::impl_parse_args!(GetOptsCommand, keep_double_dash = args); + +impl builtins::Builtin for GetOptsCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Validate the target variable name. + if !env::valid_variable_name(&self.variable_name) { + writeln!( + context.stderr(), + "{}: `{}': not a valid identifier", + context.command_name, + self.variable_name + )?; + return Ok(ExecutionResult::new(1)); + } + + let spec = parse_option_spec(&self.options_string); + + // If unset or non-numeric, assume OPTIND is 1. + let next_index_signed = context + .shell + .env_str("OPTIND") + .and_then(|s| brush_core::int_utils::parse::(s.as_ref(), 10).ok()) + .unwrap_or(1); + + // Detect external OPTIND modifications (e.g., `OPTIND=1` to restart + // parsing). If the current OPTIND differs from what we last set, clear + // the internal char index so we don't resume mid-arg. + let state = context.state_mut::(); + if state.last_optind != Some(next_index_signed) { + state.next_char_index = None; + } + + #[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive + let next_index = next_index_signed.max(1) as usize; + + // Select the arguments to parse. If none were explicitly provided, we + // default to using the shell's current positional parameters. + // Clone positional params to avoid borrowing context immutably while we + // also need it mutably in parse_next_option. + let owned_args; + let args_to_parse = if !self.args.is_empty() { + &self.args + } else { + owned_args = context.shell.current_shell_args().to_vec(); + &owned_args + }; + + let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?; + + update_variables(&mut context, &self.variable_name, result) + } +} + +/// Extracts the next option from `args_to_parse` starting at 1-based position +/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both +/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or +/// missing arguments. Tracks position within combined flags via the +/// builtin's state. +fn parse_next_option( + context: &mut brush_core::ExecutionContext<'_, SE>, + spec: &OptionSpec, + args_to_parse: &[String], + mut next_index: usize, +) -> Result { + // See if there are any args left to parse. + if next_index > args_to_parse.len() { + return Ok(GetOptsResult { + variable_value: String::from("?"), + optarg: None, + // Normalize OPTIND to one past the last argument. + optind: args_to_parse.len() + 1, + exit_code: ExecutionResult::general_error(), + }); + } + + let arg = args_to_parse[next_index - 1].as_str(); + + // See if this is an option. + if !arg.starts_with('-') || arg == "--" || arg == "-" { + // Not an option. If it was "--", skip past it. + return Ok(GetOptsResult { + variable_value: String::from("?"), + optarg: None, + optind: if arg == "--" { + next_index + 1 + } else { + next_index + }, + exit_code: ExecutionResult::general_error(), + }); + } + + // Figure out how far into this option we are. + let mut next_char_index = context + .state_mut::() + .next_char_index + .unwrap_or(DEFAULT_NEXT_CHAR_INDEX); + + // Find the char. If the index is stale (exceeds this arg's length), + // reset to the default index, mirroring bash behavior. + let mut c = arg.chars().nth(next_char_index); + if c.is_none() { + next_char_index = DEFAULT_NEXT_CHAR_INDEX; + c = arg.chars().nth(next_char_index); + } + let Some(c) = c else { + // Arg is too short even at default index. + return Ok(GetOptsResult { + variable_value: String::from("?"), + optarg: None, + optind: next_index, + exit_code: ExecutionResult::general_error(), + }); + }; + + let arg_char_count = arg.chars().count(); + let mut is_last_char_in_option = next_char_index == arg_char_count - 1; + + let mut variable_value; + let optarg; + + // Look up the char in the option spec. + if let Some(takes_arg) = spec.defs.get(&c) { + variable_value = String::from(c); + + if *takes_arg { + (variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument( + context, + spec, + c, + arg, + args_to_parse, + next_char_index, + is_last_char_in_option, + next_index, + )?; + } else { + optarg = None; + } + } else { + (variable_value, optarg) = report_unknown_option(context, spec, c)?; + } + + let optind = if is_last_char_in_option { + // We're done with this argument: forget our place in it, and move + // OPTIND on. + context.state_mut::().next_char_index = None; + next_index + 1 + } else { + // We have more to go in this argument: remember our place, and leave + // OPTIND where it is. + context.state_mut::().next_char_index = Some(next_char_index + 1); + next_index + }; + + Ok(GetOptsResult { + variable_value, + optarg, + optind, + exit_code: ExecutionResult::success(), + }) +} + +/// Resolves the argument for an option that takes a value. Returns the updated +/// `(variable_value, optarg, is_last_char, next_index)` tuple. +#[allow(clippy::too_many_arguments)] +fn resolve_option_argument( + context: &brush_core::ExecutionContext<'_, SE>, + spec: &OptionSpec, + c: char, + arg: &str, + args_to_parse: &[String], + next_char_index: usize, + is_last_char_in_option: bool, + mut next_index: usize, +) -> Result<(String, Option, bool, usize), brush_core::Error> { + // If this is the last character in the token, the argument value comes from + // the next token. Otherwise, the remainder of the current token is the value. + if is_last_char_in_option { + if next_index >= args_to_parse.len() { + // Missing required argument. + let (variable_value, optarg) = if spec.silent_errors { + (String::from(":"), Some(String::from(c))) + } else { + if is_opterr_enabled(context) { + writeln!( + context.stderr(), + "getopts: option requires an argument -- {c}" + )?; + } + (String::from("?"), None) + }; + Ok((variable_value, optarg, true, next_index)) + } else { + next_index += 1; + Ok(( + String::from(c), + Some(args_to_parse[next_index - 1].clone()), + true, + next_index, + )) + } + } else { + let optarg: String = arg.chars().skip(next_char_index + 1).collect(); + Ok((String::from(c), Some(optarg), true, next_index)) + } +} + +/// Handles an unknown option character, reporting an error if appropriate. +fn report_unknown_option( + context: &brush_core::ExecutionContext<'_, SE>, + spec: &OptionSpec, + c: char, +) -> Result<(String, Option), brush_core::Error> { + if !spec.silent_errors && is_opterr_enabled(context) { + writeln!(context.stderr(), "getopts: illegal option -- {c}")?; + } + + let optarg = if spec.silent_errors { + Some(String::from(c)) + } else { + None + }; + + Ok((String::from("?"), optarg)) +} + +/// Writes the parsing result back into shell variables (the target variable, +/// OPTARG, OPTIND) and remembers the OPTIND set. +fn update_variables( + context: &mut brush_core::ExecutionContext<'_, SE>, + variable_name: &str, + result: GetOptsResult, +) -> Result { + // Update variable value. + context.shell.env_mut().update_or_add( + variable_name, + variables::ShellValueLiteral::Scalar(result.variable_value), + |_| Ok(()), + brush_core::env::EnvironmentLookup::Anywhere, + brush_core::env::EnvironmentScope::Global, + )?; + + // Update OPTARG + if let Some(optarg) = result.optarg { + context.shell.env_mut().update_or_add( + "OPTARG", + variables::ShellValueLiteral::Scalar(optarg), + |_| Ok(()), + brush_core::env::EnvironmentLookup::Anywhere, + brush_core::env::EnvironmentScope::Global, + )?; + } else { + context.shell.env_mut().unset("OPTARG")?; + } + + // Update OPTIND and record it so we can detect external modifications. + let optind_str = result.optind.to_string(); + context.shell.env_mut().update_or_add( + "OPTIND", + variables::ShellValueLiteral::Scalar(optind_str), + |_| Ok(()), + brush_core::env::EnvironmentLookup::Anywhere, + brush_core::env::EnvironmentScope::Global, + )?; + context.state_mut::().last_optind = i32::try_from(result.optind).ok(); + + Ok(result.exit_code) +} + +/// Returns whether OPTERR is enabled (i.e., getopts should print error messages). +/// OPTERR defaults to 1; any nonzero value means errors are enabled. +fn is_opterr_enabled( + context: &brush_core::ExecutionContext<'_, SE>, +) -> bool { + context + .shell + .env_str("OPTERR") + .is_none_or(|s| s.parse::().unwrap_or(1) != 0) +} diff --git a/userland/brush/source/brush-builtins/src/hash.rs b/userland/brush/source/brush-builtins/src/hash.rs new file mode 100644 index 0000000..7b21643 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/hash.rs @@ -0,0 +1,127 @@ +use std::{io::Write, path::PathBuf}; + +use brush_core::{ExecutionResult, builtins}; + +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct HashCommand { + /// Display paths in a format usable for input. + #[arg(short = 'l')] + display_as_usable_input: bool, + + /// Remove all entries. + #[arg(short = 'r')] + remove_all: bool, + + /// The path to associate with the names. + #[arg(short = 'p', value_name = "PATH")] + path_to_use: Option, + + /// Remove entries associated with the given names. + #[arg(short = 'd')] + remove: bool, + + /// Display the paths associated with the names. + #[arg(short = 't')] + display_paths: bool, + + /// Names to process. + #[arg(positional)] + names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(HashCommand); + +impl builtins::Builtin for HashCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + let cmd = &context.command_name; + + if self.remove_all { + context.shell.program_location_cache_mut().reset(); + } else if self.remove { + for name in &self.names { + if !context.shell.program_location_cache_mut().unset(name) { + writeln!(context.stderr(), "{cmd}: {name}: not found")?; + result = ExecutionResult::general_error(); + } + } + } else if self.display_paths { + for name in &self.names { + if let Some(path) = context.shell.program_location_cache().get(name) { + if self.display_as_usable_input { + writeln!( + context.stdout(), + "builtin hash -p {} {name}", + path.to_string_lossy() + )?; + } else { + let mut prefix = String::new(); + + if self.names.len() > 1 { + prefix.push_str(name.as_str()); + prefix.push('\t'); + } + + writeln!( + context.stdout(), + "{prefix}{}", + path.to_string_lossy().as_ref() + )?; + } + } else { + writeln!(context.stderr(), "{cmd}: {name}: not found")?; + result = ExecutionResult::general_error(); + } + } + } else if let Some(path) = &self.path_to_use { + // The shell's working directory is its own state, not the process's, so a + // relative path is resolved against it before it's inspected -- but reported as given. + let is_dir = context.shell.absolute_path(path).is_dir(); + + for name in &self.names { + if is_dir { + writeln!( + context.stderr(), + "{cmd}: {}: Is a directory", + path.display() + )?; + result = ExecutionResult::general_error(); + continue; + } + + context + .shell + .program_location_cache_mut() + .set(name, path.clone()); + } + } else { + for name in &self.names { + // Remove from the cache if already hashed. + let _ = context.shell.program_location_cache_mut().unset(name); + + // Names with slashes are accepted silently + if name.contains('/') { + continue; + } + + // Hash the path + if context + .shell + .find_first_executable_in_path_using_cache(name) + .is_none() + { + writeln!(context.stderr(), "{cmd}: {name}: not found")?; + result = ExecutionResult::general_error(); + } + } + } + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/help.rs b/userland/brush/source/brush-builtins/src/help.rs new file mode 100644 index 0000000..fad0ae7 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/help.rs @@ -0,0 +1,144 @@ +use brush_core::{ExecutionResult, builtins}; +use std::io::Write; + +/// Display command help. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct HelpCommand { + /// Display a short description for the commands. + #[arg(short = 'd')] + short_description: bool, + /// Display a man-style page of documentation for the commands. + #[arg(short = 'm')] + man_page_style: bool, + /// Display a short usage summary for the commands. + #[arg(short = 's')] + short_usage: bool, + /// Patterns of topics to display help for. + #[arg(positional)] + topic_patterns: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(HelpCommand); + +impl builtins::Builtin for HelpCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.topic_patterns.is_empty() { + Self::display_general_help(&context)?; + return Ok(ExecutionResult::success()); + } + + // Match bash: succeed if at least one requested topic pattern matched + // something; return a non-zero exit code only when none of them matched. + let mut any_matched = false; + for topic_pattern in &self.topic_patterns { + if self.display_help_for_topic_pattern(&context, topic_pattern)? { + any_matched = true; + } + } + + if any_matched { + Ok(ExecutionResult::success()) + } else { + Ok(ExecutionResult::general_error()) + } + } +} + +impl HelpCommand { + fn display_general_help( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result<(), brush_core::Error> { + const COLUMN_COUNT: usize = 3; + + if let Some(display_str) = context.shell.product_display_str() { + writeln!(context.stdout(), "{display_str}\n")?; + } + + writeln!( + context.stdout(), + "The following commands are implemented as shell built-ins:" + )?; + + let builtins: Vec<_> = context.shell.builtins().iter().collect(); + let items_per_column = builtins.len().div_ceil(COLUMN_COUNT); + + for i in 0..items_per_column { + for j in 0..COLUMN_COUNT { + if let Some((name, builtin)) = builtins.get(i + j * items_per_column) { + let prefix = if !builtin.is_enabled() { "*" } else { " " }; + write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired + // column width + } + } + writeln!(context.stdout())?; + } + + Ok(()) + } + + /// Displays help for the builtins matching `topic_pattern`, returning whether + /// at least one topic matched. + fn display_help_for_topic_pattern( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + topic_pattern: &str, + ) -> Result { + let pattern = brush_core::patterns::Pattern::from(topic_pattern) + .set_extended_globbing(context.shell.options().extended_globbing) + .set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion); + + let mut matched = false; + for (builtin_name, builtin_registration) in context.shell.builtins().iter() { + if pattern.exactly_matches(builtin_name)? { + self.display_help_for_builtin(context, builtin_name, builtin_registration)?; + matched = true; + } + } + + if !matched { + writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?; + } + + Ok(matched) + } + + fn display_help_for_builtin( + &self, + context: &brush_core::ExecutionContext<'_, SE>, + name: &str, + registration: &builtins::Registration, + ) -> Result<(), brush_core::Error> { + let content_type = if self.short_description { + builtins::ContentType::ShortDescription + } else if self.man_page_style { + builtins::ContentType::ManPage + } else if self.short_usage { + builtins::ContentType::ShortUsage + } else { + builtins::ContentType::DetailedHelp + }; + + let Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else { + // If there's no stdout, nothing to do. + return Ok(()); + }; + + // For now, we assume colorized output if stdout is a terminal. + let options = builtins::ContentOptions { + colorized: stdout.is_terminal(), + }; + + let content = registration.content(name, content_type, &options)?; + + write!(stdout, "{content}")?; + stdout.flush()?; + + Ok(()) + } +} diff --git a/userland/brush/source/brush-builtins/src/history.rs b/userland/brush/source/brush-builtins/src/history.rs new file mode 100644 index 0000000..2ba6af7 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/history.rs @@ -0,0 +1,273 @@ +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; +use std::{ + io::Write, + path::{Path, PathBuf}, +}; + +/// Query or manipulate the shell's command history. +// TODO(history): Evaluate which of the options conflict with each other. +#[derive(winnow_args::Args)] +#[arg( + group("anrw", message = "cannot use more than one of -anrw"), + disable_help_short, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct HistoryCommand { + /// Clears all history. + #[arg(short = 'c')] + clear_history: bool, + + /// Deletes the history entry at the given offset. Positive offsets are relative to the + /// beginning of the history, while negative offsets are relative to the end of the history. + #[arg(short = 'd', value_name = "OFFSET", allow_negative_numbers)] + delete_offset: Option, + + /// Appends the history from the current session to the history file. The + /// first argument names the file, if given. + #[arg(short = 'a', group = "anrw")] + append_session_to_file: bool, + + /// Appends any remaining history from the history file to the current session. The + /// first argument names the file, if given. + #[arg(short = 'n', group = "anrw")] + append_rest_of_file_to_session: bool, + + /// Appends the history from the history file to the current session. The first + /// argument names the file, if given. + #[arg(short = 'r', group = "anrw")] + append_file_to_session: bool, + + /// Replaces the history file with the current session history. The first + /// argument names the file, if given. + #[arg(short = 'w', group = "anrw")] + write_session_to_file: bool, + + /// History-expands positional arguments and displays them. + #[arg(short = 'p')] + expand: bool, + + /// Appends positional arguments as an entry in the current session. + #[arg(short = 's')] + append: bool, + + /// Arguments. + #[arg(positional, double_dash = "automatic", allow_negative_numbers)] + args: Vec, +} + +struct HistoryConfig { + default_history_file_path: Option, + time_format: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(HistoryCommand); + +impl builtins::Builtin for HistoryCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Retrieve the shell's history config while we still can. + let config = HistoryConfig { + default_history_file_path: context.shell.history_file_path(), + time_format: context.shell.history_time_format(), + }; + + let stdout = context.stdout(); + let stderr = context.stderr(); + + if let Some(history) = context.shell.history_mut() { + self.execute_with_history(history, &config, stdout, stderr) + } else { + Err(brush_core::ErrorKind::HistoryNotEnabled.into()) + } + } +} + +impl HistoryCommand { + #[expect(clippy::cast_possible_wrap)] + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_sign_loss)] + fn execute_with_history( + &self, + history: &mut history::History, + config: &HistoryConfig, + stdout: impl Write, + mut stderr: impl Write, + ) -> Result { + if self.clear_history { + history.clear()?; + } + + if let Some(offset) = self.delete_offset { + if offset == 0 { + writeln!(stderr, "cannot delete history item at offset 0")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + if offset > 0 { + // Convert to 0-based index. + let index = (offset - 1) as usize; + if !history.remove_nth_item(index) { + writeln!(stderr, "index past end of history")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } else { + let count = history.count() as i64; + let index = count + offset; + if index < 0 { + writeln!(stderr, "index before beginning of history")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + let _ = history.remove_nth_item(index as usize); + } + + return Ok(ExecutionResult::success()); + } + + if self.append_session_to_file { + if let Some(file_path) = self.history_file_path(config) { + history.flush( + file_path, + true, /* append? */ + true, /* unsaved items only */ + config.time_format.is_some(), /* write timestamps? */ + )?; + } + + return Ok(ExecutionResult::success()); + } + + if self.append_rest_of_file_to_session { + return error::unimp("history -n is not yet implemented"); + } + + if self.append_file_to_session { + return error::unimp("history -r is not yet implemented"); + } + + if self.write_session_to_file { + if let Some(file_path) = self.history_file_path(config) { + history.flush( + file_path, + false, /* append? */ + false, /* unsaved items only? */ + config.time_format.is_some(), /* write timestamps? */ + )?; + } + + return Ok(ExecutionResult::success()); + } + + // With -s, -p only expands the entry; history expansion isn't implemented. + if self.append { + history.add(history::Item::new(self.args.join(" ")))?; + return Ok(ExecutionResult::success()); + } + + if self.expand { + return error::unimp("history -p is not yet implemented"); + } + + let max_entries: Option = if let Some(arg) = self.args.first() { + Some(brush_core::int_utils::parse(arg.as_str(), 10)?) + } else { + None + }; + + display_history(history, config, max_entries, stdout, stderr)?; + + Ok(ExecutionResult::success()) + } + + /// The file named by the first argument, or else the shell's history file. + fn history_file_path<'a>(&'a self, config: &'a HistoryConfig) -> Option<&'a Path> { + self.args + .first() + .map(Path::new) + .or(config.default_history_file_path.as_deref()) + } +} + +fn display_history( + history: &history::History, + config: &HistoryConfig, + max_entries: Option, + mut stdout: impl Write, + _stderr: impl Write, +) -> Result<(), brush_core::Error> { + let item_count = history.count(); + let skip_count = item_count - max_entries.unwrap_or(item_count); + + for (i, item) in history.iter().skip(skip_count).enumerate() { + let mut formatted_timestamp = String::new(); + + if let Some(timestamp) = item.timestamp { + let local_timestamp = timestamp.with_timezone(&chrono::Local); + if let Some(time_format) = &config.time_format { + let fmt_items = chrono::format::StrftimeItems::new(time_format); + formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string(); + } + } + + // Output format is something like: + // 1 echo hello world + std::writeln!( + stdout, + "{:>5} {formatted_timestamp}{}", + skip_count + i + 1, + item.command_line + )?; + } + + Ok(()) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use brush_core::builtins::{Invocation, ParseArgs}; + use pretty_assertions::assert_eq; + + fn parse(words: &[&str]) -> Result { + Ok(::parse(Invocation::new( + "history", + words.iter().copied(), + ))?) + } + + #[test] + fn test_parse_dash_a() -> Result<()> { + let config = HistoryConfig { + default_history_file_path: Some(PathBuf::from("default")), + time_format: None, + }; + + let cmd = parse(&["5"])?; + assert!(!cmd.append_session_to_file); + + let cmd = parse(&["-a"])?; + assert!(cmd.append_session_to_file); + assert_eq!(cmd.history_file_path(&config), Some(Path::new("default"))); + + for words in [ + &["-a", "token"][..], + &["-a", "--", "token"], + &["-ac", "token"], + ] { + let cmd = parse(words)?; + assert!(cmd.append_session_to_file); + assert_eq!(cmd.history_file_path(&config), Some(Path::new("token"))); + } + + assert!(parse(&["-atoken"]).is_err()); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-builtins/src/jobs.rs b/userland/brush/source/brush-builtins/src/jobs.rs new file mode 100644 index 0000000..6a17dad --- /dev/null +++ b/userland/brush/source/brush-builtins/src/jobs.rs @@ -0,0 +1,86 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, error, jobs}; + +/// Manage jobs. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct JobsCommand { + /// Also show process IDs. + #[arg(short = 'l')] + also_show_pids: bool, + + /// List only jobs that have changed status since the last notification. + #[arg(short = 'n')] + list_changed_only: bool, + + /// Show only process IDs. + #[arg(short = 'p')] + show_pids_only: bool, + + /// Show only running jobs. + #[arg(short = 'r')] + running_jobs_only: bool, + + /// Show only stopped jobs. + #[arg(short = 's')] + stopped_jobs_only: bool, + + /// Job specs to list. + // TODO(jobs): Add -x option + #[arg(positional)] + job_specs: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(JobsCommand); + +impl builtins::Builtin for JobsCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.also_show_pids { + return error::unimp("jobs -l"); + } + if self.list_changed_only { + return error::unimp("jobs -n"); + } + + if self.job_specs.is_empty() { + for job in &context.shell.jobs().jobs { + self.display_job(&context, job)?; + } + } else { + return error::unimp("jobs with job specs"); + } + + Ok(ExecutionResult::success()) + } +} + +impl JobsCommand { + fn display_job( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + job: &jobs::Job, + ) -> Result<(), brush_core::Error> { + if self.running_jobs_only && !matches!(job.state, jobs::JobState::Running) { + return Ok(()); + } + if self.stopped_jobs_only && !matches!(job.state, jobs::JobState::Stopped) { + return Ok(()); + } + + if self.show_pids_only { + if let Some(pid) = job.representative_pid() { + writeln!(context.stdout(), "{pid}")?; + } + } else { + writeln!(context.stdout(), "{job}")?; + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-builtins/src/kill.rs b/userland/brush/source/brush-builtins/src/kill.rs new file mode 100644 index 0000000..83c8ce4 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/kill.rs @@ -0,0 +1,200 @@ +use std::io::Write; + +use brush_core::traps::TrapSignal; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys}; + +/// Signal a job or process. +#[derive(winnow_args::Args)] +#[arg( + disable_help_short, + disable_version_flag, + disable_help_subcommand, + unknown_flags = "value" +)] +pub(crate) struct KillCommand { + /// Name of the signal to send. + #[arg(short = 's', value_name = "SIG_NAME")] + signal_name: Option, + + /// Number of the signal to send. + #[arg(short = 'n', value_name = "SIG_NUM")] + signal_number: Option, + + /// List known signal names. + #[arg(short = 'l', short = 'L')] + list_signals: bool, + + #[arg(positional, allow_negative_numbers, stop_flags)] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(KillCommand); + +impl builtins::Builtin for KillCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut signal_zero = false; + + // Default signal is SIGKILL. + let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL); + + // Try parsing the signal name (if specified). + if let Some(signal_name) = &self.signal_name { + if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) { + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: invalid signal name: {}", + context.command_name, + signal_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + // Try parsing the signal number (if specified). + if let Some(signal_number) = &self.signal_number { + if *signal_number == 0 { + signal_zero = true; + } else { + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_possible_wrap)] + if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) { + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: invalid signal number: {}", + context.command_name, + signal_number + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + } + + // Look through the remaining args for a pid/job spec or a -sigspec style option. + let mut pid_or_job_spec = None; + for arg in &self.args { + // See if this is -sigspec syntax. The sigspec may be a signal name + // (e.g., -TERM) or a signal number (e.g., -9, including -0). + if let Some(possible_sigspec) = arg.strip_prefix("-") { + if Ok(0) == possible_sigspec.parse::() { + signal_zero = true; + } else if let Ok(parsed_trap_signal) = possible_sigspec.parse::() { + signal_zero = false; + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: {}: invalid signal specification", + context.command_name, + possible_sigspec + )?; + return Ok(ExecutionResult::general_error()); + } + } else if pid_or_job_spec.is_none() { + pid_or_job_spec = Some(arg); + } else { + writeln!( + context.stderr(), + "{}: too many jobs or processes specified", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + if self.list_signals { + return print_signals(&context, self.args.as_ref()); + } else { + let Some(pid_or_job_spec) = pid_or_job_spec else { + writeln!(context.stderr(), "{}: invalid usage", context.command_name)?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + }; + + if pid_or_job_spec.starts_with('%') { + // It's a job spec. + if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) { + if signal_zero { + job.check_signalable()?; + } else { + job.kill(trap_signal)?; + } + } else { + writeln!( + context.stderr(), + "{}: {}: no such job", + context.command_name, + pid_or_job_spec + )?; + return Ok(ExecutionResult::general_error()); + } + } else { + let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?; + + // It's a pid. + if signal_zero { + sys::signal::check_signalable(pid)?; + } else { + sys::signal::kill_process(pid, trap_signal)?; + } + } + } + Ok(ExecutionResult::success()) + } +} + +fn print_signals( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signals: &[String], +) -> Result { + let mut exit_code = ExecutionResult::success(); + if !signals.is_empty() { + for s in signals { + // If the user gives us a code, we print the name; if they give a name, we print its + // code. + enum PrintSignal { + Name(&'static str), + Num(i32), + } + + let signal = if let Ok(n) = s.parse::() { + // bash compatibility. `SIGHUP` -> `HUP` + TrapSignal::try_from(n).map(|s| { + PrintSignal::Name(s.as_str().strip_prefix("SIG").unwrap_or(s.as_str())) + }) + } else { + TrapSignal::try_from(s.as_str()).map(|sig| { + i32::try_from(sig).map_or(PrintSignal::Name(sig.as_str()), PrintSignal::Num) + }) + }; + + match signal { + Ok(PrintSignal::Num(n)) => { + writeln!(context.stdout(), "{n}")?; + } + Ok(PrintSignal::Name(s)) => { + writeln!(context.stdout(), "{s}")?; + } + Err(e) => { + writeln!(context.stderr(), "{e}")?; + exit_code = ExecutionResult::general_error(); + } + } + } + } else { + return brush_core::traps::format_signals( + context.stdout(), + TrapSignal::iterator().filter(|s| !matches!(s, TrapSignal::Exit)), + ) + .map(|()| ExecutionResult::success()); + } + + Ok(exit_code) +} diff --git a/userland/brush/source/brush-builtins/src/let_.rs b/userland/brush/source/brush-builtins/src/let_.rs new file mode 100644 index 0000000..af3b6ca --- /dev/null +++ b/userland/brush/source/brush-builtins/src/let_.rs @@ -0,0 +1,52 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, arithmetic::Evaluatable, builtins}; + +/// Evaluate arithmetic expressions. +#[derive(winnow_args::Args)] +#[arg( + disable_help_short, + disable_version_flag, + disable_help_subcommand, + unknown_flags = "value" +)] +pub(crate) struct LetCommand { + /// Arithmetic expressions to evaluate. + #[arg(positional, double_dash = "automatic", allow_negative_numbers)] + exprs: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(LetCommand); + +impl builtins::Builtin for LetCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + + if self.exprs.is_empty() { + writeln!( + context.stderr(), + "{}: expression expected", + context.command_name + )?; + return Ok(ExecutionResult::general_error()); + } + + for expr in &self.exprs { + let parsed = brush_parser::arithmetic::parse(expr.as_str())?; + let evaluated = parsed.eval(context.shell)?; + + if evaluated == 0 { + result = ExecutionResult::general_error(); + } else { + result = ExecutionResult::success(); + } + } + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/lib.rs b/userland/brush/source/brush-builtins/src/lib.rs new file mode 100644 index 0000000..acc08f2 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/lib.rs @@ -0,0 +1,140 @@ +//! Standard builtins. + +// `brush_core::builtins::Builtin::execute` is async by contract: the trait declares a desugared +// `-> impl Future<...> + Send` so that a `brush_core::builtins::Registration` can box and dispatch +// every builtin uniformly. Most builtins in this crate do purely synchronous work, so their +// `execute` bodies contain no `.await` -- that is the trait contract being honored, not a defect. +#![allow( + clippy::unused_async_trait_impl, + reason = "builtins implement a trait whose `execute` is async by contract" +)] + +#[cfg(feature = "builtin.alias")] +mod alias; +#[cfg(feature = "builtin.bg")] +mod bg; +#[cfg(feature = "builtin.bind")] +mod bind; +#[cfg(feature = "builtin.break")] +mod break_; +#[cfg(feature = "builtin.builtin")] +mod builtin_; +#[cfg(feature = "builtin.caller")] +mod caller; +#[cfg(feature = "builtin.cd")] +mod cd; +#[cfg(feature = "builtin.colon")] +mod colon; +#[cfg(feature = "builtin.command")] +mod command; +#[cfg(any( + feature = "builtin.complete", + feature = "builtin.compgen", + feature = "builtin.compopt" +))] +mod complete; +#[cfg(feature = "builtin.continue")] +mod continue_; +#[cfg(feature = "builtin.declare")] +mod declare; +#[cfg(feature = "builtin.dirs")] +mod dirs; +#[cfg(feature = "builtin.dot")] +mod dot; +#[cfg(feature = "builtin.echo")] +mod echo; +#[cfg(feature = "builtin.enable")] +mod enable; +#[cfg(feature = "builtin.eval")] +mod eval; +#[cfg(feature = "builtin.exec")] +mod exec; +#[cfg(feature = "builtin.exit")] +mod exit; +#[cfg(feature = "builtin.export")] +mod export; +#[cfg(feature = "builtin.false")] +mod false_; +#[cfg(feature = "builtin.fc")] +mod fc; +#[cfg(feature = "builtin.fg")] +mod fg; +#[cfg(feature = "builtin.getopts")] +mod getopts; +#[cfg(feature = "builtin.hash")] +mod hash; +#[cfg(feature = "builtin.help")] +mod help; +#[cfg(feature = "builtin.history")] +mod history; +#[cfg(feature = "builtin.jobs")] +mod jobs; +#[cfg(all(feature = "builtin.kill", unix))] +mod kill; +#[cfg(feature = "builtin.let")] +mod let_; +#[cfg(feature = "builtin.mapfile")] +mod mapfile; +#[cfg(feature = "builtin.popd")] +mod popd; +#[cfg(all(feature = "builtin.printf", any(unix, windows, target_os = "wasi")))] +mod printf; +#[cfg(feature = "builtin.pushd")] +mod pushd; +#[cfg(feature = "builtin.pwd")] +mod pwd; +#[cfg(feature = "builtin.read")] +mod read; +#[cfg(feature = "builtin.return")] +mod return_; +#[cfg(feature = "builtin.set")] +mod set; +#[cfg(feature = "builtin.shift")] +mod shift; +#[cfg(feature = "builtin.shopt")] +mod shopt; +#[cfg(all(feature = "builtin.suspend", unix))] +mod suspend; +#[cfg(feature = "builtin.test")] +mod test; +#[cfg(feature = "builtin.times")] +mod times; +#[cfg(feature = "builtin.trap")] +mod trap; +#[cfg(feature = "builtin.true")] +mod true_; +#[cfg(feature = "builtin.type")] +mod type_; +#[cfg(all(feature = "builtin.ulimit", unix))] +mod ulimit; +#[cfg(all(feature = "builtin.umask", unix))] +mod umask; +#[cfg(feature = "builtin.unalias")] +mod unalias; +#[cfg(feature = "builtin.unset")] +mod unset; +#[cfg(feature = "builtin.wait")] +mod wait; + +mod builder; +mod factory; +#[cfg(any(feature = "builtin.command", feature = "builtin.type"))] +mod lookup; +mod unimp; + +pub use builder::ShellBuilderExt; +pub use factory::{BuiltinSet, default_builtins}; + +/// Writes an alias definition in the reusable form printed by `alias` and `command -v`. +#[cfg(any(feature = "builtin.alias", feature = "builtin.command"))] +fn write_alias_definition( + mut writer: impl std::io::Write, + name: &str, + value: &str, +) -> std::io::Result<()> { + writeln!( + writer, + "alias {name}={}", + brush_core::escape::single_quote(value) + ) +} diff --git a/userland/brush/source/brush-builtins/src/lookup.rs b/userland/brush/source/brush-builtins/src/lookup.rs new file mode 100644 index 0000000..ae82674 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/lookup.rs @@ -0,0 +1,156 @@ +//! Shared command-name resolution, used by the `type` and `command` builtins. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use brush_core::{ + Shell, ShellExtensions, + parser::ast, + pathsearch, + sys::{self, fs::PathExt}, +}; + +/// A way in which a name resolved, in the shell's lookup order. +pub(crate) enum Resolved<'a> { + /// An alias, with its target. + Alias(String), + /// A shell keyword. + Keyword, + /// A shell function, with its definition. + Function(&'a ast::FunctionDefinition), + /// A built-in command. + Builtin, + /// An executable file; `hashed` indicates it came from the program location cache. + File { path: PathBuf, hashed: bool }, +} + +/// Options for [`resolve`]; the defaults match a plain `type NAME` lookup. +#[derive(Default)] +pub(crate) struct Options { + /// Only search the filesystem, even if the name is an alias, keyword, function, or builtin. + pub force_path_search: bool, + /// Don't consider functions when resolving the name. + pub suppress_func_lookup: bool, + /// Report every location the name resolves to, not just the first. + pub all_locations: bool, + /// Directories to search for executables, in lieu of the shell's `PATH`. The shell's + /// hash-based path cache is still consulted first. + pub path_dirs: Option>, +} + +/// Resolves the given name, returning the ways it resolved (in lookup order). Unless +/// `all_locations` was requested, at most one way is returned. +pub(crate) fn resolve<'a, SE: ShellExtensions>( + shell: &'a Shell, + name: &str, + options: &Options, +) -> Vec> { + let mut resolved = vec![]; + + // These are all hash lookups, so there's nothing to save by stopping at the first hit; + // any extras are trimmed off at the end. + if !options.force_path_search { + // Check for aliases. They're only reported when alias expansion is enabled, since + // that's the only case in which the name would actually resolve to the alias. + if shell.options().expand_aliases + && let Some(target) = shell.aliases().get(name) + { + resolved.push(Resolved::Alias(target.clone())); + } + + // Check for keywords. + if shell.is_keyword(name) { + resolved.push(Resolved::Keyword); + } + + // Check for functions. + if !options.suppress_func_lookup + && let Some(registration) = shell.funcs().get(name) + { + resolved.push(Resolved::Function(registration.definition())); + } + + // Check for builtins. + if shell.builtins().get(name).is_some_and(|b| b.is_enabled()) { + resolved.push(Resolved::Builtin); + } + } + + // Searching the filesystem *does* cost something, so only do it if the results so far + // don't already answer the question. + if options.all_locations || resolved.is_empty() { + resolve_in_filesystem(shell, name, options, &mut resolved); + } + + if !options.all_locations { + resolved.truncate(1); + } + + resolved +} + +/// Appends the files the given name resolves to. +fn resolve_in_filesystem( + shell: &Shell, + name: &str, + options: &Options, + resolved: &mut Vec>, +) { + let to_file = |path| Resolved::File { + path, + hashed: false, + }; + + // A name with a separator in it is used as-is; it's never searched for. + if sys::fs::contains_path_separator(name) { + // A directory is never a command, even though it carries the execute bit. + let candidate = shell.absolute_path(Path::new(name)); + if !candidate.is_dir() && candidate.executable() { + resolved.push(to_file(PathBuf::from(name))); + } + return; + } + + // Reporting every location is a strict search for executables; reporting just the one the + // name resolves to matches what the shell would actually try to run, which can be a + // non-executable file if the search turns up nothing better. + if let Some(path) = shell.program_location_cache().get(name) { + resolved.push(Resolved::File { path, hashed: true }); + if !options.all_locations { + return; + } + } + + match (&options.path_dirs, options.all_locations) { + (Some(dirs), true) => { + resolved.extend(pathsearch::search_for_executable(dirs.iter(), name).map(to_file)); + } + (Some(dirs), false) => { + resolved.extend(pathsearch::resolve_command(dirs.iter(), name).map(to_file)); + } + (None, true) => resolved.extend(shell.find_executables_in_path(name).map(to_file)), + (None, false) => resolved.extend(shell.resolve_command_in_path(name).map(to_file)), + } +} + +/// Writes the description shown by `type NAME` and `command -V NAME`, newline included. +pub(crate) fn describe( + mut writer: impl Write, + name: &str, + resolved: &Resolved<'_>, +) -> std::io::Result<()> { + match resolved { + Resolved::Alias(target) => writeln!(writer, "{name} is aliased to `{target}'"), + Resolved::Keyword => writeln!(writer, "{name} is a shell keyword"), + Resolved::Function(def) => writeln!(writer, "{name} is a function\n{def}"), + Resolved::Builtin => writeln!(writer, "{name} is a shell builtin"), + Resolved::File { path, hashed } => { + let path = path.to_string_lossy(); + if *hashed { + writeln!(writer, "{name} is hashed ({path})") + } else { + writeln!(writer, "{name} is {path}") + } + } + } +} diff --git a/userland/brush/source/brush-builtins/src/mapfile.rs b/userland/brush/source/brush-builtins/src/mapfile.rs new file mode 100644 index 0000000..5e857d8 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/mapfile.rs @@ -0,0 +1,212 @@ +use std::io::{Read, Write}; + +use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables}; + +/// Read lines from standard input into an indexed array variable. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct MapFileCommand { + /// Delimiter to use (defaults to newline). + #[arg(short = 'd')] + delimiter: Option, + + /// Maximum number of entries to read (0 means no limit). + #[arg(short = 'n', default = "0", parse_with = |s: &str| at_least(s, 0))] + max_count: i64, + + /// Index into array at which to start assignment. + #[arg(short = 'O', allow_negative_numbers)] + origin: Vec, + + /// Number of initial entries to skip. + #[arg(short = 's', default = "0", parse_with = |s: &str| at_least(s, 0))] + skip_count: i64, + + /// Whether or not to remove the delimiter from each read line. + #[arg(short = 't')] + remove_delimiter: bool, + + /// File descriptor to read from (defaults to stdin). + #[arg(short = 'u')] + fd: Vec, + + /// Name of function to call for each group of lines. + #[arg(short = 'C')] + callback: Option, + + /// Number of lines to pass the callback for each group. + #[arg(short = 'c', default = "5000", parse_with = |s: &str| at_least(s, 1))] + callback_group_size: i64, + + /// Name of array to read into. + #[arg(positional, default = "MAPFILE")] + array_var_name: String, +} + +/// A count of at least `min`. Each occurrence is checked as it is parsed, so a +/// later valid one doesn't hide an earlier invalid one. +fn at_least(text: &str, min: i64) -> Result { + let count: i64 = text.parse().map_err(|e| format!("{e}"))?; + if count < min { + return Err("out of range".to_owned()); + } + Ok(count) +} + +brush_winnow_args_adapter::impl_parse_args!(MapFileCommand); + +impl builtins::Builtin for MapFileCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.callback_group_size != 5000 || self.callback.is_some() { + return error::unimp("mapfile -C/-c is not yet implemented"); + } + + // Every -O and -u given is checked before reading, and the last one is used. + if let Some(origin) = self.origin.iter().find(|origin| **origin < 0) { + writeln!( + context.stderr(), + "{}: {origin}: invalid array origin", + context.command_name + )?; + return Ok(ExecutionExitCode::GeneralError.into()); + } + let origin = self.origin.last().copied(); + + if let Some((_, var)) = context.shell.env().get(&self.array_var_name) { + if var.value().is_associative_array() { + writeln!( + context.stderr(), + "{}: {}: not an indexed array", + context.command_name, + self.array_var_name + )?; + return Ok(ExecutionExitCode::GeneralError.into()); + } + } + + let mut fd = brush_core::openfiles::OpenFiles::STDIN_FD; + for &given in &self.fd { + fd = given; + context + .try_fd(fd) + .ok_or_else(|| ErrorKind::BadFileDescriptor(fd))?; + } + let input_file = context + .try_fd(fd) + .ok_or_else(|| ErrorKind::BadFileDescriptor(fd))?; + + // Read! + let lines = self.read_lines(input_file)?; + + if let Some(origin) = origin { + // -O: preserve existing array, assign at offset. + for (elem_idx, value) in lines.into_iter().enumerate() { + // If the user is getting to wraparounds in *bash*, they got bigger problems. + #[allow(clippy::cast_possible_wrap)] + let elem_idx = elem_idx as i64; + context.shell.env_mut().update_or_add_array_element( + &self.array_var_name, + (elem_idx + origin).to_string(), + value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + } + } else { + // No -O: replace the entire variable (clears existing). + context.shell.env_mut().update_or_add( + &self.array_var_name, + variables::ShellValueLiteral::from(lines), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + } + + Ok(ExecutionResult::success()) + } +} + +impl MapFileCommand { + fn read_lines( + &self, + mut input_file: brush_core::openfiles::OpenFile, + ) -> Result, brush_core::Error> { + let _term_mode = setup_terminal_settings(&input_file)?; + + let mut entries = vec![]; + let mut read_count = 0; + let max_count = self.max_count.try_into()?; + let delimiter = match &self.delimiter { + Some(d) if d.is_empty() => b'\0', + Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'), + None => b'\n', + }; + + let mut buf = [0u8; 1]; + + while max_count == 0 || entries.len() < max_count { + let mut line = vec![]; + let mut saw_delimiter = false; + + loop { + match input_file.read(&mut buf) { + Ok(0) => break, // End of input + Ok(1) if buf[0] == b'\x03' => break, // Ctrl+C + Ok(1) if buf[0] == b'\x04' && line.is_empty() => break, // Ctrl+D + Ok(1) => { + let byte = buf[0]; + line.push(byte); + if byte == delimiter { + saw_delimiter = true; + break; + } + } + Ok(_) => unreachable!("input can only be 0, 1, or error"), + Err(e) => return Err(e.into()), + } + } + + if line.is_empty() && !saw_delimiter { + break; + } + + if read_count < self.skip_count { + read_count += 1; + continue; + } + + if self.remove_delimiter && line.ends_with(&[delimiter]) { + line.pop(); + } + + let line_str = String::from_utf8_lossy(&line).to_string(); + + entries.push(line_str); + } + + Ok(entries) + } +} + +fn setup_terminal_settings( + file: &brush_core::openfiles::OpenFile, +) -> Result, brush_core::Error> { + let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok(); + if let Some(mode) = &mode { + let config = brush_core::terminal::Settings::builder() + .line_input(false) + .interrupt_signals(false) + .build(); + + mode.apply_settings(&config)?; + } + + Ok(mode) +} diff --git a/userland/brush/source/brush-builtins/src/popd.rs b/userland/brush/source/brush-builtins/src/popd.rs new file mode 100644 index 0000000..c6bab90 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/popd.rs @@ -0,0 +1,37 @@ +use brush_core::{ExecutionResult, builtins}; + +/// Pop a path from the current directory stack. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct PopdCommand { + /// Pop the path without changing the current working directory. + #[arg(short = 'n')] + no_directory_change: bool, + // TODO(popd): implement +N and -N +} + +brush_winnow_args_adapter::impl_parse_args!(PopdCommand); + +impl builtins::Builtin for PopdCommand { + type Error = crate::dirs::DirError; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if let Some(popped) = context.shell.directory_stack_mut().pop() { + if !self.no_directory_change { + context.shell.set_working_dir(&popped)?; + } + + // Display dirs. + let dirs_cmd = crate::dirs::DirsCommand::default(); + // The change to the stack decides the status, not the listing. + let _ = dirs_cmd.execute(context).await?; + + Ok(ExecutionResult::success()) + } else { + Err(crate::dirs::DirError::DirStackEmpty) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/printf.rs b/userland/brush/source/brush-builtins/src/printf.rs new file mode 100644 index 0000000..099a6b0 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/printf.rs @@ -0,0 +1,252 @@ +use std::{ffi::OsString, io::Write, ops::ControlFlow}; +use uucore::format; + +use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion}; + +/// Format a string. +#[derive(winnow_args::Args)] +#[arg(disable_help_flag, disable_version_flag, disable_help_subcommand)] +pub(crate) struct PrintfCommand { + /// If specified, the output of the command is assigned to this variable. + #[arg(short = 'v', allow_hyphen_values)] + output_variables: Vec, + + /// Format string + arguments to the format string. + #[arg(positional, stop_flags, required)] + format_and_args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(PrintfCommand); + +impl builtins::Builtin for PrintfCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Every -v name must be valid, not just the last one, which is used. + let parser_options = context.shell.parser_options(); + if let Some(invalid) = self + .output_variables + .iter() + .find(|name| !valid_output_variable(name, &parser_options)) + { + writeln!( + context.stderr(), + "{}: `{invalid}': not a valid identifier", + context.command_name + )?; + return Ok(ExecutionResult::new(2)); + } + + if let Some(variable_name) = self.output_variables.last() { + // Format to a u8 vector. + let mut result: Vec = vec![]; + format(self.format_and_args.as_slice(), &mut result)?; + + // Convert to a string. + let result_str = String::from_utf8(result).map_err(|_| { + brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into()) + })?; + + // Assign to the selected variable. + expansion::assign_to_named_parameter( + context.shell, + &context.params, + variable_name, + result_str, + ) + .await?; + } else { + format(self.format_and_args.as_slice(), context.stdout())?; + context.stdout().flush()?; + } + + Ok(ExecutionResult::success()) + } +} + +/// Whether `name` is a variable name, or one followed by a non-empty `[subscript]`, +/// by the grammar of an assignment's name. The subscript isn't evaluated here. +fn valid_output_variable(name: &str, options: &brush_parser::ParserOptions) -> bool { + use brush_parser::ast::{AssignmentName, AssignmentValue}; + match brush_parser::word::parse_scalar_assignment(&format!("{name}="), options) { + Ok(assignment) if !assignment.append => { + matches!(&assignment.value, AssignmentValue::Scalar(word) if word.value.is_empty()) + && match assignment.name { + AssignmentName::VariableName(_) => true, + AssignmentName::ArrayElementName(_, index) => !index.is_empty(), + } + } + _ => false, + } +} + +fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> { + match format_and_args { + // Handle format string with arguments using uucore + [fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer), + // Handle case with no format string (we shouldn't be able to get here since parsing + // fails when the format string is missing) + [] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()), + } +} + +fn format_via_uucore( + format_string: &str, + args: impl Iterator>, + mut writer: impl Write, +) -> Result<(), brush_core::Error> { + // Convert string arguments to FormatArgument::Unparsed + let format_args: Vec<_> = args + .map(|s| format::FormatArgument::Unparsed(s.into())) + .collect(); + + // Parse format string once. + let format_items = parse_format_string(format_string)?; + + // Wrap the format arguments. + let mut format_args_wrapper = format::FormatArguments::new(&format_args); + + // Determine whether the format string contains any specifiers that consume arguments. If it + // doesn't, then we must only run through it once -- even when extra arguments are provided -- + // since otherwise we'd loop forever waiting for arguments that will never be consumed. This + // matches the behavior of other shells, which print such a format string exactly once. + let format_consumes_args = format_items + .iter() + .any(|(item, _)| matches!(item, format::FormatItem::Spec(_))); + + // Keep going until we've exhausted all format arguments. Also make sure to run at least once + // even if there's no format arguments. + while format_args.is_empty() || !format_args_wrapper.is_exhausted() { + // Process all format items, in order. We'll bail when we're told to stop. + for (item, backslash_quote) in &format_items { + if let (format::FormatItem::Spec(format::Spec::QuotedString { position }), true) = + (item, *backslash_quote) + { + let arg = format_args_wrapper.next_string(*position).to_string_lossy(); + let quoted = quote_printf_q(&arg); + write!(writer, "{quoted}")?; + continue; + } + + let control_flow = item + .write(&mut writer, &mut format_args_wrapper) + .map_err(|e| match e { + // Propagate I/O errors directly so they can be handled appropriately + format::FormatError::IoError(io_err) => Error::from(io_err), + // Wrap other format errors + other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!( + "printf formatting error: {other}" + ))), + })?; + + if control_flow == ControlFlow::Break(()) { + break; + } + } + + // If the format string doesn't consume any arguments, stop now; otherwise we'd reprocess + // it forever since no arguments will ever be consumed. + if !format_consumes_args { + break; + } + + // Start next batch if not exhausted + if !format_args_wrapper.is_exhausted() { + format_args_wrapper.start_next_batch(); + } + + if format_args.is_empty() { + break; + } + } + + Ok(()) +} + +fn quote_printf_q(s: &str) -> String { + // Like bash's, this escapes commas too, so the result is safe even inside a brace + // expansion. + let options = escape::QuoteOptions::builder() + .preferred_mode(escape::QuoteMode::BackslashEscape) + .quote_commas(true) + .build(); + escape::quote(s, &options).into_owned() +} + +type ParsedFormatItem = (format::FormatItem, bool); + +fn parse_format_string(format_string: &str) -> Result, brush_core::Error> { + let format_items: Result, _> = format::parse_spec_and_escape(format_string.as_bytes()) + .map(|result| match result { + Ok(item @ format::FormatItem::Spec(format::Spec::QuotedString { .. })) => { + Ok((item, true)) + } + Ok(item) => Ok((item, false)), + // Fixed q/Q modifiers are deliberately ignored; dynamic modifiers remain unsupported + // until uucore exposes quoted-string metadata. + Err(format::FormatError::SpecError(spec, span)) + if matches!(spec.last(), Some(b'q' | b'Q')) + && !spec.contains(&b'*') + && !spec.contains(&b'$') => + { + let mut bare_q: &[u8] = b"q"; + let item = format::Spec::parse(&mut bare_q) + .map(format::FormatItem::Spec) + .map_err(|spec| format::FormatError::SpecError(spec.to_vec(), span))?; + Ok((item, !spec.contains(&b'#'))) + } + Err(error) => Err(error), + }) + .collect(); + + // Observe any errors we encountered along the way. + let format_items = format_items + .map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?; + + Ok(format_items) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + fn sprintf_via_uucore( + format_string: &str, + args: impl Iterator>, + ) -> Result { + let mut result = vec![]; + format_via_uucore(format_string, args, &mut result)?; + + Ok(String::from_utf8(result)?) + } + + #[test] + fn test_basic_sprintf() -> Result<()> { + assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz"); + assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n"); + + Ok(()) + } + + #[test] + fn test_sprintf_without_args() -> Result<()> { + let empty: [&str; 0] = []; + + assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz"); + assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|"); + + Ok(()) + } + + #[test] + fn test_sprintf_with_cycles() -> Result<()> { + assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|"); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-builtins/src/pushd.rs b/userland/brush/source/brush-builtins/src/pushd.rs new file mode 100644 index 0000000..9ef02ed --- /dev/null +++ b/userland/brush/source/brush-builtins/src/pushd.rs @@ -0,0 +1,68 @@ +use brush_core::{ExecutionResult, builtins}; + +/// Push a path onto the current directory stack. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct PushdCommand { + /// Push the path without changing the current working directory. + #[arg(short = 'n')] + no_directory_change: bool, + + /// Directory to push on the directory stack. Without one, the top two + /// directories on the stack are exchanged. + #[arg(positional)] + dir: Option, + // TODO(pushd): implement +N and -N +} + +brush_winnow_args_adapter::impl_parse_args!(PushdCommand); + +impl builtins::Builtin for PushdCommand { + type Error = crate::dirs::DirError; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if let Some(dir) = &self.dir { + if self.no_directory_change { + context + .shell + .directory_stack_mut() + .push(std::path::PathBuf::from(dir)); + } else { + let prev_working_dir = context.shell.working_dir().to_path_buf(); + + let dir = std::path::Path::new(dir); + context.shell.set_working_dir(dir)?; + + context.shell.directory_stack_mut().push(prev_working_dir); + } + } else { + // With no directory, exchange the current directory with the one on top of + // the stack. + let Some(top) = context.shell.directory_stack().last().cloned() else { + return Err(crate::dirs::DirError::NoOtherDirectory); + }; + + // As in bash, -n leaves both the stack and the current directory alone here. + if self.no_directory_change { + return Ok(ExecutionResult::success()); + } + + let prev_working_dir = context.shell.working_dir().to_path_buf(); + context.shell.set_working_dir(&top)?; + + let stack = context.shell.directory_stack_mut(); + stack.pop(); + stack.push(prev_working_dir); + } + + // Display dirs. + let dirs_cmd = crate::dirs::DirsCommand::default(); + // The change to the stack decides the status, not the listing. + let _ = dirs_cmd.execute(context).await?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/pwd.rs b/userland/brush/source/brush-builtins/src/pwd.rs new file mode 100644 index 0000000..4b02e28 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/pwd.rs @@ -0,0 +1,47 @@ +use brush_core::{ExecutionResult, builtins}; +use std::{borrow::Cow, io::Write, path::Path}; + +/// Display the current working directory. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct PwdCommand { + /// Print $PWD if it names the current working directory. + #[arg(short = 'L', overrides = "-P")] + allow_symlinks: bool, + + /// Print the physical directory without any symlinks. + #[arg(short = 'P', overrides = "-L")] + physical: bool, + + /// Operands, which are ignored. Options before the first operand are + /// still misuse; everything after it is an operand. + #[arg(positional, stop_flags, hide)] + _operands: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(PwdCommand); + +impl builtins::Builtin for PwdCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut cwd: Cow<'_, Path> = context.shell.working_dir().into(); + + let should_canonicalize = self.physical + || context + .shell + .options() + .do_not_resolve_symlinks_when_changing_dir; + + if should_canonicalize { + cwd = cwd.canonicalize()?.into(); + } + + writeln!(context.stdout(), "{}", cwd.to_string_lossy())?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/read.rs b/userland/brush/source/brush-builtins/src/read.rs new file mode 100644 index 0000000..903ea8d --- /dev/null +++ b/userland/brush/source/brush-builtins/src/read.rs @@ -0,0 +1,906 @@ +use itertools::Itertools; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use brush_core::{ErrorKind, builtins, env, error, variables}; + +use std::io::{Read, Write}; +use utf8_chars::BufReadCharsExt; + +/// Exit code returned when `read` times out. +/// This is 128 + SIGALRM (14) = 142, matching bash behavior. +const TIMEOUT_EXIT_CODE: u8 = 142; + +/// ASCII control character for Ctrl+C (ETX - End of Text). +const CTRL_C: char = '\x03'; +/// ASCII control character for Ctrl+D (EOT - End of Transmission). +const CTRL_D: char = '\x04'; +/// Backslash character used for escape processing. +const BACKSLASH: char = '\\'; +/// Default line delimiter (newline). +const DEFAULT_DELIMITER: char = '\n'; +/// NUL character used as delimiter when `-d ''` is specified. +const NUL_DELIMITER: char = '\0'; + +/// Parse standard input. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ReadCommand { + /// Use readline-like input. + #[arg(short = 'e')] + use_readline: bool, + + /// Read input in raw mode; no escape sequences. + #[arg(short = 'r')] + raw_mode: bool, + + /// Do not echo input. + #[arg(short = 's')] + silent: bool, + + /// Optionally, name of an array variable to receive read words + /// of input. + #[arg(short = 'a', value_name = "VAR_NAME")] + array_variable: Option, + + /// Optionally, a delimiter to use other than a newline character. + #[arg(short = 'd')] + delimiter: Option, + + /// Provide text to use as initial input for readline. + #[arg(short = 'i', value_name = "STR")] + initial_text: Option, + + /// Read only the first N characters or until a specified + /// delimiter is reached, whichever happens first. + #[arg(short = 'n', value_name = "COUNT")] + return_after_n_chars: Option, + + /// Read exactly N characters, ignoring any specified delimiter. + #[arg(short = 'N', value_name = "COUNT")] + return_after_n_chars_no_delimiter: Option, + + /// Prompt to display before reading. + #[arg(short = 'p')] + prompt: Option, + + /// Specify timeout in seconds; fail if the timeout elapses before + /// input is completed. + #[arg(short = 't', value_name = "SECONDS", allow_hyphen_values)] + timeout_in_seconds: Vec, + + /// File descriptor to read from instead of stdin. + #[arg(short = 'u', value_name = "FD")] + fd_num_to_read: Vec, + + /// Optionally, names of variables to receive read input. + #[arg(positional)] + variable_names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(ReadCommand); + +impl builtins::Builtin for ReadCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.use_readline { + return error::unimp("read -e"); + } + if self.initial_text.is_some() { + return error::unimp("read -i"); + } + + // Every -t and -u given is checked before reading, and the last one is used. + let mut timeout = None; + for spec in &self.timeout_in_seconds { + let Some(parsed) = parse_timeout(spec) else { + writeln!( + context.stderr(), + "{}: {spec}: invalid timeout specification", + context.command_name + )?; + return Ok(brush_core::ExecutionResult::general_error()); + }; + timeout = Some(parsed); + } + + let mut fd_num = brush_core::openfiles::OpenFiles::STDIN_FD; + for fd in &self.fd_num_to_read { + fd_num = brush_core::ShellFd::from(*fd); + context + .try_fd(fd_num) + .ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?; + } + + // Retrieve the file. + let input_stream = context + .try_fd(fd_num) + .ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?; + + // Retrieve effective value of IFS for splitting. + // We convert to owned String to release the borrow before the mutable borrow + // needed for variable assignment. + let ifs = context.shell.ifs().into_owned(); + + // Perform the read operation (potentially with timeout). + let read_result = self.read_line(input_stream, context.stderr(), timeout)?; + + // Determine whether to skip IFS splitting (for -N option). + let skip_ifs_splitting = self.return_after_n_chars_no_delimiter.is_some(); + + // Extract the input line and determine exit code based on result. + let (input_line, result) = match &read_result { + ReadResult::Line(line) => (Some(line.clone()), brush_core::ExecutionResult::success()), + ReadResult::Eof(Some(line)) => ( + Some(line.clone()), + brush_core::ExecutionResult::general_error(), + ), + ReadResult::Eof(None) | ReadResult::Interrupted | ReadResult::InputNotReady => { + (None, brush_core::ExecutionResult::general_error()) + } + ReadResult::TimedOut(partial) => ( + partial.clone(), + brush_core::ExecutionResult::new(TIMEOUT_EXIT_CODE), + ), + ReadResult::InputReady => (None, brush_core::ExecutionResult::success()), + }; + + // Assign input to variables based on options. + assign_input_to_variables( + context.shell, + input_line.as_deref(), + &ifs, + skip_ifs_splitting, + self.array_variable.as_deref(), + &self.variable_names, + )?; + + Ok(result) + } +} + +/// Assigns read input to shell variables based on the specified options. +/// +/// This handles three modes: +/// - Array mode (`-a`): Split input by IFS and assign to array elements +/// - Named variables: Split input by IFS and assign to each variable, with remainder to last +/// - Default (`REPLY`): Assign entire input line to the `REPLY` variable +fn assign_input_to_variables( + shell: &mut brush_core::Shell, + input_line: Option<&str>, + ifs: &str, + skip_ifs_splitting: bool, + array_variable: Option<&str>, + variable_names: &[String], +) -> Result<(), brush_core::Error> { + if let Some(array_variable) = array_variable { + let literal_fields = build_array_fields(input_line, ifs, skip_ifs_splitting); + shell.env_mut().update_or_add( + array_variable, + variables::ShellValueLiteral::Array(variables::ArrayLiteral::new(literal_fields)), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + } else if !variable_names.is_empty() { + assign_to_named_variables(shell, input_line, ifs, skip_ifs_splitting, variable_names)?; + } else { + shell.env_mut().update_or_add( + "REPLY", + variables::ShellValueLiteral::Scalar(input_line.unwrap_or_default().to_owned()), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + } + Ok(()) +} + +/// Assigns split fields to named variables. +/// +/// Fields are assigned one per variable, with any remaining fields joined by space +/// and assigned to the last variable. If there are more variables than fields, +/// the extra variables are set to empty strings. +fn assign_to_named_variables( + shell: &mut brush_core::Shell, + input_line: Option<&str>, + ifs: &str, + skip_ifs_splitting: bool, + variable_names: &[String], +) -> Result<(), brush_core::Error> { + let mut fields = + build_variable_fields(input_line, ifs, skip_ifs_splitting, variable_names.len()); + + for (i, name) in variable_names.iter().enumerate() { + let is_last = i == variable_names.len() - 1; + + let value = if fields.is_empty() { + String::new() + } else if is_last { + // Last variable gets all remaining fields joined by space. + std::mem::take(&mut fields).into_iter().join(" ") + } else { + fields.pop_front().unwrap_or_default() + }; + + shell.env_mut().update_or_add( + name, + variables::ShellValueLiteral::Scalar(value), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + if is_last { + break; + } + } + Ok(()) +} + +/// Builds array field values from input, optionally splitting by IFS. +fn build_array_fields( + input_line: Option<&str>, + ifs: &str, + skip_ifs_splitting: bool, +) -> Vec<(Option, String)> { + match input_line { + Some(line) if skip_ifs_splitting => { + // With -N, don't split - put entire input as single element. + vec![(None, line.to_string())] + } + Some(line) => { + let fields: VecDeque<_> = split_line_by_ifs(ifs, line, None /* max_fields */); + fields.into_iter().map(|f| (None, f)).collect() + } + None => vec![], + } +} + +/// Builds field values from input for assignment to named variables. +fn build_variable_fields( + input_line: Option<&str>, + ifs: &str, + skip_ifs_splitting: bool, + num_variables: usize, +) -> VecDeque { + match input_line { + Some(line) if skip_ifs_splitting => { + // With -N, don't split - put entire input in first variable. + VecDeque::from([line.to_string()]) + } + Some(line) => split_line_by_ifs(ifs, line, Some(num_variables)), + None => VecDeque::new(), + } +} + +/// Result of a `read` operation. +/// +/// This enum clearly represents all possible outcomes of `read_line()`, +/// making the contract with callers explicit. +enum ReadResult { + /// Successfully read a complete line (delimiter or char limit reached). + Line(String), + /// Reached end of input. Contains any partial content read before EOF. + Eof(Option), + /// Input was interrupted (e.g., Ctrl+C). No content is returned. + Interrupted, + /// The operation timed out. Contains any partial content read before timeout. + TimedOut(Option), + /// For `-t 0`: input is immediately available (exit 0). + InputReady, + /// For `-t 0`: no input immediately available (exit 1). + InputNotReady, +} + +/// Helper struct that encapsulates the state for reading input character by character. +/// +/// This separates the concerns of character-level I/O with timeout handling from the +/// higher-level logic of line building and escape processing. +struct InputReader { + /// The input source. + input: std::io::BufReader, + /// Bytes from a malformed UTF-8 sequence, still owed to the caller one at a time. + pending: VecDeque, + /// Terminal mode guard - kept alive for RAII cleanup on drop. + /// The guard restores original terminal settings when dropped, even though + /// we don't access the field directly after construction. + /// + /// The leading underscore suppresses the "unused field" warning while making + /// it explicit this field exists solely for its `Drop` implementation. + _term_mode: Option, +} + +/// Events that can occur when reading input. +enum InputEvent { + /// A regular character was read. + Char(char), + /// End of file was reached. + Eof, + /// The read operation timed out. + Timeout, + /// Ctrl+C was pressed. + CtrlC, + /// Ctrl+D was pressed. + CtrlD, +} + +impl InputReader { + /// Creates a new input reader with optional timeout. + fn new( + input: brush_core::openfiles::OpenFile, + timeout: Option, + term_mode: Option, + ) -> Self { + Self { + input: std::io::BufReader::with_capacity( + 1, + PolledInput { + input, + // A deadline too far off to represent is no deadline at all. + deadline: timeout.and_then(|t| Instant::now().checked_add(t)), + }, + ), + pending: VecDeque::new(), + _term_mode: term_mode, + } + } + + /// Checks if input is immediately available (for `-t 0`). Returns `false` if an error + /// occurs while checking for available input. + fn check_input_available(&self) -> bool { + brush_core::sys::poll::poll_for_input(&self.input.get_ref().input, Duration::ZERO) + .unwrap_or(false) + } + + /// Reads the next input event, handling timeout and control characters. + fn read_event(&mut self) -> Result { + let ch = loop { + if let Some(ch) = self.pending.pop_front() { + break ch; + } + + match self.input.read_char_raw() { + Ok(Some(ch)) => break ch, + Ok(None) => return Ok(InputEvent::Eof), + Err(e) => { + // Hand back the bytes it consumed, one at a time; the byte that broke + // the sequence was left unconsumed, for the next call to re-read. + // + // TODO(utf-8): `line` is a `String`, so an invalid byte can't + // round-trip; bash preserves it verbatim, we re-encode it. + if e.as_bytes().is_empty() { + if e.as_io_error().kind() == std::io::ErrorKind::TimedOut { + return Ok(InputEvent::Timeout); + } + return Err(e.into_io_error().into()); + } + self.pending + .extend(e.as_bytes().iter().copied().map(char::from)); + } + } + }; + + // Map control characters to events. + Ok(match ch { + CTRL_C => InputEvent::CtrlC, + CTRL_D => InputEvent::CtrlD, + _ => InputEvent::Char(ch), + }) + } +} + +/// Reads the input file one byte at a time, enforcing the deadline on every byte. +/// +/// Wrapped in a 1-byte `BufReader`, which gives us both the pushback a byte that turns +/// out not to belong to the current UTF-8 sequence needs, and the guarantee that `read` +/// never consumes more of the descriptor than it asked for -- whatever runs next may +/// want the rest. +struct PolledInput { + /// The input source. + input: brush_core::openfiles::OpenFile, + /// Optional deadline for timeout. + deadline: Option, +} + +impl Read for PolledInput { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() + || !brush_core::sys::poll::poll_for_input(&self.input, remaining)? + { + return Err(std::io::ErrorKind::TimedOut.into()); + } + } + self.input.read(buf) + } +} + +/// Configuration for line reading behavior. +struct LineReaderConfig { + /// Character that terminates input (None for -N mode). + delimiter: Option, + /// Maximum characters to read (for -n or -N). + char_limit: Option, + /// Whether to process backslash escapes (false for -r mode). + process_escapes: bool, +} + +/// Reads a complete line of input using the given reader and configuration. +/// +/// Returns a `ReadResult` indicating success, EOF, timeout, or interruption. +/// +/// Note on character counting for `-n` limit: +/// Bash counts OUTPUT characters (after escape processing) toward the limit. +/// For example, with `-n 3` and input `a\bc` (4 bytes): +/// - Bash processes: 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) → "abc" +/// - The backslash is consumed but doesn't count toward the limit +fn read_line_with_reader( + reader: &mut InputReader, + config: &LineReaderConfig, +) -> Result { + let mut line = String::new(); + // Tracked alongside `line`, which is a `String`: the limit counts characters, and + // recounting a growing string on every one of them is quadratic. + let mut output_chars = 0usize; + let mut pending_backslash = false; + + loop { + let event = reader.read_event()?; + + match event { + InputEvent::Eof => { + // Bash discards pending backslash on EOF. + return Ok(ReadResult::Eof(if line.is_empty() { + None + } else { + Some(line) + })); + } + + InputEvent::Timeout => { + // Include pending backslash on timeout (different from EOF). + if pending_backslash { + line.push(BACKSLASH); + } + return Ok(ReadResult::TimedOut(if line.is_empty() { + None + } else { + Some(line) + })); + } + + InputEvent::CtrlC => { + return Ok(ReadResult::Interrupted); + } + + InputEvent::CtrlD => { + // At line start = EOF, mid-input = flush current input. + // Bash discards pending backslash here too. + return Ok(if line.is_empty() && !pending_backslash { + ReadResult::Eof(None) + } else { + ReadResult::Line(line) + }); + } + + InputEvent::Char(ch) => { + // Handle backslash escape processing (when enabled). + if config.process_escapes { + if pending_backslash { + pending_backslash = false; + + // Backslash-delimiter is line continuation. + if let Some(delim) = config.delimiter + && ch == delim + { + continue; // Line continuation. + } + + // For other chars, add char literally (backslash consumed). + line.push(ch); + output_chars += 1; + + // Check character limit (based on output length). + if let Some(limit) = config.char_limit + && output_chars >= limit + { + return Ok(ReadResult::Line(line)); + } + continue; + } + + if ch == BACKSLASH { + pending_backslash = true; + continue; + } + } + + // Check for delimiter. + if let Some(delim) = config.delimiter + && ch == delim + { + return Ok(ReadResult::Line(line)); + } + + // Ignore non-whitespace control characters. + if ch.is_ascii_control() && !ch.is_ascii_whitespace() { + continue; + } + + line.push(ch); + output_chars += 1; + + // Check character limit (based on output length). + if let Some(limit) = config.char_limit + && output_chars >= limit + { + return Ok(ReadResult::Line(line)); + } + } + } + } +} + +impl ReadCommand { + /// Reads a line of input, optionally with a timeout. + /// + /// Handles backslash escape processing: + /// - Without `-r`: backslash-newline is line continuation, other backslashes escape the next + /// char + /// - With `-r`: backslash is treated as a literal character + fn read_line( + &self, + input_file: brush_core::openfiles::OpenFile, + mut stderr_file: impl std::io::Write, + timeout: Option, + ) -> Result { + let term_mode = self.setup_terminal_settings(&input_file)?; + + // Display prompt on stderr, but only if input is from a terminal (per bash behavior). + if let Some(prompt) = &self.prompt { + if input_file.is_terminal() { + write!(stderr_file, "{prompt}")?; + stderr_file.flush()?; + } + } + + // Determine delimiter based on options. + let delimiter = if self.return_after_n_chars_no_delimiter.is_some() { + None + } else if let Some(delimiter_str) = &self.delimiter { + if delimiter_str.is_empty() { + Some(NUL_DELIMITER) + } else { + delimiter_str.chars().next() + } + } else { + Some(DEFAULT_DELIMITER) + }; + + let char_limit = self + .return_after_n_chars_no_delimiter + .or(self.return_after_n_chars); + + // Create the input reader. + let mut reader = InputReader::new(input_file, timeout, term_mode); + + // Handle -t 0 special case: just check if input is available without reading. + if timeout == Some(Duration::ZERO) { + return Ok(if reader.check_input_available() { + ReadResult::InputReady + } else { + ReadResult::InputNotReady + }); + } + + // Configure and perform the read. + let config = LineReaderConfig { + delimiter, + char_limit, + process_escapes: !self.raw_mode, + }; + + read_line_with_reader(&mut reader, &config) + } + + fn setup_terminal_settings( + &self, + file: &brush_core::openfiles::OpenFile, + ) -> Result, brush_core::Error> { + let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok(); + if let Some(mode) = &mode { + let config = brush_core::terminal::Settings::builder() + .line_input(false) + .interrupt_signals(false) + .echo_input(!self.silent) + .build(); + + mode.apply_settings(&config)?; + } + + Ok(mode) + } +} + +/// Parses a `-t` timeout given in seconds, returning `None` if it isn't a valid timeout. +/// +/// TODO(read): Bash uses $TMOUT as a default timeout for `read` when -t is not specified. +fn parse_timeout(spec: &str) -> Option { + let secs: f64 = spec.parse().ok()?; + + // -0 is a zero timeout; other negatives, infinities and NaN are invalid. + if !secs.is_finite() || secs < 0.0 { + return None; + } + + // A timeout too long to represent is as good as one that never expires. + Some(Duration::try_from_secs_f64(secs).unwrap_or(Duration::MAX)) +} + +/// Splits a line by IFS (Internal Field Separator) according to shell rules. +/// +/// Shell IFS splitting has special rules: +/// - Whitespace IFS chars (space, tab, newline) are "IFS whitespace" +/// - Leading/trailing IFS whitespace is trimmed from the input +/// - Consecutive IFS whitespace chars act as a single delimiter +/// - Non-whitespace IFS chars each act as individual delimiters +/// - Trailing non-whitespace delimiter does NOT create an empty final field +/// +/// # Arguments +/// * `ifs` - The IFS string (typically " \t\n") +/// * `line` - The input line to split +/// * `max_fields` - Optional limit on number of fields (for `read var1 var2`) +fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option) -> VecDeque { + let ifs_chars: Vec = ifs.chars().collect(); + + // Helper to check if a char is IFS whitespace (space, tab, or newline AND in IFS). + let is_ifs_whitespace = + |c: char| -> bool { (c == ' ' || c == '\t' || c == '\n') && ifs_chars.contains(&c) }; + + // Trim leading/trailing IFS whitespace from the input. + let trimmed_line = line.trim_matches(&is_ifs_whitespace); + if trimmed_line.is_empty() { + return VecDeque::new(); + } + + let max_fields = max_fields.unwrap_or(usize::MAX); + + // State machine for splitting: + // - `consuming_whitespace_run`: Currently skipping consecutive IFS whitespace + // - `prev_was_non_ws_delim`: Previous char was a non-whitespace delimiter + // - `collecting_remainder`: We've hit max_fields, collect everything into last field + let mut fields = VecDeque::new(); + let mut current_field = String::new(); + let mut consuming_whitespace_run = false; + let mut prev_was_non_ws_delim = false; + let mut collecting_remainder = false; + + for c in trimmed_line.chars() { + // Skip consecutive IFS whitespace (they act as single delimiter). + if consuming_whitespace_run && is_ifs_whitespace(c) { + continue; + } + consuming_whitespace_run = false; + + let is_delimiter = ifs_chars.contains(&c); + let at_field_limit = fields.len() + 1 >= max_fields; + + if !at_field_limit && is_delimiter { + // Normal case: delimiter ends current field, start new one. + fields.push_back(std::mem::take(&mut current_field)); + consuming_whitespace_run = is_ifs_whitespace(c); + prev_was_non_ws_delim = !consuming_whitespace_run; + } else if at_field_limit && !collecting_remainder && is_delimiter { + // At field limit but haven't started last field content yet. + // Skip leading IFS whitespace for the final field. + if is_ifs_whitespace(c) { + consuming_whitespace_run = true; + } else { + // Non-whitespace delimiters at boundary: include in remainder. + // e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"] + collecting_remainder = true; + current_field.push(c); + } + } else { + // Regular character: add to current field. + collecting_remainder = at_field_limit; + current_field.push(c); + prev_was_non_ws_delim = false; + } + } + + // Finalize: push last field unless it's empty AND we ended with non-ws delimiter. + // e.g., "a,b,c," with IFS="," gives ["a", "b", "c"], not ["a", "b", "c", ""]. + if !current_field.is_empty() || !prev_was_non_ws_delim { + fields.push_back(current_field); + } + + fields +} + +#[cfg(test)] +mod tests { + use itertools::assert_equal; + + use super::*; + + // ==================== UTF-8 decoding tests ==================== + + // Decoding is otherwise covered end-to-end by the compat suite; what can't be + // expressed there is a partial sequence that never completes, since it needs a + // writer held open while `read` gives up on it. + // + // `-t` needs `poll_for_input`, which only the unix backend implements; elsewhere it + // reports `Unsupported`, so there's no timeout to observe. + #[cfg(unix)] + #[test] + fn test_read_times_out_mid_sequence_without_losing_the_partial_bytes() { + let (rx, mut tx) = std::io::pipe().unwrap(); + tx.write_all(b"\xc3").unwrap(); + + let mut reader = InputReader::new(rx.into(), Some(Duration::from_millis(100)), None); + let config = LineReaderConfig { + delimiter: Some(DEFAULT_DELIMITER), + char_limit: None, + process_escapes: false, + }; + + // Holding `tx` means the continuation byte never arrives, so the deadline has to + // be enforced on it and not just on the byte that started the sequence. + let result = read_line_with_reader(&mut reader, &config).unwrap(); + drop(tx); + + assert!(matches!(result, ReadResult::TimedOut(Some(line)) if line == "\u{c3}")); + } + + // ==================== split_line_by_ifs tests ==================== + + #[test] + fn test_split_line_by_ifs_basic() { + let result = split_line_by_ifs(",", "a,b,c", None); + assert_equal(result, VecDeque::from(vec!["a", "b", "c"])); + } + + #[test] + fn test_split_line_by_ifs_leading_or_trailing_space() { + let result = split_line_by_ifs(" ", " a b c ", None); + assert_equal(result, VecDeque::from(vec!["a", "b", "c"])); + } + + #[test] + fn test_split_line_by_ifs_extra_interior_space() { + let result = split_line_by_ifs(" ", "a b c", None); + assert_equal(result, VecDeque::from(vec!["a", "b", "c"])); + } + + #[test] + fn test_split_line_by_ifs_leading_non_space_delimiter() { + let result = split_line_by_ifs(",", ",a,b,c", None); + assert_equal(result, VecDeque::from(vec!["", "a", "b", "c"])); + } + + #[test] + fn test_split_line_by_ifs_trailing_non_space_delimiter() { + // Bash does NOT include empty trailing field when input ends with non-ws delimiter. + let result = split_line_by_ifs(",", "a,b,c,", None); + assert_equal(result, VecDeque::from(vec!["a", "b", "c"])); + } + + #[test] + fn test_split_line_by_ifs_max_fields() { + // With max_fields=2, remainder goes into second field. + let result = split_line_by_ifs(" ", "a b c d", Some(2)); + assert_equal(result, VecDeque::from(vec!["a", "b c d"])); + } + + #[test] + fn test_split_line_by_ifs_max_fields_with_non_ws_delimiter() { + // With max_fields and non-whitespace delimiter. + let result = split_line_by_ifs(",", "a,b,c,d", Some(2)); + assert_equal(result, VecDeque::from(vec!["a", "b,c,d"])); + } + + #[test] + fn test_split_line_by_ifs_consecutive_delimiters_at_boundary() { + // Consecutive non-whitespace delimiters at field boundary should be preserved. + // e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"] + let result = split_line_by_ifs(":", "x::y", Some(2)); + assert_equal(result, VecDeque::from(vec!["x", ":y"])); + + // Triple delimiter at boundary. + let result = split_line_by_ifs(":", "x:::y", Some(2)); + assert_equal(result, VecDeque::from(vec!["x", "::y"])); + + // Delimiter in middle of remainder is also preserved. + let result = split_line_by_ifs(":", "x:y:z:w", Some(2)); + assert_equal(result, VecDeque::from(vec!["x", "y:z:w"])); + } + + #[test] + fn test_split_line_by_ifs_mixed_delimiters() { + // Mixed whitespace and non-whitespace in IFS. + let result = split_line_by_ifs(": ", "a:b c:d", None); + assert_equal(result, VecDeque::from(vec!["a", "b", "c", "d"])); + } + + #[test] + fn test_split_line_by_ifs_empty_input() { + let result = split_line_by_ifs(" ", "", None); + assert_equal(result, VecDeque::::new()); + } + + #[test] + fn test_split_line_by_ifs_whitespace_only() { + let result = split_line_by_ifs(" ", " ", None); + assert_equal(result, VecDeque::::new()); + } + + #[test] + fn test_split_line_by_ifs_consecutive_non_ws_delimiters() { + // Consecutive non-whitespace delimiters create empty fields. + let result = split_line_by_ifs(",", "a,,b", None); + assert_equal(result, VecDeque::from(vec!["a", "", "b"])); + } + + // ==================== build_array_fields tests ==================== + + #[test] + fn test_build_array_fields_basic() { + let result = build_array_fields(Some("a b c"), " ", false); + assert_eq!( + result, + vec![ + (None, "a".to_string()), + (None, "b".to_string()), + (None, "c".to_string()) + ] + ); + } + + #[test] + fn test_build_array_fields_skip_splitting() { + // With -N option, entire input goes as single element. + let result = build_array_fields(Some("a b c"), " ", true); + assert_eq!(result, vec![(None, "a b c".to_string())]); + } + + #[test] + fn test_build_array_fields_none_input() { + let result = build_array_fields(None, " ", false); + assert_eq!(result, Vec::<(Option, String)>::new()); + } + + // ==================== build_variable_fields tests ==================== + + #[test] + fn test_build_variable_fields_basic() { + let result = build_variable_fields(Some("a b c"), " ", false, 3); + assert_equal(result, VecDeque::from(vec!["a", "b", "c"])); + } + + #[test] + fn test_build_variable_fields_fewer_vars_than_fields() { + // Last variable gets remainder. + let result = build_variable_fields(Some("a b c d"), " ", false, 2); + assert_equal(result, VecDeque::from(vec!["a", "b c d"])); + } + + #[test] + fn test_build_variable_fields_skip_splitting() { + // With -N option, entire input goes to first variable. + let result = build_variable_fields(Some("a b c"), " ", true, 3); + assert_equal(result, VecDeque::from(vec!["a b c"])); + } + + #[test] + fn test_build_variable_fields_none_input() { + let result = build_variable_fields(None, " ", false, 3); + assert_eq!(result, VecDeque::::new()); + } +} diff --git a/userland/brush/source/brush-builtins/src/return_.rs b/userland/brush/source/brush-builtins/src/return_.rs new file mode 100644 index 0000000..ca7e4b2 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/return_.rs @@ -0,0 +1,43 @@ +use std::io::Write; + +use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; + +/// Return from the current function. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ReturnCommand { + /// The exit code to return. + #[arg(positional, allow_negative_numbers)] + code: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(ReturnCommand); + +impl builtins::Builtin for ReturnCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + #[expect(clippy::cast_sign_loss)] + let code_8bit = if let Some(code_32bit) = &self.code { + (code_32bit & 0xFF) as u8 + } else { + context.shell.last_exit_status() + }; + + if context.shell.in_function() || context.shell.in_sourced_script() { + let mut result = ExecutionResult::new(code_8bit); + result.next_control_flow = ExecutionControlFlow::ReturnFromFunctionOrScript; + + Ok(result) + } else { + let _ = writeln!( + context.stderr(), + "return: can only be used in a function or sourced script" + ); + Ok(ExecutionExitCode::InvalidUsage.into()) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/set.rs b/userland/brush/source/brush-builtins/src/set.rs new file mode 100644 index 0000000..a78200f --- /dev/null +++ b/userland/brush/source/brush-builtins/src/set.rs @@ -0,0 +1,283 @@ +use std::io::Write; + +use itertools::Itertools; + +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables}; + +/// Manage set-based shell options. +#[derive(winnow_args::Args)] +#[arg( + plus_options, + disable_help_short, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct SetCommand { + #[arg(sequence)] + options: Vec, + + #[arg(positional, double_dash = "preserve", stop_flags)] + positional_args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(SetCommand, keep_double_dash = positional_args); + +/// One option, `true` for its `-` spelling and `false` for its `+` one, in command-line order. +#[derive(winnow_args::Occurrence)] +enum SetOption { + /// Export variables on modification. + #[arg(short = 'a', plus = 'a')] + Allexport(bool), + + /// Notify job termination immediately. + #[arg(short = 'b', plus = 'b')] + Notify(bool), + + /// Exit on nonzero command exit. + #[arg(short = 'e', plus = 'e')] + Errexit(bool), + + /// Disable filename globbing. + #[arg(short = 'f', plus = 'f')] + Noglob(bool), + + /// Remember command locations. + #[arg(short = 'h', plus = 'h')] + Hashall(bool), + + /// Place all assignment args in command environment. + #[arg(short = 'k', plus = 'k')] + Keyword(bool), + + /// Enable job control. + #[arg(short = 'm', plus = 'm')] + Monitor(bool), + + /// Do not execute commands. + #[arg(short = 'n', plus = 'n')] + Noexec(bool), + + /// Real effective UID mismatch. + #[arg(short = 'p', plus = 'p')] + Privileged(bool), + + /// Restricted mode. + #[arg(short = 'r', plus = 'r')] + Restricted(#[expect(dead_code, reason = "restricted mode is not implemented")] bool), + + /// Exit after one command. + #[arg(short = 't', plus = 't')] + Onecmd(bool), + + /// Treat unset variables as error. + #[arg(short = 'u', plus = 'u')] + Nounset(bool), + + /// Print shell input lines. + #[arg(short = 'v', plus = 'v')] + Verbose(bool), + + /// Print commands and arguments. + #[arg(short = 'x', plus = 'x')] + Xtrace(bool), + + /// Perform brace expansion. + #[arg(short = 'B', plus = 'B')] + Braceexpand(bool), + + /// Disallow overwriting regular files via output redirection. + #[arg(short = 'C', plus = 'C')] + Noclobber(bool), + + /// Shell functions inherit ERR trap. + #[arg(short = 'E', plus = 'E')] + Errtrace(bool), + + /// Enable bang style history substitution. + #[arg(short = 'H', plus = 'H')] + Histexpand(bool), + + /// Do not resolve symlinks when changing dir. + #[arg(short = 'P', plus = 'P')] + Physical(bool), + + /// Shell functions inherit DEBUG and RETURN traps. + #[arg(short = 'T', plus = 'T')] + Functrace(bool), + + /// Set (`-o`) or unset (`+o`) the named option; alone, list the options. + // A bare `-o` holds the empty name, which lists the options as `-o ''` does. + #[arg( + short = 'o', + plus = 'o', + value_name = "OPT", + default_missing = "", + detached_only + )] + Named(bool, String), +} + +impl builtins::Builtin for SetCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut stdout = context.stdout(); + + for option in &self.options { + let options = context.shell.options_mut(); + match option { + SetOption::Allexport(on) => options.export_variables_on_modification = *on, + SetOption::Notify(on) => options.notify_job_termination_immediately = *on, + SetOption::Errexit(on) => options.exit_on_nonzero_command_exit = *on, + SetOption::Noglob(on) => options.disable_filename_globbing = *on, + SetOption::Hashall(on) => options.remember_command_locations = *on, + SetOption::Keyword(on) => options.place_all_assignment_args_in_command_env = *on, + SetOption::Monitor(on) => options.enable_job_control = *on, + SetOption::Noexec(on) => options.do_not_execute_commands = *on, + SetOption::Privileged(on) => options.real_effective_uid_mismatch = *on, + // shortcut: restricted mode isn't implemented, so -r and +r are accepted and + // change nothing; set the option here once brush-core has one. + SetOption::Restricted(_) => {} + SetOption::Onecmd(on) => options.exit_after_one_command = *on, + SetOption::Nounset(on) => options.treat_unset_variables_as_error = *on, + SetOption::Verbose(on) => options.print_shell_input_lines = *on, + SetOption::Xtrace(on) => options.print_commands_and_arguments = *on, + SetOption::Braceexpand(on) => options.perform_brace_expansion = *on, + SetOption::Noclobber(on) => { + options.disallow_overwriting_regular_files_via_output_redirection = *on; + } + SetOption::Errtrace(on) => options.shell_functions_inherit_err_trap = *on, + SetOption::Histexpand(on) => options.enable_bang_style_history_substitution = *on, + SetOption::Physical(on) => options.do_not_resolve_symlinks_when_changing_dir = *on, + SetOption::Functrace(on) => { + options.shell_functions_inherit_debug_and_return_traps = *on; + } + SetOption::Named(on, name) if name.is_empty() => { + list_named_options(&context, *on, &mut stdout)?; + } + SetOption::Named(on, name) => { + let named = brush_core::namedoptions::options( + brush_core::namedoptions::ShellOptionKind::SetO, + ); + let Some(definition) = named.get(name.as_str()) else { + // Like bash, stop at the bad name: what follows is not applied. + writeln!( + context.stderr(), + "{}: {name}: invalid option name", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + }; + definition.set(options, *on); + } + } + } + + let args = context.shell.current_shell_args_mut(); + + let skip = match self.positional_args.first() { + Some(x) if x == "-" => { + if self.positional_args.len() > 1 { + args.clear(); + } + 1 + } + Some(x) if x == "--" => { + args.clear(); + 1 + } + Some(_) => { + args.clear(); + 0 + } + None => 0, + }; + + for arg in self.positional_args.iter().skip(skip) { + args.push(arg.to_owned()); + } + + // A lone `-` ending the options also turns off -x and -v. + if self.positional_args.first().is_some_and(|x| x == "-") { + let options = context.shell.options_mut(); + options.print_commands_and_arguments = false; + options.print_shell_input_lines = false; + } + + if self.options.is_empty() && self.positional_args.is_empty() { + stdout.write_all(&display_all(&context)?)?; + } + + stdout.flush()?; + + Ok(ExecutionResult::success()) + } +} + +/// `set -o` (`on`) or `set +o`: every option, as a table or as the commands restoring it. +fn list_named_options( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + on: bool, + output: &mut impl Write, +) -> Result<(), brush_core::Error> { + for option in brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO) + .iter() + .sorted_by_key(|option| option.name) + { + let value = option.definition.get(context.shell.options()); + if on { + writeln!( + output, + "{:15}\t{}", + option.name, + if value { "on" } else { "off" } + )?; + } else { + writeln!( + output, + "set {} {}", + if value { "-o" } else { "+o" }, + option.name + )?; + } + } + Ok(()) +} + +fn display_all( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, +) -> Result, brush_core::Error> { + let mut output = Vec::new(); + + for (name, var) in context.shell.env().iter().sorted_by_key(|v| v.0) { + if !var.is_enumerable() { + continue; + } + + if matches!(var.value(), variables::ShellValue::Dynamic { .. }) { + continue; + } + + if !var.value().is_set() { + continue; + } + + writeln!( + output, + "{name}={}", + var.value() + .format(variables::FormatStyle::Basic, context.shell)?, + )?; + } + + if !context.shell.options().posix_mode { + for (_name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) { + writeln!(output, "{}", registration.definition())?; + } + } + + Ok(output) +} diff --git a/userland/brush/source/brush-builtins/src/shift.rs b/userland/brush/source/brush-builtins/src/shift.rs new file mode 100644 index 0000000..dead6b7 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/shift.rs @@ -0,0 +1,47 @@ +use std::io::Write; + +use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; + +/// Shift positional arguments. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ShiftCommand { + /// Number of positions to shift the arguments by (defaults to 1). + #[arg(positional, allow_negative_numbers)] + n: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(ShiftCommand); + +impl builtins::Builtin for ShiftCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let n = self.n.unwrap_or(1); + + if n < 0 { + writeln!( + context.stderr(), + "{}: {n}: shift count out of range", + context.command_name + )?; + return Ok(ExecutionResult::general_error()); + } + + #[expect(clippy::cast_sign_loss)] + let n = n as usize; + + let args = context.shell.current_shell_args_mut(); + + if n > args.len() { + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + args.drain(0..n); + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/shopt.rs b/userland/brush/source/brush-builtins/src/shopt.rs new file mode 100644 index 0000000..88c3bd3 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/shopt.rs @@ -0,0 +1,156 @@ +use itertools::Itertools; +use std::io::Write; + +use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; + +/// Manage shopt-style options. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ShoptCommand { + /// Manage set -o options. + #[arg(short = 'o')] + set_o_names_only: bool, + + /// Print options' current values. + #[arg(short = 'p')] + print: bool, + + /// Suppress typical output. + #[arg(short = 'q')] + quiet: bool, + + /// Set the specified options. + #[arg(short = 's')] + set: bool, + + /// Unset the specified options. + #[arg(short = 'u')] + unset: bool, + + /// Names of options to operate on. + #[arg(positional)] + options: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(ShoptCommand); + +impl builtins::Builtin for ShoptCommand { + type Error = brush_core::Error; + + #[allow(clippy::too_many_lines)] + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.set && self.unset { + writeln!( + context.stderr(), + "cannot set and unset shell options simultaneously" + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + if self.options.is_empty() { + if self.quiet { + return Ok(ExecutionResult::success()); + } + + // Enumerate all options of the selected type. + let options = if self.set_o_names_only { + brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO) + .iter() + .sorted_by_key(|opt| opt.name) + } else { + brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::Shopt) + .iter() + .sorted_by_key(|opt| opt.name) + }; + + for option in options { + let option_value = option.definition.get(context.shell.options()); + if self.set && !option_value { + continue; + } + if self.unset && option_value { + continue; + } + + if self.print { + if self.set_o_names_only { + let option_value_str = if option_value { "-o" } else { "+o" }; + writeln!(context.stdout(), "set {option_value_str} {}", option.name)?; + } else { + let option_value_str = if option_value { "-s" } else { "-u" }; + writeln!(context.stdout(), "shopt {option_value_str} {}", option.name)?; + } + } else { + let option_value_str = if option_value { "on" } else { "off" }; + writeln!(context.stdout(), "{:20}\t{option_value_str}", option.name)?; + } + } + + Ok(ExecutionResult::success()) + } else { + let mut return_value = ExecutionResult::success(); + + // Enumerate only the specified options. + for option_name in &self.options { + let option_definition = if self.set_o_names_only { + brush_core::namedoptions::options( + brush_core::namedoptions::ShellOptionKind::SetO, + ) + .get(option_name.as_str()) + } else { + brush_core::namedoptions::options( + brush_core::namedoptions::ShellOptionKind::Shopt, + ) + .get(option_name.as_str()) + }; + + if let Some(option_definition) = option_definition { + if self.set { + option_definition.set(context.shell.options_mut(), true); + } else if self.unset { + option_definition.set(context.shell.options_mut(), false); + } else { + let option_value = option_definition.get(context.shell.options()); + if !option_value { + return_value = ExecutionResult::general_error(); + } + + if !self.quiet { + if self.print { + if self.set_o_names_only { + let option_value_str = if option_value { "-o" } else { "+o" }; + writeln!( + context.stdout(), + "set {option_value_str} {option_name}" + )?; + } else { + let option_value_str = if option_value { "-s" } else { "-u" }; + writeln!( + context.stdout(), + "shopt {option_value_str} {option_name}" + )?; + } + } else { + let option_value_str = if option_value { "on" } else { "off" }; + writeln!(context.stdout(), "{option_name:20}\t{option_value_str}")?; + } + } + } + } else { + writeln!( + context.stderr(), + "{}: {}: invalid shell option name", + context.command_name, + option_name + )?; + return_value = ExecutionResult::general_error(); + } + } + + Ok(return_value) + } + } +} diff --git a/userland/brush/source/brush-builtins/src/suspend.rs b/userland/brush/source/brush-builtins/src/suspend.rs new file mode 100644 index 0000000..07c1132 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/suspend.rs @@ -0,0 +1,36 @@ +use std::io::Write; + +use brush_core::{ExecutionExitCode, ExecutionResult, builtins}; + +/// Suspend the shell. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct SuspendCommand { + /// Force suspend login shells. + #[arg(short = 'f')] + force: bool, +} + +brush_winnow_args_adapter::impl_parse_args!(SuspendCommand); + +impl builtins::Builtin for SuspendCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if context.shell.options().login_shell && !self.force { + writeln!(context.stderr(), "login shell cannot be suspended")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + #[expect(clippy::cast_possible_wrap)] + brush_core::sys::signal::kill_process( + std::process::id() as i32, + brush_core::traps::TrapSignal::Signal(nix::sys::signal::SIGSTOP), + )?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/test.rs b/userland/brush/source/brush-builtins/src/test.rs new file mode 100644 index 0000000..f48b54b --- /dev/null +++ b/userland/brush/source/brush-builtins/src/test.rs @@ -0,0 +1,59 @@ +use std::io::Write; + +use brush_core::{ + ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, builtins, tests, +}; + +/// Evaluate test expression. +#[derive(winnow_args::Args)] +#[arg( + unknown_flags = "value", + disable_help_flag, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct TestCommand { + #[arg(positional, double_dash = "preserve")] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(TestCommand, keep_double_dash = args); + +impl builtins::Builtin for TestCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut args = self.args.as_slice(); + + if context.command_name == "[" { + match args.last() { + Some(s) if s == "]" => (), + None | Some(_) => { + writeln!(context.stderr(), "[: missing ']'")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + args = &args[0..args.len() - 1]; + } + + if execute_test(context.shell, &context.params, args)? { + Ok(ExecutionResult::success()) + } else { + Ok(ExecutionResult::general_error()) + } + } +} + +fn execute_test( + shell: &mut Shell, + params: &ExecutionParameters, + args: &[String], +) -> Result { + let test_command = + brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?; + tests::eval_expr(&test_command, shell, params) +} diff --git a/userland/brush/source/brush-builtins/src/times.rs b/userland/brush/source/brush-builtins/src/times.rs new file mode 100644 index 0000000..02413c7 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/times.rs @@ -0,0 +1,43 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, timing}; + +/// Report on usage time. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct TimesCommand { + /// Operands, which are ignored. Options before the first operand are + /// still misuse; everything after it is an operand. + #[arg(positional, stop_flags, hide)] + _operands: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(TimesCommand); + +impl builtins::Builtin for TimesCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let (self_user, self_system) = brush_core::sys::resource::get_self_user_and_system_time()?; + writeln!( + context.stdout(), + "{} {}", + timing::format_duration_non_posixly(&self_user), + timing::format_duration_non_posixly(&self_system), + )?; + + let (children_user, children_system) = + brush_core::sys::resource::get_children_user_and_system_time()?; + writeln!( + context.stdout(), + "{} {}", + timing::format_duration_non_posixly(&children_user), + timing::format_duration_non_posixly(&children_system), + )?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/trap.rs b/userland/brush/source/brush-builtins/src/trap.rs new file mode 100644 index 0000000..747d303 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/trap.rs @@ -0,0 +1,127 @@ +use std::io::Write; + +use brush_core::traps::TrapSignal; +use brush_core::{ExecutionResult, builtins}; + +/// Manage signal traps. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct TrapCommand { + /// List all signal names. + #[arg(short = 'l')] + list_signals: bool, + + /// Print registered trap commands. + #[arg(short = 'p')] + print_trap_commands: bool, + + #[arg(positional, double_dash = "automatic")] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(TrapCommand); + +impl builtins::Builtin for TrapCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.list_signals { + brush_core::traps::format_signals(context.stdout(), TrapSignal::iterator()) + .map(|()| ExecutionResult::success()) + } else if self.print_trap_commands || self.args.is_empty() { + if !self.args.is_empty() { + for signal_type in &self.args { + Self::display_handlers_for(&context, signal_type.parse()?)?; + } + } else { + Self::display_all_handlers(&context)?; + } + Ok(ExecutionResult::success()) + } else { + // A single argument is a signal whose handlers are removed, as are + // the signals after a leading "-"; otherwise the first argument is + // the handler for the signals after it. + let (handler, signals) = match self.args.as_slice() { + [first, rest @ ..] if !rest.is_empty() => { + (Some(first.as_str()).filter(|h| *h != "-"), rest) + } + all => (None, all), + }; + + // An invalid signal is reported and fails the command, but the + // valid ones around it are still updated. + let mut result = ExecutionResult::success(); + for signal in signals { + match signal.parse() { + Ok(signal) => match handler { + Some(handler) => { + Self::register_handler(&mut context, [signal], handler); + } + None => Self::remove_all_handlers(&mut context, signal), + }, + Err(e) => { + writeln!(context.stderr(), "{}: {e}", context.command_name)?; + result = ExecutionResult::general_error(); + } + } + } + Ok(result) + } + } +} + +impl TrapCommand { + fn display_all_handlers( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result<(), brush_core::Error> { + for (signal, _) in context.shell.traps().iter_handlers() { + Self::display_handlers_for(context, signal)?; + } + Ok(()) + } + + fn display_handlers_for( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signal_type: TrapSignal, + ) -> Result<(), brush_core::Error> { + if let Some(handler) = context.shell.traps().get_handler(signal_type) { + writeln!( + context.stdout(), + "trap -- '{}' {signal_type}", + handler.command + )?; + } + Ok(()) + } + + fn remove_all_handlers( + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signal: TrapSignal, + ) { + context.shell.traps_mut().remove_handlers(signal); + } + + fn register_handler( + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signals: I, + handler: &str, + ) where + I: IntoIterator, + { + // Our new source context is relative to the current position. + // TODO(source-info): Provide the location of the specific token that makes up + // `self.args[0]`. + let source_info = context.shell.call_stack().current_pos_as_source_info(); + + for signal in signals { + context.shell.traps_mut().register_handler( + signal, + handler.to_owned(), + source_info.clone(), + ); + } + } +} diff --git a/userland/brush/source/brush-builtins/src/true_.rs b/userland/brush/source/brush-builtins/src/true_.rs new file mode 100644 index 0000000..f8912cd --- /dev/null +++ b/userland/brush/source/brush-builtins/src/true_.rs @@ -0,0 +1,34 @@ +use brush_core::{ExecutionResult, builtins, error}; + +/// No-op command. Same with :. +pub(crate) struct TrueCommand {} + +impl builtins::ParseArgs for TrueCommand { + fn content( + name: &str, + content_type: builtins::ContentType, + _options: &builtins::ContentOptions, + ) -> Result { + match content_type { + builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()), + builtins::ContentType::ShortUsage => Ok(format!("{name}: {name}\n")), + builtins::ContentType::ShortDescription => Ok(format!("{name} - success\n")), + builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + } + } + + fn parse(_invocation: builtins::Invocation<'_>) -> Result { + Ok(Self {}) + } +} + +impl builtins::Builtin for TrueCommand { + type Error = brush_core::Error; + + async fn execute( + self, + _context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-builtins/src/type_.rs b/userland/brush/source/brush-builtins/src/type_.rs new file mode 100644 index 0000000..82ad562 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/type_.rs @@ -0,0 +1,114 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +use crate::lookup::{self, Resolved}; + +/// Inspect the type of a named shell item. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct TypeCommand { + /// Display all locations of the specified name, not just the first. + #[arg(short = 'a')] + all_locations: bool, + + /// Don't consider functions when resolving the name. + #[arg(short = 'f')] + suppress_func_lookup: bool, + + /// Force searching by file path, even if the name is an alias, built-in + /// command, or shell function. + #[arg(short = 'P')] + force_path_search: bool, + + /// Show file path only. + #[arg(short = 'p')] + show_path_only: bool, + + /// Only display the type of the specified name. + #[arg(short = 't')] + type_only: bool, + + /// Names to search for. + #[arg(positional)] + names: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(TypeCommand); + +impl builtins::Builtin for TypeCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + let options = lookup::Options { + force_path_search: self.force_path_search, + suppress_func_lookup: self.suppress_func_lookup, + all_locations: self.all_locations, + path_dirs: None, + }; + + for name in &self.names { + let resolved_types = lookup::resolve(context.shell, name, &options); + + if resolved_types.is_empty() { + if !self.type_only && !self.force_path_search && !self.show_path_only { + writeln!(context.stderr(), "type: {name}: not found")?; + } + + result = ExecutionResult::general_error(); + continue; + } + + for resolved_type in resolved_types { + if self.show_path_only && !matches!(resolved_type, Resolved::File { .. }) { + // Do nothing. + } else if self.type_only { + match &resolved_type { + Resolved::Alias(_) => { + writeln!(context.stdout(), "alias")?; + } + Resolved::Keyword => { + writeln!(context.stdout(), "keyword")?; + } + Resolved::Function(_) => { + writeln!(context.stdout(), "function")?; + } + Resolved::Builtin => { + writeln!(context.stdout(), "builtin")?; + } + Resolved::File { path, .. } => { + if self.show_path_only || self.force_path_search { + writeln!(context.stdout(), "{}", path.to_string_lossy())?; + } else { + writeln!(context.stdout(), "file")?; + } + } + } + } else { + match &resolved_type { + // When we're displaying all locations, we don't show hashed paths. + Resolved::File { hashed: true, .. } + if self.all_locations && !self.force_path_search => {} + Resolved::File { path, .. } + if self.show_path_only || self.force_path_search => + { + writeln!(context.stdout(), "{}", path.to_string_lossy())?; + } + _ => lookup::describe(context.stdout(), name, &resolved_type)?, + } + } + + // If we only want the first, then break after the first. + if !self.all_locations { + break; + } + } + } + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/src/ulimit.rs b/userland/brush/source/brush-builtins/src/ulimit.rs new file mode 100644 index 0000000..81d365f --- /dev/null +++ b/userland/brush/source/brush-builtins/src/ulimit.rs @@ -0,0 +1,552 @@ +use std::{ + io::{self, ErrorKind, Write}, + str::FromStr, +}; + +use brush_core::{ExecutionResult, builtins}; + +#[derive(Clone, Copy)] +enum Unit { + Block, + Bytes, + HalfKBytes, + KBytes, + Micros, + Number, + Seconds, +} + +impl Unit { + const fn scale(self) -> u64 { + match self { + Self::Block | Self::HalfKBytes => 512, + Self::KBytes => 1024, + _ => 1, + } + } +} + +#[derive(Clone, Copy)] +enum Virtual { + Pipe, + VMem, +} + +impl Virtual { + fn get(self) -> std::io::Result<(u64, u64)> { + match self { + Self::Pipe => { + let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512; + Ok((lim, lim)) + } + Self::VMem => rlimit::Resource::AS + .get() + .or_else(|_| rlimit::Resource::VMEM.get()), + } + } + fn set(self, soft: u64, hard: u64) -> std::io::Result<()> { + match self { + Self::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)), + Self::VMem => rlimit::Resource::AS + .set(soft, hard) + .or_else(|_| rlimit::Resource::VMEM.set(soft, hard)), + } + } + const fn is_supported(self) -> bool { + match self { + Self::Pipe => true, + Self::VMem => { + rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported() + } + } + } +} + +#[derive(Clone, Copy)] +enum Resource { + Phy(rlimit::Resource), + Virt(Virtual), +} + +impl Resource { + fn get(self) -> std::io::Result<(u64, u64)> { + match self { + Self::Phy(res) => res.get(), + Self::Virt(res) => res.get(), + } + } + fn set(self, soft: u64, hard: u64) -> std::io::Result<()> { + match self { + Self::Phy(res) => res.set(soft, hard), + Self::Virt(res) => res.set(soft, hard), + } + } + const fn is_supported(self) -> bool { + match self { + Self::Phy(res) => res.is_supported(), + Self::Virt(res) => res.is_supported(), + } + } +} + +#[derive(Clone, Copy)] +struct ResourceDescription { + resource: Resource, + description: &'static str, + short: char, + unit: Unit, +} + +impl ResourceDescription { + const SBSIZE: Self = Self { + resource: Resource::Phy(rlimit::Resource::SBSIZE), + description: "socket buffer size", + short: 'b', + unit: Unit::Bytes, + }; + const CORE: Self = Self { + resource: Resource::Phy(rlimit::Resource::CORE), + description: "core file size", + short: 'c', + unit: Unit::Block, + }; + const DATA: Self = Self { + resource: Resource::Phy(rlimit::Resource::DATA), + description: "data seg size", + short: 'd', + unit: Unit::KBytes, + }; + const NICE: Self = Self { + resource: Resource::Phy(rlimit::Resource::NICE), + description: "scheduling priority", + short: 'e', + unit: Unit::Number, + }; + const FSIZE: Self = Self { + resource: Resource::Phy(rlimit::Resource::FSIZE), + description: "file size", + short: 'f', + unit: Unit::Block, + }; + const SIGPENDING: Self = Self { + resource: Resource::Phy(rlimit::Resource::SIGPENDING), + description: "pending signals", + short: 'i', + unit: Unit::Number, + }; + const MEMLOCK: Self = Self { + resource: Resource::Phy(rlimit::Resource::MEMLOCK), + description: "max locked memory", + short: 'l', + unit: Unit::KBytes, + }; + const KQUEUES: Self = Self { + resource: Resource::Phy(rlimit::Resource::KQUEUES), + description: "max kqueues", + short: 'k', + unit: Unit::Number, + }; + const RSS: Self = Self { + resource: Resource::Phy(rlimit::Resource::RSS), + description: "max memory size", + short: 'm', + unit: Unit::KBytes, + }; + const LOCKS: Self = Self { + resource: Resource::Phy(rlimit::Resource::LOCKS), + description: "file locks", + short: 'x', + unit: Unit::Number, + }; + const NOFILE: Self = Self { + resource: Resource::Phy(rlimit::Resource::NOFILE), + description: "open files", + short: 'n', + unit: Unit::Number, + }; + const MSGQUEUE: Self = Self { + resource: Resource::Phy(rlimit::Resource::MSGQUEUE), + description: "POSIX message queues", + short: 'q', + unit: Unit::Bytes, + }; + const PIPE: Self = Self { + resource: Resource::Virt(Virtual::Pipe), + description: "pipe size", + short: 'p', + unit: Unit::HalfKBytes, + }; + const RTPRIO: Self = Self { + resource: Resource::Phy(rlimit::Resource::RTPRIO), + description: "real-time priority", + short: 'r', + unit: Unit::Number, + }; + const RTTIME: Self = Self { + resource: Resource::Phy(rlimit::Resource::RTTIME), + description: "real-time non-blocking time", + short: 'R', + unit: Unit::Micros, + }; + const STACK: Self = Self { + resource: Resource::Phy(rlimit::Resource::STACK), + description: "stack size", + short: 's', + unit: Unit::KBytes, + }; + const CPU: Self = Self { + resource: Resource::Phy(rlimit::Resource::CPU), + description: "cpu time", + short: 't', + unit: Unit::Seconds, + }; + const NPROC: Self = Self { + resource: Resource::Phy(rlimit::Resource::NPROC), + description: "max user processes", + short: 'u', + unit: Unit::Number, + }; + const VMEM: Self = Self { + resource: Resource::Virt(Virtual::VMem), + description: "virtual memory", + short: 'v', + unit: Unit::KBytes, + }; + const THREADS: Self = Self { + resource: Resource::Phy(rlimit::Resource::THREADS), + description: "number of threads", + short: 'T', + unit: Unit::Number, + }; + const NPTS: Self = Self { + resource: Resource::Phy(rlimit::Resource::NPTS), + description: "number of pseudoterminals", + short: 'P', + unit: Unit::Number, + }; + + fn get(&self, hard: bool) -> std::io::Result { + let (soft_limit, hard_limit) = self.resource.get()?; + let val = if hard { hard_limit } else { soft_limit }; + + if val == rlimit::INFINITY { + Ok("unlimited".into()) + } else { + Ok(format!("{}", val / self.unit.scale())) + } + } + + fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> { + let (soft, hard) = self.resource.get()?; + let value = match value { + LimitValue::Soft => soft, + LimitValue::Hard => hard, + LimitValue::Unlimited => rlimit::INFINITY, + LimitValue::Value(v) => v * self.unit.scale(), + LimitValue::Unset => return Ok(()), + }; + + if set_hard { + self.resource.set(soft, value) + } else { + self.resource.set(value, hard) + } + } + + /// Print either soft or hard limit + fn print( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + hard: bool, + ) -> io::Result<()> { + if !self.resource.is_supported() { + return Ok(()); + } + let unit = match self.unit { + Unit::Block => format!("(block, -{})", self.short), + Unit::Bytes => format!("(bytes, -{})", self.short), + Unit::HalfKBytes => format!("(512 bytes, -{})", self.short), + Unit::KBytes => format!("(kbytes, -{})", self.short), + Unit::Micros => format!("(microseconds, -{})", self.short), + Unit::Number => format!("(-{})", self.short), + Unit::Seconds => format!("(seconds, -{})", self.short), + }; + let resource = self.get(hard).unwrap_or_else(|e| format!("{e}")); + writeln!( + context.stdout(), + "{:<26}{:>16} {}", + self.description, + unit, + resource + ) + } +} + +#[derive(Debug, Clone, Copy)] +enum LimitValue { + Unset, + Unlimited, + Soft, + Hard, + Value(u64), +} + +impl FromStr for LimitValue { + type Err = ::Err; + fn from_str(s: &str) -> Result { + let v = match s { + "" => Self::Unset, + "unlimited" => Self::Unlimited, + "soft" => Self::Soft, + "hard" => Self::Hard, + _ => Self::Value(s.parse()?), + }; + Ok(v) + } +} + +impl winnow_args::FromArg for LimitValue { + fn from_arg(value: &winnow_args::BStr) -> Result { + Ok(winnow_args::value::to_str(value)?.parse()?) + } +} + +/// A flag's help, and whether this platform has the resource: `ulimit --help` +/// says "(supported)" or "(unsupported)" after it. +macro_rules! limit_help { + ($resource:ident, $help:literal) => { + if ResourceDescription::$resource.resource.is_supported() { + concat!($help, " (supported)") + } else { + concat!($help, " (unsupported)") + } + }; +} + +/// Modify shell resource limits. +/// +/// Provides control over the resources available to the shell and processes +/// it creates, on systems that allow such control. +#[derive(winnow_args::Args, Debug)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct ULimitCommand { + /// use the `soft` resource limit + #[arg(short = 'S')] + soft: bool, + /// use the `hard` resource limit + #[arg(short = 'H')] + hard: bool, + /// all current limits are reported + #[arg(short = 'a')] + all: bool, + /// the socket buffer size + #[arg(short = 'b', default_missing = "", help = limit_help!(SBSIZE, "the socket buffer size"))] + sbsize: Option, + /// the maximum size of core files created + #[arg( + short = 'c', + default_missing = "", + help = limit_help!(CORE, "the maximum size of core files created") + )] + core: Option, + /// the maximum size of a process's data segment + #[arg( + short = 'd', + default_missing = "", + help = limit_help!(DATA, "the maximum size of a process's data segment") + )] + data: Option, + /// the maximum scheduling priority (`nice`) + #[arg( + short = 'e', + default_missing = "", + help = limit_help!(NICE, "the maximum scheduling priority (`nice`)") + )] + nice: Option, + /// the maximum size of files written by the shell and its children + #[arg( + short = 'f', + default_missing = "", + help = limit_help!(FSIZE, "the maximum size of files written by the shell and its children") + )] + file_size: Option, + /// the maximum number of pending signals + #[arg( + short = 'i', + default_missing = "", + help = limit_help!(SIGPENDING, "the maximum number of pending signals") + )] + sigpending: Option, + /// the maximum size a process may lock into memory + #[arg( + short = 'l', + default_missing = "", + help = limit_help!(MEMLOCK, "the maximum size a process may lock into memory") + )] + memlock: Option, + /// the maximum number of kqueues allocated for this process + #[arg( + short = 'k', + default_missing = "", + help = limit_help!(KQUEUES, "the maximum number of kqueues allocated for this process") + )] + kqueues: Option, + /// the maximum resident set size + #[arg( + short = 'm', + default_missing = "", + help = limit_help!(RSS, "the maximum resident set size") + )] + rss: Option, + /// the maximum number of open file descriptors + #[arg( + short = 'n', + default_missing = "", + help = limit_help!(NOFILE, "the maximum number of open file descriptors") + )] + file_open: Option, + /// the pipe buffer size + #[arg(short = 'p', default_missing = "", help = limit_help!(PIPE, "the pipe buffer size"))] + pipe: Option, + /// the maximum number of bytes in POSIX message queues + #[arg( + short = 'q', + default_missing = "", + help = limit_help!(MSGQUEUE, "the maximum number of bytes in POSIX message queues") + )] + msgqueue: Option, + /// the maximum real-time scheduling priority + #[arg( + short = 'r', + default_missing = "", + help = limit_help!(RTPRIO, "the maximum real-time scheduling priority") + )] + rtprio: Option, + /// the maximum stack size + #[arg(short = 's', default_missing = "", help = limit_help!(STACK, "the maximum stack size"))] + stack: Option, + /// the maximum amount of cpu time in seconds + #[arg( + short = 't', + default_missing = "", + help = limit_help!(CPU, "the maximum amount of cpu time in seconds") + )] + cpu: Option, + /// the maximum number of user processes + #[arg( + short = 'u', + default_missing = "", + help = limit_help!(NPROC, "the maximum number of user processes") + )] + nproc: Option, + /// the size of virtual memory + #[arg(short = 'v', default_missing = "", help = limit_help!(VMEM, "the size of virtual memory"))] + vmem: Option, + /// the maximum number of file locks + #[arg( + short = 'x', + default_missing = "", + help = limit_help!(LOCKS, "the maximum number of file locks") + )] + file_lock: Option, + /// the maximum number of pseudoterminals + #[arg( + short = 'P', + default_missing = "", + help = limit_help!(NPTS, "the maximum number of pseudoterminals") + )] + npts: Option, + /// the maximum time a real-time process can run before blocking + #[arg( + short = 'R', + default_missing = "", + help = limit_help!( + RTTIME, + "the maximum time a real-time process can run before blocking" + ) + )] + rttime: Option, + /// the maximum number of threads + #[arg( + short = 'T', + default_missing = "", + help = limit_help!(THREADS, "the maximum number of threads") + )] + threads: Option, + + /// argument for the implicit limit (`-f`) + #[arg(positional)] + limit: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(ULimitCommand); + +impl builtins::Builtin for ULimitCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let exit_code = ExecutionResult::success(); + let mut resources_to_set = Vec::new(); + let mut resources_to_get = Vec::new(); + + let mut set_or_get = |val, descr| { + match val { + Some(LimitValue::Unset) => resources_to_get.push(descr), + Some(v) => resources_to_set.push((descr, v)), + None => {} + } + if self.all { + resources_to_get.push(descr); + } + }; + + set_or_get(self.sbsize, ResourceDescription::SBSIZE); + set_or_get(self.core, ResourceDescription::CORE); + set_or_get(self.data, ResourceDescription::DATA); + set_or_get(self.file_size, ResourceDescription::FSIZE); + set_or_get(self.sigpending, ResourceDescription::SIGPENDING); + set_or_get(self.kqueues, ResourceDescription::KQUEUES); + set_or_get(self.memlock, ResourceDescription::MEMLOCK); + set_or_get(self.rss, ResourceDescription::RSS); + set_or_get(self.file_lock, ResourceDescription::LOCKS); + set_or_get(self.file_open, ResourceDescription::NOFILE); + set_or_get(self.pipe, ResourceDescription::PIPE); + set_or_get(self.npts, ResourceDescription::NPTS); + set_or_get(self.nice, ResourceDescription::NICE); + set_or_get(self.msgqueue, ResourceDescription::MSGQUEUE); + set_or_get(self.rtprio, ResourceDescription::RTPRIO); + set_or_get(self.rttime, ResourceDescription::RTTIME); + set_or_get(self.stack, ResourceDescription::STACK); + set_or_get(self.threads, ResourceDescription::THREADS); + set_or_get(self.cpu, ResourceDescription::CPU); + set_or_get(self.nproc, ResourceDescription::NPROC); + set_or_get(self.vmem, ResourceDescription::VMEM); + + if resources_to_set.is_empty() { + if resources_to_get.is_empty() { + if let Some(fsize) = self.limit { + resources_to_set.push((ResourceDescription::FSIZE, fsize)); + } else { + resources_to_get.push(ResourceDescription::FSIZE); + } + } + } + + for (resource, value) in resources_to_set { + resource.set(self.hard, value)?; + } + + if resources_to_get.len() == 1 { + writeln!(context.stdout(), "{}", resources_to_get[0].get(self.hard)?)?; + } else { + for resource in resources_to_get { + resource.print(&context, self.hard)?; + } + } + + Ok(exit_code) + } +} diff --git a/userland/brush/source/brush-builtins/src/umask.rs b/userland/brush/source/brush-builtins/src/umask.rs new file mode 100644 index 0000000..0644349 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/umask.rs @@ -0,0 +1,100 @@ +use brush_core::{ErrorKind, ExecutionResult, builtins}; +use cfg_if::cfg_if; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +use nix::sys::stat::Mode; +use std::io::Write; + +/// Manage the process umask. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct UmaskCommand { + /// If MODE is omitted, output in a form that may be reused as input. + #[arg(short = 'p')] + print_roundtrippable: bool, + + /// Makes the output symbolic; otherwise an octal number is given. + #[arg(short = 'S')] + symbolic_output: bool, + + /// Mode mask. + #[arg(positional)] + mode: Option, +} + +brush_winnow_args_adapter::impl_parse_args!(UmaskCommand); + +impl builtins::Builtin for UmaskCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if let Some(mode) = &self.mode { + if mode.starts_with(|c: char| c.is_digit(8)) { + let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?; + set_umask(parsed)?; + } else { + return brush_core::error::unimp("umask setting mode from symbolic value"); + } + } else { + let umask = get_umask()?; + + let formatted = if self.symbolic_output { + let u = symbolic_mask_from_bits((!umask & 0o700) >> 6); + let g = symbolic_mask_from_bits((!umask & 0o070) >> 3); + let o = symbolic_mask_from_bits(!umask & 0o007); + std::format!("u={u},g={g},o={o}") + } else { + std::format!("{umask:04o}") + }; + + if self.print_roundtrippable { + writeln!(context.stdout(), "umask {formatted}")?; + } else { + writeln!(context.stdout(), "{formatted}")?; + } + } + + Ok(ExecutionResult::success()) + } +} + +cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "android"))] { + fn get_umask() -> Result { + let umask = procfs::process::Process::myself().ok().and_then(|me| me.status().ok()).and_then(|status| status.umask); + umask.ok_or_else(|| brush_core::ErrorKind::InvalidUmask.into()) + } + } else { + #[expect(clippy::unnecessary_wraps)] + fn get_umask() -> Result { + let u = nix::sys::stat::umask(Mode::empty()); + nix::sys::stat::umask(u); + Ok(u32::from(u.bits())) + } + } +} + +fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> { + // value of mode_t can be platform dependent + let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?; + nix::sys::stat::umask(mode); + Ok(()) +} + +fn symbolic_mask_from_bits(bits: u32) -> String { + let mut result = String::new(); + + if (bits & 0b100) != 0 { + result.push('r'); + } + if (bits & 0b010) != 0 { + result.push('w'); + } + if (bits & 0b001) != 0 { + result.push('x'); + } + + result +} diff --git a/userland/brush/source/brush-builtins/src/unalias.rs b/userland/brush/source/brush-builtins/src/unalias.rs new file mode 100644 index 0000000..dded3c5 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/unalias.rs @@ -0,0 +1,47 @@ +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +/// Unset a shell alias. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct UnaliasCommand { + /// Remove all aliases. + #[arg(short = 'a')] + remove_all: bool, + + /// Names of aliases to operate on. + #[arg(positional)] + aliases: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(UnaliasCommand); + +impl builtins::Builtin for UnaliasCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut exit_code = ExecutionResult::success(); + + if self.remove_all { + context.shell.aliases_mut().clear(); + } else { + for alias in &self.aliases { + if context.shell.aliases_mut().remove(alias).is_none() { + writeln!( + context.stderr(), + "{}: {}: not found", + context.command_name, + alias + )?; + exit_code = ExecutionResult::general_error(); + } + } + } + + Ok(exit_code) + } +} diff --git a/userland/brush/source/brush-builtins/src/unimp.rs b/userland/brush/source/brush-builtins/src/unimp.rs new file mode 100644 index 0000000..e67abc8 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/unimp.rs @@ -0,0 +1,32 @@ +use brush_core::{ExecutionExitCode, builtins, trace_categories}; + +/// (UNIMPLEMENTED COMMAND) +#[derive(winnow_args::Args)] +#[arg( + unknown_flags = "value", + disable_help_short, + disable_version_flag, + disable_help_subcommand +)] +pub(crate) struct UnimplementedCommand { + #[arg(positional, double_dash = "preserve")] + args: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(UnimplementedCommand); + +impl builtins::Builtin for UnimplementedCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, + "unimplemented built-in: {} {}", + context.command_name, + self.args.join(" ") + ); + Ok(ExecutionExitCode::Unimplemented.into()) + } +} diff --git a/userland/brush/source/brush-builtins/src/unset.rs b/userland/brush/source/brush-builtins/src/unset.rs new file mode 100644 index 0000000..7db5c02 --- /dev/null +++ b/userland/brush/source/brush-builtins/src/unset.rs @@ -0,0 +1,132 @@ +use std::borrow::Cow; +use std::io::Write; + +use brush_core::{ExecutionResult, Shell, builtins}; + +/// Unset a variable. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct UnsetCommand { + #[arg(flatten)] + name_interpretation: UnsetNameInterpretation, + + /// Names of variables to unset. + #[arg(positional)] + names: Vec, +} + +// Only `-f` with `-v` is rejected, and at execution time: `-n` combines with either. +#[derive(winnow_args::Args)] +pub(crate) struct UnsetNameInterpretation { + /// Treat each name as a shell function. + #[arg(short = 'f')] + shell_functions: bool, + + /// Treat each name as a shell variable. + #[arg(short = 'v')] + shell_variables: bool, + + /// Treat each name as a name reference. + #[arg(short = 'n')] + name_references: bool, +} + +impl UnsetNameInterpretation { + pub const fn unspecified(&self) -> bool { + !self.shell_functions && !self.shell_variables && !self.name_references + } +} + +brush_winnow_args_adapter::impl_parse_args!(UnsetCommand); + +impl builtins::Builtin for UnsetCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.name_interpretation.shell_functions && self.name_interpretation.shell_variables { + writeln!( + context.stderr(), + "{}: cannot simultaneously unset a function and a variable", + context.command_name + )?; + return Ok(ExecutionResult::general_error()); + } + + // + // TODO(nameref): implement nameref + // + if self.name_interpretation.name_references { + return brush_core::error::unimp("unset: name references are not yet implemented"); + } + + let unspecified = self.name_interpretation.unspecified(); + + #[expect(clippy::needless_continue)] + for name in &self.names { + if unspecified || self.name_interpretation.shell_variables { + // Try to parse the name as a parameter. If we can't, don't bail; it may not be a + // valid variable name/parameter but could still be a function name. + if let Ok(parameter) = + brush_parser::word::parse_parameter(name, &context.shell.parser_options()) + { + let result = match parameter { + brush_parser::word::Parameter::Positional(_) => continue, + brush_parser::word::Parameter::Special(_) => continue, + brush_parser::word::Parameter::Named(name) => { + context.shell.env_mut().unset(name.as_str())?.is_some() + } + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + unset_array_index(context.shell, name.as_str(), index.as_str())? + } + brush_parser::word::Parameter::NamedWithAllIndices { + name: _, + concatenate: _, + } => continue, + }; + + if result { + continue; + } + } + } + + // TODO(unset): Deal with readonly functions + if unspecified || self.name_interpretation.shell_functions { + if context.shell.undefine_func(name) { + continue; + } + } + } + + Ok(ExecutionResult::success()) + } +} + +fn unset_array_index( + shell: &mut Shell, + name: &str, + index: &str, +) -> Result { + // First check to see if it's an associative array. + let is_assoc_array = shell + .env() + .get(name) + .is_some_and(|(_, var)| var.value().is_associative_array()); + + // Compute which index we should actually use. For indexed arrays, we need to evaluate + // the index string as an arithmetic expression first. + let index_to_use: Cow<'_, str> = if is_assoc_array { + index.into() + } else { + // First evaluate the index expression. + let index_as_expr = brush_parser::arithmetic::parse(index)?; + let evaluated_index = shell.eval_arithmetic(&index_as_expr)?; + evaluated_index.to_string().into() + }; + + // Now we can try to unset, and return the result. + shell.env_mut().unset_index(name, index_to_use.as_ref()) +} diff --git a/userland/brush/source/brush-builtins/src/wait.rs b/userland/brush/source/brush-builtins/src/wait.rs new file mode 100644 index 0000000..3ee687b --- /dev/null +++ b/userland/brush/source/brush-builtins/src/wait.rs @@ -0,0 +1,84 @@ +use std::io::Write; + +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error}; + +/// Wait for jobs to terminate. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct WaitCommand { + /// Wait for specified job to terminate (instead of change status). + #[arg(short = 'f')] + wait_for_terminate: bool, + + /// Wait for a single job to change status; if jobs are specified, waits for + /// the first to change status, and otherwise waits for the next change. + #[arg(short = 'n')] + wait_for_first_or_next: bool, + + /// Name of variable to receive the job ID of the job whose status is indicated. + #[arg(short = 'p', value_name = "VAR_NAME")] + variable_to_receive_id: Option, + + /// Process IDs or job specs to wait for. + #[arg(positional)] + ids: Vec, +} + +brush_winnow_args_adapter::impl_parse_args!(WaitCommand); + +impl builtins::Builtin for WaitCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.wait_for_terminate { + return error::unimp("wait -f"); + } + if self.wait_for_first_or_next { + return error::unimp("wait -n"); + } + if self.variable_to_receive_id.is_some() { + return error::unimp("wait -p"); + } + + let mut result = ExecutionResult::success(); + + if !self.ids.is_empty() { + for id in &self.ids { + if id.starts_with('%') { + // It's a job spec. + if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) { + // Take the job's status, but not its control flow: an `exit` in a + // background job ends only that job. + result = job.wait().await?.exit_code.into(); + } else { + writeln!( + context.stderr(), + "{}: no such job: {}", + context.command_name, + id + )?; + + result = ExecutionExitCode::GeneralError.into(); + } + } else { + // It's a process ID. + return error::unimp("wait with process IDs"); + } + } + } else { + // Wait for all jobs. + let jobs = context.shell.jobs_mut().wait_all().await?; + + if context.shell.options().enable_job_control { + for job in jobs { + writeln!(context.stdout(), "{job}")?; + } + } + } + + Ok(result) + } +} diff --git a/userland/brush/source/brush-builtins/tests/process_launcher_tests.rs b/userland/brush/source/brush-builtins/tests/process_launcher_tests.rs new file mode 100644 index 0000000..64e91ea --- /dev/null +++ b/userland/brush/source/brush-builtins/tests/process_launcher_tests.rs @@ -0,0 +1,487 @@ +//! Integration tests for launching processes through a host's `ProcessLauncher`. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context as _, Result}; +use brush_builtins::{BuiltinSet, ShellBuilderExt as _}; +use brush_core::{ + ExecutionResult, ShellFd, + extensions::{DefaultErrorFormatter, ProcessLauncher, ShellExtensionsImpl}, + openfiles::OpenFiles, + processes::{ChildProcess, LaunchRequest, ProcessGroup}, +}; + +/// How a process was to be launched. +#[derive(Debug, PartialEq, Eq)] +enum How { + Spawn, + Exec, +} + +/// What a launcher was asked to launch. +#[derive(Debug)] +struct Launch { + how: How, + program: PathBuf, + argv0: String, + args: Vec, + env: Vec<(String, String)>, + working_dir: PathBuf, + /// The descriptors the process was to have open, sorted. + fds: Vec, + process_group: ProcessGroup, +} + +impl Launch { + fn new(how: How, request: LaunchRequest) -> Self { + Self { + how, + program: request.program, + argv0: request.argv0, + args: request.args, + env: request.env, + working_dir: request.working_dir, + fds: sorted_fds(&request.open_files), + process_group: request.process_group, + } + } + + /// Returns the values of the environment variable `name`, in order. + fn env_values(&self, name: &str) -> Vec<&str> { + self.env + .iter() + .filter(|(n, _)| n == name) + .map(|(_, value)| value.as_str()) + .collect() + } +} + +fn sorted_fds(open_files: &OpenFiles) -> Vec { + let mut fds: Vec<_> = open_files.iter_fds().map(|(fd, _)| fd).collect(); + fds.sort_unstable(); + fds +} + +/// How the processes a launcher spawns fail, if they do. +#[derive(Clone, Copy, Default)] +enum Failure { + /// They don't: each exits with 42. + #[default] + None, + /// Starting them fails with an error of this kind. + Spawn(std::io::ErrorKind), + /// They start, but waiting for them fails. + Wait, +} + +/// Records what it's asked to launch, and reports each process it spawns as +/// exiting with 42 (unless told to fail). +#[derive(Clone, Default)] +struct RecordingLauncher { + launches: Arc>>, + /// Whether it can replace the shell's process. If it can, it still fails to, + /// as if the program couldn't be run, so that the shell carries on. + can_exec: bool, + failure: Failure, +} + +impl RecordingLauncher { + fn record(&self, how: How, request: LaunchRequest) -> std::io::Result<()> { + self.launches + .lock() + .map_err(|_| std::io::Error::other("lock poisoned"))? + .push(Launch::new(how, request)); + Ok(()) + } + + /// Takes what it's been asked to launch so far. + fn take(&self) -> Result> { + let mut launches = self + .launches + .lock() + .map_err(|_| anyhow::anyhow!("lock poisoned"))?; + Ok(std::mem::take(&mut *launches)) + } +} + +impl ProcessLauncher for RecordingLauncher { + fn spawn(&self, request: LaunchRequest) -> Result { + self.record(How::Spawn, request)?; + + let result = match self.failure { + Failure::None => Ok(ExecutionResult::new(42)), + Failure::Spawn(kind) => return Err(kind.into()), + Failure::Wait => Err(std::io::Error::other("lost track of the process")), + }; + Ok(ChildProcess::from_future(std::future::ready(result))) + } + + fn exec(&self, request: LaunchRequest) -> std::io::Error { + if let Err(err) = self.record(How::Exec, request) { + return err; + } + + if self.can_exec { + std::io::ErrorKind::PermissionDenied.into() + } else { + std::io::ErrorKind::Unsupported.into() + } + } +} + +type Host = ShellExtensionsImpl; + +/// Runs `script` in a shell that launches processes with `launcher`, returning +/// the shell. +async fn run(launcher: &RecordingLauncher, script: &str) -> Result> { + let mut shell = brush_core::Shell::builder_with_extensions::() + .default_builtins(BuiltinSet::BashMode) + .process_launcher(launcher.clone()) + .build() + .await?; + + let params = shell.default_exec_params(); + // The launches are under test; the recorded processes' statuses aren't. + let _ = shell + .run_string(script, &brush_core::SourceInfo::default(), ¶ms) + .await?; + + Ok(shell) +} + +/// Runs `script` as [`run`] does, returning the one process it launched. +async fn run_one(launcher: &RecordingLauncher, script: &str) -> Result { + run(launcher, script).await?; + + let mut launches = launcher.take()?.into_iter(); + let launch = launches.next().context("nothing was launched")?; + assert!( + launches.next().is_none(), + "more than one process was launched" + ); + Ok(launch) +} + +#[tokio::test] +async fn external_commands_are_launched_by_the_hosts_launcher() -> Result<()> { + let launcher = RecordingLauncher::default(); + let shell = run(&launcher, "GREETING=hi ./prog a 'b c'").await?; + + assert_eq!(shell.last_exit_status(), 42); + let mut launches = launcher.take()?.into_iter(); + let launch = launches.next().context("nothing was launched")?; + assert!(launches.next().is_none()); + assert_eq!(launch.how, How::Spawn); + assert_eq!(launch.program, Path::new("./prog")); + assert_eq!(launch.argv0, "./prog"); + assert_eq!(launch.args, ["a", "b c"]); + assert_eq!(launch.env_values("GREETING"), ["hi"]); + assert_eq!(launch.fds, [0, 1, 2]); + + Ok(()) +} + +#[tokio::test] +async fn a_closed_stream_is_launched_closed() -> Result<()> { + let launch = run_one(&RecordingLauncher::default(), "./prog <&-").await?; + + assert_eq!(launch.fds, [1, 2]); + + Ok(()) +} + +#[tokio::test] +async fn a_standard_stream_the_shell_lacks_is_launched_closed() -> Result<()> { + // As in a shell an embedder made without any open files: its processes get none + // of the shell process's own, as they'd get none of a stream the shell closed. + let launcher = RecordingLauncher::default(); + let mut shell = brush_core::Shell::builder_with_extensions::() + .default_builtins(BuiltinSet::BashMode) + .process_launcher(launcher.clone()) + .build() + .await?; + *shell.open_files_mut() = OpenFiles::default(); + + let params = shell.default_exec_params(); + // The launches are under test; the recorded processes' statuses aren't. + let _ = shell + .run_string( + "./prog\n./prog >&-", + &brush_core::SourceInfo::default(), + ¶ms, + ) + .await?; + + let launches = launcher.take()?; + let fds: Vec<_> = launches.iter().map(|l| l.fds.clone()).collect(); + assert_eq!(fds, [Vec::::new(), vec![]]); + + Ok(()) +} + +#[tokio::test] +async fn a_stream_the_shell_closed_is_launched_closed() -> Result<()> { + let launch = run_one(&RecordingLauncher::default(), "exec 1>&-\n./prog").await?; + + assert_eq!(launch.fds, [0, 2]); + + Ok(()) +} + +#[tokio::test] +async fn other_open_files_are_launched_open() -> Result<()> { + let launch = run_one(&RecordingLauncher::default(), "./prog 3<&0 4>&1").await?; + + assert_eq!(launch.fds, [0, 1, 2, 3, 4]); + + Ok(()) +} + +#[tokio::test] +async fn an_exported_underscore_is_launched_once() -> Result<()> { + let launcher = RecordingLauncher::default(); + let mut shell = shell_with_inherited_underscore(&launcher).await?; + + let params = shell.default_exec_params(); + // The launches are under test; the recorded processes' statuses aren't. + let _ = shell + .run_string("./prog", &brush_core::SourceInfo::default(), ¶ms) + .await?; + + let launches = launcher.take()?; + let launch = launches.first().context("nothing was launched")?; + assert_eq!(launch.env_values("_"), ["./prog"]); + + Ok(()) +} + +/// Builds a shell, as [`run`] does, whose `_` is exported, as it is when a shell +/// inherits it from its parent's environment. +async fn shell_with_inherited_underscore( + launcher: &RecordingLauncher, +) -> Result> { + let mut underscore = brush_core::ShellVariable::new("/from/parent"); + underscore.export(); + Ok(brush_core::Shell::builder_with_extensions::() + .default_builtins(BuiltinSet::BashMode) + .var("_", underscore) + .process_launcher(launcher.clone()) + .build() + .await?) +} + +#[tokio::test] +async fn exec_leaves_underscore_as_the_shell_exports_it() -> Result<()> { + let launcher = RecordingLauncher { + can_exec: true, + ..Default::default() + }; + let mut shell = shell_with_inherited_underscore(&launcher).await?; + let params = shell.default_exec_params(); + // The launches are under test; the recorded processes' statuses aren't. + let _ = shell + .run_string("exec ./prog", &brush_core::SourceInfo::default(), ¶ms) + .await?; + + let launches = launcher.take()?; + let launch = launches.first().context("nothing was launched")?; + assert_eq!(launch.env_values("_"), ["/from/parent"]); + + Ok(()) +} + +#[tokio::test] +async fn exec_hands_the_launcher_its_request() -> Result<()> { + let launcher = RecordingLauncher { + can_exec: true, + ..Default::default() + }; + + let launch = run_one(&launcher, "exec -a NAME ./prog x").await?; + assert_eq!(launch.how, How::Exec); + assert_eq!(launch.program, Path::new("./prog")); + assert_eq!(launch.argv0, "NAME"); + assert_eq!(launch.args, ["x"]); + + let launch = run_one(&launcher, "exec -l ./prog").await?; + assert_eq!(launch.argv0, "-./prog"); + + let launch = run_one(&launcher, "export GREETING=hi\nexec -c ./prog").await?; + assert_eq!(launch.env, []); + + Ok(()) +} + +#[tokio::test] +async fn exec_launches_the_program_running_it_would() -> Result<()> { + let launcher = RecordingLauncher { + can_exec: true, + ..Default::default() + }; + + run(&launcher, "./prog\nexec ./prog").await?; + + let launches = launcher.take()?; + let hows: Vec<_> = launches.iter().map(|l| &l.how).collect(); + assert_eq!(hows, [&How::Spawn, &How::Exec]); + assert_eq!(launches[1].program, launches[0].program); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn exec_runs_only_the_program_it_resolved() -> Result<()> { + // `printf` is a builtin too, and the builtin would set `out`; but `exec` runs + // the program found in `PATH`. + let launcher = RecordingLauncher { + can_exec: true, + ..Default::default() + }; + let shell = run(&launcher, "exec printf -v out hi").await?; + + assert_eq!(shell.env_str("out"), None); + let launches = launcher.take()?; + let [launch] = launches.as_slice() else { + anyhow::bail!("expected one launch, got {}", launches.len()); + }; + assert_eq!(launch.how, How::Exec); + assert!(launch.program.is_absolute()); + assert_eq!(launch.program.file_name(), Some("printf".as_ref())); + assert_eq!(launch.argv0, "printf"); + + Ok(()) +} + +#[tokio::test] +async fn every_way_of_running_a_command_uses_the_hosts_launcher() -> Result<()> { + let launcher = RecordingLauncher::default(); + run(&launcher, "x=$(./a)\n./b | ./c\n(./d)\n./e &\nwait").await?; + + let mut programs: Vec<_> = launcher.take()?.into_iter().map(|l| l.program).collect(); + programs.sort(); + assert_eq!( + programs, + ["./a", "./b", "./c", "./d", "./e"].map(PathBuf::from) + ); + + Ok(()) +} + +#[tokio::test] +async fn a_process_starts_in_the_shells_working_dir() -> Result<()> { + let launcher = RecordingLauncher::default(); + let script = std::format!("cd '{}'\n./prog", std::env::temp_dir().display()); + let shell = run(&launcher, &script).await?; + + // Compare with where the shell ended up, not the path given to `cd`, which a + // platform may spell differently (e.g., through a symlink on macOS). + let launches = launcher.take()?; + let launch = launches.first().context("nothing was launched")?; + assert_ne!(launch.working_dir, std::env::current_dir()?); + assert_eq!(launch.working_dir, shell.working_dir()); + + Ok(()) +} + +#[tokio::test] +async fn without_job_control_processes_stay_in_the_shells_group() -> Result<()> { + let launcher = RecordingLauncher::default(); + run(&launcher, "./a\n./b | ./c").await?; + + for launch in launcher.take()? { + assert_eq!(launch.process_group, ProcessGroup::Inherit, "{launch:?}"); + } + + Ok(()) +} + +#[tokio::test] +async fn with_job_control_a_command_gets_a_group_of_its_own() -> Result<()> { + let launcher = RecordingLauncher::default(); + let mut shell = brush_core::Shell::builder_with_extensions::() + .process_launcher(launcher.clone()) + .build() + .await?; + shell.options_mut().enable_job_control = true; + + let params = shell.default_exec_params(); + // The launches are under test; the recorded processes' statuses aren't. + let _ = shell + .run_string("./prog", &brush_core::SourceInfo::default(), ¶ms) + .await?; + + let launches = launcher.take()?; + let launch = launches.first().context("nothing was launched")?; + assert!( + matches!(launch.process_group, ProcessGroup::New { .. }), + "{:?}", + launch.process_group + ); + + Ok(()) +} + +#[tokio::test] +async fn a_program_the_launcher_cant_find_is_a_command_not_found() -> Result<()> { + let launcher = RecordingLauncher { + failure: Failure::Spawn(std::io::ErrorKind::NotFound), + ..Default::default() + }; + let shell = run(&launcher, "./prog").await?; + + assert_eq!(shell.last_exit_status(), 127); + + Ok(()) +} + +#[tokio::test] +async fn a_program_the_launcher_cant_start_cant_be_executed() -> Result<()> { + let launcher = RecordingLauncher { + failure: Failure::Spawn(std::io::ErrorKind::PermissionDenied), + ..Default::default() + }; + let shell = run(&launcher, "./prog").await?; + + assert_eq!(shell.last_exit_status(), 126); + + Ok(()) +} + +#[tokio::test] +async fn a_process_that_cant_be_waited_for_fails_the_command() -> Result<()> { + let launcher = RecordingLauncher { + failure: Failure::Wait, + ..Default::default() + }; + let shell = run(&launcher, "./prog\nstatus=$?").await?; + + assert_eq!(shell.env_str("status").as_deref(), Some("1")); + + Ok(()) +} + +#[tokio::test] +#[ignore = "TODO(exec): exec fails where the launcher can't replace the shell's process"] +async fn exec_with_a_launcher_that_cant_exec_runs_the_program_then_exits() -> Result<()> { + let launcher = RecordingLauncher::default(); + let shell = run(&launcher, "exec ./a x\n./b").await?; + + assert_eq!(shell.last_exit_status(), 42); + let spawned: Vec<_> = launcher + .take()? + .into_iter() + .filter(|l| l.how == How::Spawn) + .map(|l| (l.program, l.argv0, l.args)) + .collect(); + assert_eq!( + spawned, + [(PathBuf::from("./a"), "./a".to_owned(), vec!["x".to_owned()])] + ); + + Ok(()) +} diff --git a/userland/brush/source/brush-builtins/tests/registration_tests.rs b/userland/brush/source/brush-builtins/tests/registration_tests.rs new file mode 100644 index 0000000..df1190a --- /dev/null +++ b/userland/brush/source/brush-builtins/tests/registration_tests.rs @@ -0,0 +1,83 @@ +//! Tests for running builtins through their registrations, outside of a script. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::io::Read as _; + +use anyhow::{Context as _, Result}; +use brush_builtins::{BuiltinSet, ShellBuilderExt as _}; +use brush_core::{CommandArg, ExecutionContext, openfiles::OpenFiles}; + +async fn new_shell() -> Result { + Ok(brush_core::Shell::builder() + .default_builtins(BuiltinSet::BashMode) + .build() + .await?) +} + +#[tokio::test] +async fn a_builtin_runs_in_a_context_made_by_hand() -> Result<()> { + let mut shell = new_shell().await?; + let echo = shell.builtins().get("echo").context("no echo")?.clone(); + + let (mut reader, writer) = std::io::pipe()?; + let mut params = shell.default_exec_params(); + params.set_fd(OpenFiles::STDOUT_FD, writer.into()); + + let context = ExecutionContext::new(&mut shell, "echo".into(), params); + let args = ["echo", "hello", "world"] + .map(|arg| CommandArg::String(arg.into())) + .into(); + let result = echo.execute(context, args).await?; + assert!(result.is_success()); + + // The context, and with it the pipe's write end, is gone, so this reads + // to the end of what echo wrote. + let mut output = String::new(); + reader.read_to_string(&mut output)?; + assert_eq!(output, "hello world\n"); + + Ok(()) +} + +#[tokio::test] +async fn a_registration_reports_what_enable_changes() -> Result<()> { + let mut shell = new_shell().await?; + let echo = shell.builtins().get("echo").context("no echo")?; + assert!(echo.is_enabled()); + assert!(!echo.is_special()); + let export = shell.builtins().get("export").context("no export")?; + assert!(export.is_special()); + + let params = shell.default_exec_params(); + let result = shell + .run_string( + "enable -n echo", + &brush_core::SourceInfo::default(), + ¶ms, + ) + .await?; + assert!(result.is_success()); + let echo = shell.builtins().get("echo").context("no echo")?; + assert!(!echo.is_enabled()); + + // Enabling it through the registration shows in what `enable` lists. + shell + .builtins_mut() + .get_mut("echo") + .context("no echo")? + .set_enabled(true); + let result = shell + .run_string( + "listed=$(enable)", + &brush_core::SourceInfo::default(), + ¶ms, + ) + .await?; + assert!(result.is_success()); + let listed = shell.env_str("listed").context("listed is unset")?; + assert!(listed.lines().any(|line| line == "enable echo")); + + Ok(()) +} diff --git a/userland/brush/source/brush-builtins/tests/source_program_tests.rs b/userland/brush/source/brush-builtins/tests/source_program_tests.rs new file mode 100644 index 0000000..1fdd6d2 --- /dev/null +++ b/userland/brush/source/brush-builtins/tests/source_program_tests.rs @@ -0,0 +1,81 @@ +//! Tests of sourcing an already parsed program. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Result; +use brush_builtins::{BuiltinSet, ShellBuilderExt as _}; + +async fn new_shell() -> Result { + Ok(brush_core::Shell::builder() + .default_builtins(BuiltinSet::BashMode) + .build() + .await?) +} + +#[tokio::test] +async fn a_parsed_program_is_sourced_as_a_script() -> Result<()> { + let mut shell = new_shell().await?; + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from(std::path::PathBuf::from("lib.sh")); + let program = shell.parse_string("seen+=\"$1 \"; return 3; seen+=late")?; + + // Each run gets its own positional parameters, and `return` ends the script, not + // its caller, with its status. + for arg in ["a", "b"] { + let result = shell + .source_program(&program, &source_info, std::iter::once(arg), ¶ms) + .await?; + assert_eq!(u8::from(result.exit_code), 3); + assert!(!result.is_return_or_exit()); + } + let result = shell + .run_string("[[ $seen == 'a b ' ]]", &source_info, ¶ms) + .await?; + assert_eq!(u8::from(result.exit_code), 0); + Ok(()) +} + +#[tokio::test] +async fn a_sourced_program_runs_in_its_own_script_frame() -> Result<()> { + let mut shell = new_shell().await?; + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from(std::path::PathBuf::from("lib.sh")); + let program = shell.parse_string("src=${BASH_SOURCE[0]}")?; + + let result = shell + .source_program( + &program, + &source_info, + std::iter::empty::(), + ¶ms, + ) + .await?; + assert_eq!(u8::from(result.exit_code), 0); + let result = shell + .run_string("[[ $src == lib.sh ]]", &source_info, ¶ms) + .await?; + assert_eq!(u8::from(result.exit_code), 0); + Ok(()) +} + +#[tokio::test] +async fn exit_in_a_sourced_program_still_exits() -> Result<()> { + let mut shell = new_shell().await?; + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from(std::path::PathBuf::from("lib.sh")); + let program = shell.parse_string("exit 4; echo late")?; + + // Only a `return` ends at the script's boundary; an `exit` goes on to end the shell. + let result = shell + .source_program( + &program, + &source_info, + std::iter::empty::(), + ¶ms, + ) + .await?; + assert_eq!(u8::from(result.exit_code), 4); + assert!(result.is_exit()); + Ok(()) +} diff --git a/userland/brush/source/brush-builtins/tests/task_spawner_tests.rs b/userland/brush/source/brush-builtins/tests/task_spawner_tests.rs new file mode 100644 index 0000000..549e7ce --- /dev/null +++ b/userland/brush/source/brush-builtins/tests/task_spawner_tests.rs @@ -0,0 +1,143 @@ +//! Integration tests for starting the shell's tasks through a host's `TaskSpawner`. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use anyhow::Result; +use brush_builtins::{BuiltinSet, ShellBuilderExt as _}; +use brush_core::{ + ErrorKind, + extensions::{ + DefaultErrorFormatter, DefaultProcessLauncher, DefaultTaskSpawner, ShellExtensionsImpl, + TaskSpawner, + }, + tasks::TaskHandle, +}; + +/// Counts the tasks it's asked to start, and starts them as the default +/// spawner does. +#[derive(Clone, Default)] +struct CountingSpawner { + spawned: Arc, + spawned_blocking: Arc, +} + +impl TaskSpawner for CountingSpawner { + fn spawn(&self, future: F) -> TaskHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + self.spawned.fetch_add(1, Ordering::Relaxed); + DefaultTaskSpawner.spawn(future) + } + + fn spawn_blocking( + &self, + future: F, + ) -> impl Future> + Send + use + where + F: Future + Send + 'static, + F::Output: Send + Sync + 'static, + { + self.spawned_blocking.fetch_add(1, Ordering::Relaxed); + DefaultTaskSpawner.spawn_blocking(future) + } +} + +/// Starts tasks with [`tokio::task::spawn_local`], so they run on the +/// [`tokio::task::LocalSet`] the caller drives, as a host whose thread isn't +/// driven by tokio would start them. +#[derive(Clone, Default)] +struct LocalSpawner; + +impl TaskSpawner for LocalSpawner { + fn spawn(&self, future: F) -> TaskHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + let handle = tokio::task::spawn_local(future); + TaskHandle::from_future(async move { + handle + .await + .map_err(|err| ErrorKind::TaskError(Box::new(err)).into()) + }) + } + + #[allow( + clippy::async_yields_async, + reason = "the future yields the finished task's handle" + )] + fn spawn_blocking( + &self, + future: F, + ) -> impl Future> + Send + use + where + F: Future + Send + 'static, + F::Output: Send + Sync + 'static, + { + // Only the caller's thread can run the task, so it can't help but hold + // the caller up: hand back its handle once it has finished. + let task = self.spawn(future); + async move { + let result = task.await; + TaskHandle::from_future(std::future::ready(result)) + } + } +} + +/// Runs `script` in a shell that starts its tasks with `spawner`, returning the +/// script's exit status. +async fn run(spawner: TS, script: &str) -> Result { + let mut shell = brush_core::Shell::builder_with_extensions::< + ShellExtensionsImpl, + >() + .default_builtins(BuiltinSet::BashMode) + .task_spawner(spawner) + .build() + .await?; + + let params = shell.default_exec_params(); + let _ = shell + .run_string(script, &brush_core::SourceInfo::default(), ¶ms) + .await?; + + Ok(shell.last_exit_status()) +} + +/// A script whose pipeline stages, asynchronous list, and command substitutions +/// all run as tasks, and which succeeds only if each of them produced its output. +const SCRIPT: &str = r#" + piped=$(echo hi | { read -r line; echo "${line}!"; }) + background=$(echo bg & wait) + [[ ${piped} == "hi!" && ${background} == "bg" ]] +"#; + +#[tokio::test] +async fn the_shells_tasks_are_started_by_the_hosts_spawner() -> Result<()> { + let spawner = CountingSpawner::default(); + + assert_eq!(run(spawner.clone(), SCRIPT).await?, 0); + + assert!(spawner.spawned.load(Ordering::Relaxed) > 0); + assert!(spawner.spawned_blocking.load(Ordering::Relaxed) > 0); + + Ok(()) +} + +#[tokio::test] +async fn the_shell_runs_with_tasks_on_a_local_set() -> Result<()> { + let status = tokio::task::LocalSet::new() + .run_until(run(LocalSpawner, SCRIPT)) + .await?; + + assert_eq!(status, 0); + + Ok(()) +} diff --git a/userland/brush/source/brush-bundled-tests/Cargo.toml b/userland/brush/source/brush-bundled-tests/Cargo.toml new file mode 100644 index 0000000..ccf9d35 --- /dev/null +++ b/userland/brush/source/brush-bundled-tests/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "brush-bundled-tests" +description = "End-to-end tests for brush's bundled-command shim" +version = "0.1.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +# Depends on brush-shell, so shares its MSRV; see docs/reference/msrv-policy.md. +rust-version = "1.95.0" +publish = false + +# A brush with fake bundled commands, for the tests to run. +[[bin]] +name = "brush-with-fakes" +path = "src/main.rs" +test = false +bench = false + +[lints] +workspace = true + +[dependencies] +brush-shell = { path = "../brush-shell" } + +[dev-dependencies] +assert_cmd = "2.2.0" +tempfile = "3.27.0" diff --git a/userland/brush/source/brush-bundled-tests/src/main.rs b/userland/brush/source/brush-bundled-tests/src/main.rs new file mode 100644 index 0000000..53b28a2 --- /dev/null +++ b/userland/brush/source/brush-bundled-tests/src/main.rs @@ -0,0 +1,88 @@ +//! brush, with fake bundled commands registered. +//! +//! The bundled-command shim runs a bundled command by re-executing its own +//! binary as ` --invoke-bundled NAME ARGS...`, so testing it end to end +//! needs a brush binary that has bundled commands registered. The real one has +//! none unless built with `experimental-bundled-coreutils`. + +use std::ffi::OsString; +use std::io::{BufRead as _, Write as _}; +use std::path::Path; + +use brush_shell::bundled::{self, BundledFn}; + +fn main() { + let commands: [(&str, BundledFn); 5] = [ + ("bundled-echo", echo), + ("bundled-exit", exit), + ("bundled-cat", cat), + ("bundled-argv0", argv0), + // Shares its name with a brush builtin, which must win. + ("echo", shadowed_echo), + ]; + bundled::install( + commands + .into_iter() + .map(|(name, f)| (name.to_owned(), f)) + .collect(), + ); + + // Installing first makes brush's own (empty) providers a no-op. + brush_shell::entry::run(); +} + +/// Writes each argument, `argv[0]` included, on a line of its own in brackets. +#[allow( + clippy::needless_pass_by_value, + reason = "a bundled command's signature is fixed" +)] +fn echo(args: Vec) -> i32 { + let mut stdout = std::io::stdout().lock(); + for arg in &args { + if writeln!(stdout, "[{}]", arg.to_string_lossy()).is_err() { + return 1; + } + } + 0 +} + +/// Says on stderr which status it's exiting with, then exits with the status +/// given as its first argument. +#[allow( + clippy::needless_pass_by_value, + reason = "a bundled command's signature is fixed" +)] +fn exit(args: Vec) -> i32 { + let code = args + .get(1) + .and_then(|a| a.to_str()) + .and_then(|a| a.parse().ok()) + .unwrap_or(99); + eprintln!("bundled-exit: {code}"); + code +} + +/// Copies stdin to stdout, prefixing each line with `cat: `. +fn cat(_args: Vec) -> i32 { + let mut stdout = std::io::stdout().lock(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { return 1 }; + if writeln!(stdout, "cat: {line}").is_err() { + return 1; + } + } + 0 +} + +/// Writes the file name of the process's own `argv[0]`. +fn argv0(_args: Vec) -> i32 { + let argv0 = std::env::args_os().next().unwrap_or_default(); + let name = Path::new(&argv0).file_name().unwrap_or_default(); + println!("{}", name.to_string_lossy()); + 0 +} + +fn shadowed_echo(_args: Vec) -> i32 { + println!("bundled echo ran instead of the builtin"); + 0 +} diff --git a/userland/brush/source/brush-bundled-tests/tests/shim.rs b/userland/brush/source/brush-bundled-tests/tests/shim.rs new file mode 100644 index 0000000..5b26689 --- /dev/null +++ b/userland/brush/source/brush-bundled-tests/tests/shim.rs @@ -0,0 +1,212 @@ +//! End-to-end tests for the bundled-command shim in `brush_shell::bundled`, +//! run against `brush-with-fakes`. + +#![cfg(any(unix, windows))] +#![cfg(test)] + +use std::process::{Command, Output, Stdio}; + +/// Runs `brush-with-fakes` with `args`, with nothing from the user's +/// configuration and no stdin. +#[allow( + clippy::expect_used, + reason = "a test helper; failing to run is a test failure" +)] +fn brush(args: &[&str]) -> Output { + Command::new(assert_cmd::cargo::cargo_bin!("brush-with-fakes")) + .args(args) + .env_remove("BASH_ENV") + .env_remove("ENV") + .stdin(Stdio::null()) + .output() + .expect("running brush-with-fakes") +} + +/// Runs `script` with `brush -c`. +fn run(script: &str) -> Output { + brush(&["--norc", "--noprofile", "--no-config", "-c", script]) +} + +/// Checks that `script` writes exactly `stdout` and exits with `status`. +#[track_caller] +fn check(script: &str, stdout: &str, status: i32) { + let output = run(script); + assert_eq!( + ( + String::from_utf8_lossy(&output.stdout).as_ref(), + output.status.code() + ), + (stdout, Some(status)), + "script: {script}\nstderr: {}", + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn arguments_pass_through_verbatim() { + check( + r"bundled-echo -x --help '' 'a b' -- --invoke-bundled '*'", + "[bundled-echo]\n[-x]\n[--help]\n[]\n[a b]\n[--]\n[--invoke-bundled]\n[*]\n", + 0, + ); + check("bundled-echo", "[bundled-echo]\n", 0); + check( + r#"set -- 'x y' ''; bundled-echo "$@" "$#""#, + "[bundled-echo]\n[x y]\n[]\n[2]\n", + 0, + ); +} + +#[test] +fn exit_status_is_the_command_s() { + check("bundled-exit 42 2>/dev/null", "", 42); + check( + r#"bundled-exit 7 2>/dev/null; echo "status $?""#, + "status 7\n", + 0, + ); + check( + "bundled-exit 0 2>/dev/null && echo yes; bundled-exit 1 2>/dev/null || echo no", + "yes\nno\n", + 0, + ); +} + +#[test] +fn redirections_apply() { + check("bundled-exit 3 2>&1", "bundled-exit: 3\n", 3); + check("bundled-echo hidden >/dev/null; echo done", "done\n", 0); + + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("out.txt"); + std::fs::write(&file, "stale contents\n").unwrap(); + let file = file.to_str().unwrap(); + let quoted = file.replace('\'', r"'\''"); + check( + &format!("bundled-echo to file > '{quoted}'; bundled-echo again >> '{quoted}'"), + "", + 0, + ); + assert_eq!( + std::fs::read_to_string(file).unwrap(), + "[bundled-echo]\n[to]\n[file]\n[bundled-echo]\n[again]\n" + ); +} + +#[test] +fn pipelines() { + check( + r#"bundled-echo one two | while read -r line; do echo "got $line"; done"#, + "got [bundled-echo]\ngot [one]\ngot [two]\n", + 0, + ); + check("echo piped | bundled-cat", "cat: piped\n", 0); + check( + "bundled-echo x | bundled-cat | bundled-cat", + "cat: cat: [bundled-echo]\ncat: cat: [x]\n", + 0, + ); + check( + r#"echo x | bundled-exit 5 2>/dev/null; echo "status $?""#, + "status 5\n", + 0, + ); + check( + r#"set -o pipefail; bundled-exit 6 2>/dev/null | bundled-cat; echo "status $?""#, + "status 6\n", + 0, + ); +} + +#[test] +fn stdin_from_here_strings_and_documents() { + check("bundled-cat <<< 'here string'", "cat: here string\n", 0); + check( + "bundled-cat < for brush_core::ExecutionExitCode { + fn from(value: &GreetError) -> Self { + match value { + GreetError::RepeatCountOutOfRange => Self::InvalidUsage, + GreetError::ShellError(e) => e.into(), + GreetError::IoError(_) => Self::GeneralError, + } + } +} + +// +// Step 2: Define your builtin command's arguments +// ============================================== +// A builtin is a type that is its own parsed arguments. We recommend deriving +// `clap::Parser` and letting `brush_clap_adapter::impl_parse_args!` implement +// `brush_core::builtins::ParseArgs` from it: clap parses the arguments, validates +// them, and supplies the usage and help text. +// + +/// Greet the user with a friendly message. +#[derive(Parser)] +struct GreetCommand { + /// Number of times to repeat the greeting. + #[arg(short = 'n', long = "repeat", default_value_t = 1)] + repeat_count: usize, +} + +brush_clap_adapter::impl_parse_args!(GreetCommand); + +// +// Step 3: Implement the Builtin trait +// ============================================== +// The `Builtin` trait requires implementing the `execute` method. +// + +impl builtins::Builtin for GreetCommand { + // Specify the error type you will use; this will either be your custom type or + // the default-provided `brush_core::Error` type. + type Error = GreetError; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Additional validation. + if self.repeat_count == 0 || self.repeat_count > 10 { + return Err(GreetError::RepeatCountOutOfRange); + } + + // For demonstration, we expand a greeting string using shell variable expansion. + // This is a bit contrived, but it shows how to wrap errors coming back from + // `brush_core`. + let greeting = context + .shell + .basic_expand_string(&context.params, "Hello, ${USER}!") + .await?; + + // Execute the greeting. + for _ in 0..self.repeat_count { + writeln!(context.stdout(), "{greeting}")?; + } + + // Return success + Ok(ExecutionResult::success()) + } +} + +// +// Step 4: Integrate your builtin into a shell +// ============================================== +// This example shows how to register and use your custom builtin. +// + +async fn run_example() -> Result<()> { + // Create a shell instance with custom builtin registered. + let mut shell = brush_core::Shell::builder() + .builtin("greet", builtins::Registration::of::()) + .build() + .await?; + + // Demonstrate basic usage. + let result = shell + .run_string( + "greet -n 4", + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await?; + println!("Exit code: {}\n", u8::from(result.exit_code)); + + Ok(()) +} + +fn main() -> Result<()> { + // Construct a `tokio` runtime for async execution + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + rt.block_on(run_example())?; + + Ok(()) +} diff --git a/userland/brush/source/brush-clap-adapter/src/adapter.rs b/userland/brush/source/brush-clap-adapter/src/adapter.rs new file mode 100644 index 0000000..b3e932f --- /dev/null +++ b/userland/brush/source/brush-clap-adapter/src/adapter.rs @@ -0,0 +1,470 @@ +//! Parses builtin arguments with clap and renders their help: what +//! [`crate::impl_parse_args`] expands to. + +use brush_core::CommandArg; +use brush_core::builtins::{ContentOptions, ContentType, Invocation, UsageError}; +use clap::builder::styling; + +/// Converts a clap failure into a usage error, whose message is clap's +/// rendering of it. A request for help (`--help`) becomes +/// [`UsageError::help`]. +pub fn to_usage_error(err: &clap::Error) -> UsageError { + if err.kind() == clap::error::ErrorKind::DisplayHelp { + UsageError::help(err.to_string()) + } else { + UsageError::new(err.to_string()) + } +} + +/// Parses a builtin's arguments with clap. +/// +/// # Arguments +/// +/// * `invocation` - The invocation; its name occupies clap's bin-name slot. +/// +/// # Errors +/// +/// Returns a [`UsageError`] if clap rejects the arguments, or they ask for +/// help. +pub fn parse(invocation: Invocation<'_>) -> Result { + T::try_parse_from(argv(invocation)).map_err(|e| to_usage_error(&e)) +} + +/// Like [`parse`], for a builtin that takes options with a leading `+` as +/// well as `-`: each `+x` reaches clap as `--+x`. Arguments after `--` aren't +/// options, so they're left alone. +/// +/// # Arguments +/// +/// * `invocation` - The invocation. +/// +/// # Errors +/// +/// Returns the same errors as [`parse`]. +pub fn parse_with_plus_options( + invocation: Invocation<'_>, +) -> Result { + T::try_parse_from(rewrite_plus_options(argv(invocation))).map_err(|e| to_usage_error(&e)) +} + +/// Like [`parse`], but leaves the first `--`, and every word after it, +/// unparsed, and returns them. +/// +/// Exists because clap consumes `--` itself, while builtins such as `echo` +/// and `test` need to see it; see . +/// +/// # Arguments +/// +/// * `invocation` - The invocation. +/// +/// # Errors +/// +/// Returns the same errors as [`parse`], for the words before the separator. +pub fn parse_with_double_dash( + invocation: Invocation<'_>, +) -> Result<(T, Vec), UsageError> { + try_parse_before_double_dash::(argv(invocation)).map_err(|e| to_usage_error(&e)) +} + +/// Parses a declaration utility's arguments: its options with clap, and the +/// rest as declarations, which it returns as they arrived. +/// +/// The options are the words that start with `-` or `+`, and aren't only +/// that, up to the first word that doesn't or to `--`, which is dropped. +/// +/// # Arguments +/// +/// * `invocation` - The invocation. +/// * `plus_options` - Whether the builtin takes options with a leading `+`; +/// see [`parse_with_plus_options`]. +/// +/// # Errors +/// +/// Returns the same errors as [`parse`], for the options. +pub fn parse_declarations( + invocation: Invocation<'_>, + plus_options: bool, +) -> Result<(T, Vec), UsageError> { + let mut options = vec![invocation.name().to_owned()]; + let mut declarations = vec![]; + + let mut args = invocation.into_args().into_iter(); + for arg in args.by_ref() { + match arg { + CommandArg::String(s) if s == "--" => break, + CommandArg::String(s) if s.len() > 1 && (s.starts_with('-') || s.starts_with('+')) => { + options.push(s); + } + _ => { + declarations.push(arg); + break; + } + } + } + declarations.extend(args); + + let options = if plus_options { + rewrite_plus_options(options) + } else { + options + }; + + let this = T::try_parse_from(options).map_err(|e| to_usage_error(&e))?; + Ok((this, declarations)) +} + +/// Returns the invocation's name, followed by its arguments as strings: what +/// clap parses. +fn argv(invocation: Invocation<'_>) -> impl Iterator { + let name = invocation.name().to_owned(); + std::iter::once(name).chain(invocation.into_args().into_iter().map(String::from)) +} + +/// Renames options with a leading `+` (e.g. `+x`, `+ab`) to the per-character +/// long options (`--+x`, `--+a --+b`) that clap accepts, since clap has no +/// notion of a `+`-prefixed option. Arguments after `--` aren't options, so +/// they're left alone (e.g. a command name starting with `+`). +fn rewrite_plus_options(args: impl IntoIterator) -> Vec { + let args = args.into_iter(); + let mut updated_args = Vec::with_capacity(args.size_hint().0); + let mut options_ended = false; + for arg in args { + if options_ended { + updated_args.push(arg); + } else if let Some(plus_options) = arg.strip_prefix("+") { + updated_args.extend(plus_options.chars().map(|c| format!("--+{c}"))); + } else { + options_ended = arg == "--"; + updated_args.push(arg); + } + } + updated_args +} + +/// Parses `args` (starting with the bin name) with clap, up to the first +/// `--`, and returns that `--` and every word after it; they're empty if +/// there's no `--`. +/// +/// This exists to deal with clap's limitation of treating `--` like a +/// regular value (). +/// +/// # Arguments +/// +/// * `args` - The arguments, starting with the bin name. +/// +/// # Errors +/// +/// Returns clap's error if it rejects the arguments before the separator. +pub fn try_parse_before_double_dash( + args: impl IntoIterator, +) -> Result<(T, Vec), clap::Error> { + let mut args = args.into_iter(); + let mut found_double_dash = false; + let parsed = T::try_parse_from(args.by_ref().take_while(|a| { + found_double_dash = a == "--"; + !found_double_dash + }))?; + + let rest = if found_double_dash { + std::iter::once("--".to_owned()).chain(args).collect() + } else { + vec![] + }; + Ok((parsed, rest)) +} + +/// Returns a clap-derived builtin's help content of the given type. +/// +/// # Arguments +/// +/// * `name` - The name the builtin was invoked under. +/// * `content_type` - The type of content to retrieve. +/// * `options` - Additional options for content retrieval. +/// +/// # Errors +/// +/// Returns an error for a man page, which isn't implemented. +pub fn content( + name: &str, + content_type: ContentType, + options: &ContentOptions, +) -> Result { + let mut clap_command = T::command() + .styles(brush_help_styles()) + .next_line_help(false); + clap_command.set_bin_name(name); + + let s = match content_type { + ContentType::DetailedHelp => { + let rendered = clap_command.render_help(); + if options.colorized { + rendered.ansi().to_string() + } else { + rendered.to_string() + } + } + ContentType::ShortUsage => get_builtin_short_usage(name, &clap_command), + ContentType::ShortDescription => get_builtin_short_description(name, &clap_command), + ContentType::ManPage => { + brush_core::error::unimp("man page rendering is not yet implemented")? + } + }; + + Ok(s) +} + +fn get_builtin_short_description(name: &str, command: &clap::Command) -> String { + let about = command + .get_about() + .map_or_else(String::new, |s| s.to_string()); + + std::format!("{name} - {about}\n") +} + +fn get_builtin_short_usage(name: &str, command: &clap::Command) -> String { + let mut usage = String::new(); + + let mut needs_space = false; + + let mut optional_short_opts = vec![]; + let mut required_short_opts = vec![]; + for opt in command.get_opts() { + if opt.is_hide_set() { + continue; + } + + if let Some(c) = opt.get_short() { + if !opt.is_required_set() { + optional_short_opts.push(c); + } else { + required_short_opts.push(c); + } + } + } + + if !optional_short_opts.is_empty() { + usage.push('['); + usage.push('-'); + for c in optional_short_opts { + usage.push(c); + } + + usage.push(']'); + needs_space = true; + } + + if !required_short_opts.is_empty() { + if needs_space { + usage.push(' '); + } + + usage.push('-'); + for c in required_short_opts { + usage.push(c); + } + + needs_space = true; + } + + for pos in command.get_positionals() { + if pos.is_hide_set() { + continue; + } + + if !pos.is_required_set() { + if needs_space { + usage.push(' '); + } + + usage.push('['); + needs_space = false; + } + + for name in pos.get_value_names().into_iter().flatten() { + if needs_space { + usage.push(' '); + } + + usage.push_str(name); + needs_space = true; + } + + if !pos.is_required_set() { + usage.push(']'); + needs_space = true; + } + } + + std::format!("{name}: {name} {usage}\n") +} + +fn brush_help_styles() -> clap::builder::Styles { + styling::Styles::styled() + .header( + styling::AnsiColor::Yellow.on_default() + | styling::Effects::BOLD + | styling::Effects::UNDERLINE, + ) + .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD) + .literal(styling::AnsiColor::Magenta.on_default() | styling::Effects::BOLD) + .placeholder(styling::AnsiColor::Cyan.on_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser, Debug)] + struct Flags { + #[arg(short = 'x')] + x: bool, + #[arg(long = "+x")] + plus_x: bool, + #[arg(allow_hyphen_values = true)] + args: Vec, + } + + #[test] + fn plus_options_reach_clap_as_long_options_until_double_dash() { + let flags: Flags = + parse_with_plus_options(Invocation::new("f", ["+x", "--", "+y"])).unwrap(); + assert!(flags.plus_x && !flags.x); + assert_eq!(flags.args, ["+y"]); + } + + #[test] + fn double_dash_and_what_follows_are_left_unparsed() { + let (flags, rest): (Flags, _) = + parse_with_double_dash(Invocation::new("f", ["-x", "a", "--", "-x"])).unwrap(); + assert!(flags.x); + assert_eq!(flags.args, ["a"]); + assert_eq!(rest, ["--", "-x"]); + } + + #[test] + fn declaration_utilities_get_their_options_parsed_and_the_rest_as_given() { + let (flags, declarations): (Flags, _) = + parse_declarations(Invocation::new("f", ["-x", "+x", "a=1", "-"]), true).unwrap(); + assert!(flags.x && flags.plus_x); + let declarations: Vec<_> = declarations.into_iter().map(String::from).collect(); + assert_eq!(declarations, ["a=1", "-"]); + } + + #[test] + fn options_end_at_the_first_declaration() { + let (flags, declarations): (Flags, _) = + parse_declarations(Invocation::new("f", ["a=1", "-x", "+x"]), true).unwrap(); + assert!(!flags.x && !flags.plus_x); + let declarations: Vec<_> = declarations.into_iter().map(String::from).collect(); + assert_eq!(declarations, ["a=1", "-x", "+x"]); + } + + #[test] + fn options_end_at_a_double_dash_which_is_dropped() { + let (flags, declarations): (Flags, _) = + parse_declarations(Invocation::new("f", ["-x", "--", "-x", "--"]), true).unwrap(); + assert!(flags.x && !flags.plus_x); + let declarations: Vec<_> = declarations.into_iter().map(String::from).collect(); + assert_eq!(declarations, ["-x", "--"]); + } + + #[derive(Parser, Debug)] + struct Strict { + #[arg(short = 'x')] + x: bool, + } + + #[test] + fn a_usage_error_is_clap_s_rendering_of_the_failure() { + let err = parse::(Invocation::new("f", ["-z"])).unwrap_err(); + let clap_err = Strict::try_parse_from(["f", "-z"]).unwrap_err(); + assert_eq!(err.to_string(), clap_err.to_string()); + } + + #[test] + fn a_usage_error_names_the_invoked_name() { + let err = parse::(Invocation::new("alias", ["-z"])).unwrap_err(); + assert!(err.to_string().contains("Usage: alias"), "{err}"); + } + + #[test] + fn help_is_a_request_for_help_and_misuse_is_not() { + let help = parse::(Invocation::new("f", ["--help"])).unwrap_err(); + assert!(help.is_help(), "{help}"); + assert!(help.to_string().contains("Usage: f"), "{help}"); + let misuse = parse::(Invocation::new("f", ["-z"])).unwrap_err(); + assert!(!misuse.is_help(), "{misuse}"); + } + + /// Splits `args` with [`try_parse_before_double_dash`], into what clap + /// parsed and the rest. + fn split(args: &[&str]) -> Result<(Flags, Vec), clap::Error> { + try_parse_before_double_dash(args.iter().map(|&a| a.to_owned())) + } + + #[test] + fn without_a_double_dash_everything_is_parsed_and_nothing_is_left() { + let (flags, rest) = split(&["f", "-x", "a"]).unwrap(); + assert!(flags.x); + assert_eq!(flags.args, ["a"]); + assert_eq!(rest, Vec::::new()); + } + + #[test] + fn a_leading_double_dash_leaves_everything_unparsed() { + let (flags, rest) = split(&["f", "--", "-x"]).unwrap(); + assert!(!flags.x && flags.args.is_empty()); + assert_eq!(rest, ["--", "-x"]); + } + + #[test] + fn only_the_first_double_dash_splits() { + let (flags, rest) = split(&["f", "a", "--", "b", "--", "c"]).unwrap(); + assert_eq!(flags.args, ["a"]); + assert_eq!(rest, ["--", "b", "--", "c"]); + } + + #[test] + fn a_trailing_double_dash_is_left_alone() { + let (_, rest) = split(&["f", "-x", "--"]).unwrap(); + assert_eq!(rest, ["--"]); + } + + #[test] + fn only_words_before_the_double_dash_are_checked() { + let err = try_parse_before_double_dash::(["f", "-z", "--"].map(str::to_owned)) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + + let (strict, rest) = + try_parse_before_double_dash::(["f", "--", "-z"].map(str::to_owned)).unwrap(); + assert!(!strict.x); + assert_eq!(rest, ["--", "-z"]); + } + + #[test] + fn plus_options_become_one_long_option_per_letter() { + let rewritten = rewrite_plus_options(["f", "+ab", "-c", "x", "-"].map(str::to_owned)); + assert_eq!(rewritten, ["f", "--+a", "--+b", "-c", "x", "-"]); + } + + #[test] + fn plus_options_after_a_double_dash_are_left_alone() { + let rewritten = + rewrite_plus_options(["f", "+a", "--", "+b", "--", "+c"].map(str::to_owned)); + assert_eq!(rewritten, ["f", "--+a", "--", "+b", "--", "+c"]); + } + + #[test] + fn declarations_are_told_apart_from_options_by_their_first_character() { + let (flags, declarations): (Flags, _) = parse_declarations( + Invocation::new("f", ["-x", "+x", "a", "-", "+", "--", "b"]), + true, + ) + .unwrap(); + assert!(flags.x && flags.plus_x); + let declarations: Vec<_> = declarations.into_iter().map(String::from).collect(); + assert_eq!(declarations, ["a", "-", "+", "--", "b"]); + } +} diff --git a/userland/brush/source/brush-clap-adapter/src/lib.rs b/userland/brush/source/brush-clap-adapter/src/lib.rs new file mode 100644 index 0000000..0555de3 --- /dev/null +++ b/userland/brush/source/brush-clap-adapter/src/lib.rs @@ -0,0 +1,143 @@ +//! Binds builtins that use [clap](https://docs.rs/clap) to the engine-neutral +//! contract in [`brush_core::builtins`]. +//! +//! A builtin opts in by deriving [`clap::Parser`] and invoking +//! [`impl_parse_args`]; the macro implements +//! [`brush_core::builtins::ParseArgs`], its parsing and its help. That macro, +//! and [`to_usage_error`] and [`try_parse_before_double_dash`] for a builtin +//! that runs clap itself, are this crate's API. +//! +//! `brush-core` does not depend on clap; a builtin that uses a different engine +//! implements [`ParseArgs`](brush_core::builtins::ParseArgs) some other way and +//! never touches this crate. + +mod adapter; + +pub use adapter::{to_usage_error, try_parse_before_double_dash}; + +/// What the exported macros expand to. Not part of the API, so it can change +/// in any release. +#[doc(hidden)] +pub mod __private { + // Lets the macros name brush-core items from any calling crate. + pub use brush_core; + + pub use crate::adapter::{ + content, parse, parse_declarations, parse_with_double_dash, parse_with_plus_options, + }; +} + +/// Declares that a builtin uses clap: implements +/// [`ParseArgs`](brush_core::builtins::ParseArgs), its parsing and its help, for +/// a type that derives [`clap::Parser`]. +/// +/// # Forms +/// +/// | Form | Parsing | +/// |---|---| +/// | `impl_parse_args!(Type)` | Every argument is flattened to a word and parsed by clap. | +/// | `impl_parse_args!(Type, plus_options)` | As above, but options may also start with `+`: each `+x` reaches clap as the long option `--+x`. | +/// | `impl_parse_args!(Type, keep_double_dash = field)` | The first `--` and every word after it are appended to `field` (a `Vec`) verbatim, instead of letting clap consume the separator. | +/// | `impl_parse_args!(Type, declarations = field)` | A declaration utility: words that look like options are parsed by clap, and every other argument is kept in `field` (a `Vec`) as it arrived. Add `, plus_options` for `+` options. | +/// | `impl_parse_args!(Type, parse = parser)` | `parser` (a function or closure from an [`Invocation`](brush_core::builtins::Invocation) to `Result`) parses the arguments by hand; only the help comes from clap. Add `, declaration_utility` for a declaration utility. | +/// +/// # Examples +/// +/// ``` +/// #[derive(clap::Parser)] +/// struct GreetCommand { +/// names: Vec, +/// } +/// +/// brush_clap_adapter::impl_parse_args!(GreetCommand); +/// +/// #[derive(clap::Parser)] +/// struct EchoCommand { +/// #[arg(allow_hyphen_values = true)] +/// args: Vec, +/// } +/// +/// brush_clap_adapter::impl_parse_args!(EchoCommand, keep_double_dash = args); +/// ``` +#[macro_export] +macro_rules! impl_parse_args { + ($t:ty $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + $crate::__private::parse::(invocation) + }); + }; + + ($t:ty, plus_options $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + $crate::__private::parse_with_plus_options::(invocation) + }); + }; + + ($t:ty, keep_double_dash = $field:ident $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + let (mut this, trailing) = $crate::__private::parse_with_double_dash::(invocation)?; + this.$field.extend(trailing); + ::std::result::Result::Ok(this) + }); + }; + + ($t:ty, declarations = $field:ident $(,)?) => { + $crate::__impl_parse_args!($t, role = Utility, invocation => { + let (mut this, declarations) = + $crate::__private::parse_declarations::(invocation, false)?; + this.$field = declarations; + ::std::result::Result::Ok(this) + }); + }; + + ($t:ty, declarations = $field:ident, plus_options $(,)?) => { + $crate::__impl_parse_args!($t, role = Utility, invocation => { + let (mut this, declarations) = + $crate::__private::parse_declarations::(invocation, true)?; + this.$field = declarations; + ::std::result::Result::Ok(this) + }); + }; + + ($t:ty, parse = $parse:expr $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + ($parse)(invocation) + }); + }; + + ($t:ty, parse = $parse:expr, declaration_utility $(,)?) => { + $crate::__impl_parse_args!($t, role = Utility, invocation => { + ($parse)(invocation) + }); + }; +} + +/// Implements [`ParseArgs`](brush_core::builtins::ParseArgs) for a +/// clap-derived type, with the given body of `parse`. Internal to +/// [`impl_parse_args!`]; not part of the API. +#[doc(hidden)] +#[macro_export] +macro_rules! __impl_parse_args { + ($t:ty, role = $role:ident, $invocation:ident => $parse:block) => { + impl $crate::__private::brush_core::builtins::ParseArgs for $t { + const DECLARATION_ROLE: $crate::__private::brush_core::builtins::DeclarationRole = + $crate::__private::brush_core::builtins::DeclarationRole::$role; + + fn content( + name: &str, + content_type: $crate::__private::brush_core::builtins::ContentType, + options: &$crate::__private::brush_core::builtins::ContentOptions, + ) -> ::std::result::Result<::std::string::String, $crate::__private::brush_core::Error> + { + $crate::__private::content::(name, content_type, options) + } + + fn parse( + $invocation: $crate::__private::brush_core::builtins::Invocation<'_>, + ) -> ::std::result::Result + { + $parse + } + } + }; +} diff --git a/userland/brush/source/brush-clap-adapter/tests/impl_parse_args_tests.rs b/userland/brush/source/brush-clap-adapter/tests/impl_parse_args_tests.rs new file mode 100644 index 0000000..3923983 --- /dev/null +++ b/userland/brush/source/brush-clap-adapter/tests/impl_parse_args_tests.rs @@ -0,0 +1,499 @@ +//! Tests of what `impl_parse_args!` implements, in each of its forms, as a +//! crate that uses it sees it. + +#![cfg(test)] +#![allow(clippy::unwrap_used)] + +use brush_core::CommandArg; +use brush_core::builtins::{ + ContentOptions, ContentType, DeclarationRole, Invocation, ParseArgs, UsageError, +}; +use brush_core::parser::{SourceSpan, ast}; +use clap::Parser; + +/// Parses `args`, as the builtin `T` invoked under `name`. +fn parse( + name: &str, + args: impl IntoIterator, +) -> Result { + T::parse(Invocation::new(name, args)) +} + +/// Returns words as arguments. +fn words(words: [&str; N]) -> Vec { + words.into_iter().map(CommandArg::from).collect() +} + +/// Returns an assignment argument, as the shell passes one to a declaration +/// utility. +fn assignment(name: &str, value: &str) -> CommandArg { + CommandArg::Assignment(ast::Assignment { + name: ast::AssignmentName::VariableName(name.to_owned()), + value: ast::AssignmentValue::Scalar(ast::Word::new(value)), + append: false, + loc: SourceSpan::default(), + }) +} + +/// Describes arguments by kind and text, e.g. `["a=1" (assignment), "b"]`. +fn describe(args: &[CommandArg]) -> Vec { + args.iter() + .map(|arg| match arg { + CommandArg::String(s) => s.clone(), + CommandArg::Assignment(a) => format!("{a} (assignment)"), + }) + .collect() +} + +fn content(name: &str, content_type: ContentType, colorized: bool) -> String { + T::content(name, content_type, &ContentOptions { colorized }).unwrap() +} + +// +// impl_parse_args!(Type) +// + +#[derive(Parser, Debug)] +#[command(about = "Does plain things")] +struct Plain { + #[arg(short = 'v')] + verbose: bool, + #[arg(short = 'n')] + count: Option, + args: Vec, +} + +brush_clap_adapter::impl_parse_args!(Plain); + +#[test] +fn plain_form_is_not_a_declaration_utility() { + assert_eq!(Plain::DECLARATION_ROLE, DeclarationRole::None); +} + +#[test] +fn plain_form_parses_every_argument_with_clap() { + let plain: Plain = parse("plain", words(["-v", "-n", "3", "a", "b"])).unwrap(); + assert!(plain.verbose); + assert_eq!(plain.count, Some(3)); + assert_eq!(plain.args, ["a", "b"]); +} + +#[test] +fn plain_form_flattens_an_assignment_to_its_text() { + let plain: Plain = parse("plain", [assignment("a", "1"), "b".into()]).unwrap(); + assert_eq!(plain.args, ["a=1", "b"]); +} + +#[test] +fn plain_form_rejects_what_clap_rejects() { + let err = parse::("plain", words(["-z"])).unwrap_err(); + assert!(err.to_string().contains("'-z'"), "{err}"); + let err = parse::("plain", words(["-n", "x"])).unwrap_err(); + assert!(err.to_string().contains("'x'"), "{err}"); +} + +#[test] +fn plain_form_takes_a_double_dash_as_clap_does() { + let plain: Plain = parse("plain", words(["--", "-v"])).unwrap(); + assert!(!plain.verbose); + assert_eq!(plain.args, ["-v"]); +} + +// +// impl_parse_args!(Type, plus_options) +// + +#[derive(Parser, Debug)] +struct PlusOptions { + #[arg(short = 'x')] + x: bool, + #[arg(long = "+x")] + plus_x: bool, + #[arg(short = 'y')] + y: bool, + #[arg(long = "+y")] + plus_y: bool, + args: Vec, +} + +brush_clap_adapter::impl_parse_args!(PlusOptions, plus_options,); + +#[test] +fn plus_options_form_is_not_a_declaration_utility() { + assert_eq!(PlusOptions::DECLARATION_ROLE, DeclarationRole::None); +} + +#[test] +fn plus_options_form_takes_options_with_either_sign() { + let opts: PlusOptions = parse("p", words(["+x", "-y"])).unwrap(); + assert!(opts.plus_x && opts.y && !opts.x && !opts.plus_y); +} + +#[test] +fn plus_options_form_splits_combined_plus_options() { + let opts: PlusOptions = parse("p", words(["+xy"])).unwrap(); + assert!(opts.plus_x && opts.plus_y && !opts.x && !opts.y); +} + +#[test] +fn plus_options_form_leaves_words_after_double_dash_alone() { + let opts: PlusOptions = parse("p", words(["+x", "--", "+y", "-x"])).unwrap(); + assert!(opts.plus_x && !opts.plus_y && !opts.x); + assert_eq!(opts.args, ["+y", "-x"]); +} + +#[test] +fn plus_options_form_rejects_an_unknown_plus_option() { + assert!(parse::("p", words(["+z"])).is_err()); +} + +// +// impl_parse_args!(Type, keep_double_dash = field) +// + +#[derive(Parser, Debug)] +struct KeepDoubleDash { + #[arg(short = 'n')] + n: bool, + args: Vec, +} + +brush_clap_adapter::impl_parse_args!(KeepDoubleDash, keep_double_dash = args); + +#[test] +fn keep_double_dash_form_is_not_a_declaration_utility() { + assert_eq!(KeepDoubleDash::DECLARATION_ROLE, DeclarationRole::None); +} + +#[test] +fn keep_double_dash_form_appends_the_double_dash_and_what_follows() { + let parsed: KeepDoubleDash = parse("k", words(["-n", "a", "--", "-n", "--", "b"])).unwrap(); + assert!(parsed.n); + assert_eq!(parsed.args, ["a", "--", "-n", "--", "b"]); +} + +#[test] +fn keep_double_dash_form_without_a_double_dash_parses_as_usual() { + let parsed: KeepDoubleDash = parse("k", words(["-n", "a"])).unwrap(); + assert!(parsed.n); + assert_eq!(parsed.args, ["a"]); +} + +#[test] +fn keep_double_dash_form_with_a_leading_double_dash_parses_no_options() { + let parsed: KeepDoubleDash = parse("k", words(["--", "-n"])).unwrap(); + assert!(!parsed.n); + assert_eq!(parsed.args, ["--", "-n"]); +} + +#[test] +fn keep_double_dash_form_rejects_a_bad_option_before_the_double_dash_only() { + assert!(parse::("k", words(["-z", "--"])).is_err()); + assert!(parse::("k", words(["--", "-z"])).is_ok()); +} + +// +// impl_parse_args!(Type, declarations = field[, plus_options]) +// + +#[derive(Parser, Debug)] +struct DeclarationUtility { + #[arg(short = 'i')] + integer: bool, + #[arg(long = "+i")] + plus_integer: bool, + #[arg(skip)] + declarations: Vec, +} + +brush_clap_adapter::impl_parse_args!(DeclarationUtility, declarations = declarations); + +#[derive(Parser, Debug)] +struct PlusDeclarationUtility { + #[arg(short = 'i')] + integer: bool, + #[arg(long = "+i")] + plus_integer: bool, + #[arg(skip)] + declarations: Vec, +} + +brush_clap_adapter::impl_parse_args!( + PlusDeclarationUtility, + declarations = declarations, + plus_options, +); + +#[test] +fn declarations_forms_are_declaration_utilities() { + assert_eq!( + DeclarationUtility::DECLARATION_ROLE, + DeclarationRole::Utility + ); + assert_eq!( + PlusDeclarationUtility::DECLARATION_ROLE, + DeclarationRole::Utility + ); +} + +#[test] +fn declarations_form_keeps_each_declaration_as_it_arrived() { + let parsed: DeclarationUtility = parse( + "d", + [ + "-i".into(), + assignment("a", "1 2"), + "b".into(), + "-".into(), + "+".into(), + ], + ) + .unwrap(); + assert!(parsed.integer); + assert_eq!( + describe(&parsed.declarations), + ["a=1 2 (assignment)", "b", "-", "+"] + ); +} + +#[test] +fn declarations_form_takes_options_only_before_the_first_declaration() { + let parsed: DeclarationUtility = parse("d", words(["a", "-i", "b"])).unwrap(); + assert!(!parsed.integer); + assert_eq!(describe(&parsed.declarations), ["a", "-i", "b"]); +} + +#[test] +fn declarations_form_takes_what_follows_a_double_dash_as_declarations() { + let parsed: DeclarationUtility = parse("d", words(["-i", "--", "-i"])).unwrap(); + assert!(parsed.integer); + assert_eq!(describe(&parsed.declarations), ["-i"]); +} + +#[test] +fn declarations_form_without_plus_options_rejects_a_plus_option() { + assert!(parse::("d", words(["+i"])).is_err()); +} + +#[test] +fn declarations_form_rejects_an_unknown_option() { + assert!(parse::("d", words(["-z", "a"])).is_err()); +} + +#[test] +fn declarations_form_with_plus_options_takes_them() { + let parsed: PlusDeclarationUtility = parse("d", words(["+i", "a"])).unwrap(); + assert!(parsed.plus_integer && !parsed.integer); + assert_eq!(describe(&parsed.declarations), ["a"]); +} + +// +// impl_parse_args!(Type, parse = parser[, declaration_utility]) +// + +/// Parses by hand. +#[derive(Parser, Debug)] +#[command(about = "Parses by hand")] +struct Custom { + #[arg(skip)] + args: Vec, +} + +impl Custom { + fn parse_args(invocation: Invocation<'_>) -> Result { + let name = invocation.name().to_owned(); + let args = invocation.into_args(); + if args + .iter() + .any(|arg| matches!(arg, CommandArg::String(s) if s == "bad")) + { + return Err(UsageError::new(format!("{name}: bad argument"))); + } + Ok(Self { args }) + } +} + +brush_clap_adapter::impl_parse_args!(Custom, parse = Custom::parse_args); + +/// Parses by hand, as a declaration utility. +#[derive(Parser, Debug)] +struct CustomDeclarations { + #[arg(skip)] + args: Vec, +} + +brush_clap_adapter::impl_parse_args!( + CustomDeclarations, + parse = |invocation: Invocation<'_>| Ok(Self { + args: invocation.into_args() + }), + declaration_utility, +); + +#[test] +fn parse_form_role_follows_declaration_utility() { + assert_eq!(Custom::DECLARATION_ROLE, DeclarationRole::None); + assert_eq!( + CustomDeclarations::DECLARATION_ROLE, + DeclarationRole::Utility + ); +} + +#[test] +fn parse_form_hands_the_whole_invocation_to_the_parser() { + let parsed: Custom = parse("c", [CommandArg::from("-z"), assignment("a", "1")]).unwrap(); + assert_eq!(describe(&parsed.args), ["-z", "a=1 (assignment)"]); + + let parsed: CustomDeclarations = parse("c", words(["--help", "--"])).unwrap(); + assert_eq!(describe(&parsed.args), ["--help", "--"]); +} + +#[test] +fn parse_form_returns_the_parser_s_error() { + let err = parse::("custom", words(["ok", "bad"])).unwrap_err(); + assert_eq!(err, UsageError::new("custom: bad argument")); +} + +#[test] +fn parse_form_takes_its_help_from_clap() { + assert_eq!( + content::("custom", ContentType::ShortDescription, false), + "custom - Parses by hand\n" + ); +} + +// +// Help content, the same in every form. +// + +/// Exercises the short usage line. +#[derive(Parser, Debug)] +#[command(about = "Shows its usage")] +struct Usage { + #[arg(short = 'a')] + all: bool, + #[arg(short = 'b', value_name = "B")] + bee: Option, + #[arg(short = 'r', required = true)] + required: Option, + #[arg(short = 's', hide = true)] + secret: bool, + #[arg(long = "long-only")] + long_only: bool, + #[arg(value_name = "FILE")] + file: String, + #[arg(hide = true)] + hidden: Option, + #[arg(value_name = "REST")] + rest: Vec, +} + +brush_clap_adapter::impl_parse_args!(Usage); + +// Has no options, operands, or description (which clap would take from a +// doc comment). +#[derive(Parser, Debug)] +struct Bare {} + +brush_clap_adapter::impl_parse_args!(Bare); + +#[test] +fn short_usage_lists_optional_then_required_short_options_then_operands() { + assert_eq!( + content::("usage", ContentType::ShortUsage, false), + "usage: usage [-ab] -r FILE [REST]\n" + ); +} + +#[test] +fn short_usage_is_given_under_the_invoked_name() { + assert_eq!( + content::("alias", ContentType::ShortUsage, false), + "alias: alias [-ab] -r FILE [REST]\n" + ); +} + +/// Takes operands only. +#[derive(Parser, Debug)] +struct OperandsOnly { + #[arg(value_name = "OPTIONAL")] + optional: Option, + #[arg(value_name = "NEXT")] + next: Option, +} + +brush_clap_adapter::impl_parse_args!(OperandsOnly); + +/// Takes a required option only. +#[derive(Parser, Debug)] +struct RequiredOnly { + #[arg(short = 'r', required = true)] + required: Option, + #[arg(short = 'q', required = true)] + quiet: Option, +} + +brush_clap_adapter::impl_parse_args!(RequiredOnly); + +#[test] +fn short_usage_of_operands_alone_brackets_each_optional_one() { + assert_eq!( + content::("ops", ContentType::ShortUsage, false), + "ops: ops [OPTIONAL] [NEXT]\n" + ); +} + +#[test] +fn short_usage_of_required_options_alone_groups_them() { + assert_eq!( + content::("req", ContentType::ShortUsage, false), + "req: req -rq\n" + ); +} + +#[test] +fn short_description_is_the_name_and_clap_s_about() { + assert_eq!( + content::("alias", ContentType::ShortDescription, false), + "alias - Shows its usage\n" + ); + assert_eq!( + content::("bare", ContentType::ShortDescription, false), + "bare - \n" + ); +} + +#[test] +fn detailed_help_is_clap_s_rendering_under_the_invoked_name() { + let help = content::("alias", ContentType::DetailedHelp, false); + assert!(help.starts_with("Shows its usage\n"), "{help}"); + assert!(help.contains("Usage: alias "), "{help}"); + assert!(help.contains("--long-only"), "{help}"); + assert!(!help.contains("-s"), "a hidden option is listed: {help}"); + assert!(!help.contains('\x1b'), "uncolored help has escapes: {help}"); +} + +#[test] +fn detailed_help_is_colored_on_request() { + use clap::builder::styling::{AnsiColor, Effects}; + + let colored = content::("alias", ContentType::DetailedHelp, true); + let header = AnsiColor::Yellow.on_default() | Effects::BOLD | Effects::UNDERLINE; + let usage = AnsiColor::Green.on_default() | Effects::BOLD; + let literal = AnsiColor::Magenta.on_default() | Effects::BOLD; + for expected in [ + format!("{header}Options:"), + format!("{usage}Usage:"), + format!("{literal}--long-only"), + ] { + assert!( + colored.contains(&expected), + "{expected:?} not in {colored:?}" + ); + } +} + +#[test] +fn a_man_page_is_not_implemented() { + assert!(Usage::content("usage", ContentType::ManPage, &ContentOptions::default()).is_err()); +} diff --git a/userland/brush/source/brush-core/Cargo.toml b/userland/brush/source/brush-core/Cargo.toml new file mode 100644 index 0000000..0689fb0 --- /dev/null +++ b/userland/brush/source/brush-core/Cargo.toml @@ -0,0 +1,91 @@ +[package] +name = "brush-core" +description = "Reusable core of a POSIX/bash shell (used by brush-shell)" +version = "0.5.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[lints] +workspace = true + +[features] +default = [] +serde = ["dep:serde", "brush-parser/serde", "rpds/serde", "chrono/serde"] +experimental-parser = ["brush-parser/winnow-parser"] + +[dependencies] +async-recursion = "1.1.1" +async-trait = "0.1.89" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +bon = "3.9.1" +cached = "2.0.2" +cfg-if = "1.0.4" +chrono = "0.4.44" +color-print = "0.3.7" +fancy-regex = "0.19.0" +futures = "0.3.32" +inherent = "1.0.13" +itertools = "0.15.0" +normalize-path = "0.2.1" +rand = "0.10.0" +rpds = "1.2.0" +serde = { version = "1.0.228", optional = true, features = ["derive"] } +strum = "0.28.0" +strum_macros = "0.28.0" +thiserror = "2.0.18" +tracing = "0.1.44" + +[target.'cfg(target_family = "wasm")'.dependencies] +wit-bindgen = "0.46" +tokio = { version = "1.52.3", features = ["io-util", "macros", "rt", "sync"] } + +[target.'cfg(any(unix, windows))'.dependencies] +hostname = "0.4.2" +tokio = { version = "1.52.3", features = [ + "io-util", + "macros", + "net", + "process", + "rt", + "rt-multi-thread", + "signal", + "sync", +] } + +[target.'cfg(windows)'.dependencies] +check_elevation = "0.3.0" +whoami = "2.1.1" + +[target.'cfg(unix)'.dependencies] +command-fds = "0.3.2" +libc = "0.2.183" +nix = { version = "0.31.2", features = [ + "fs", + "poll", + "process", + "resource", + "signal", + "term", + "user", +] } +terminal_size = "0.4.4" +terminfo = "0.9.0" +uzers = "0.12.2" + +[target.wasm32-unknown-unknown.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } +uuid = { version = "1", features = ["js"] } + +[dev-dependencies] +anyhow = "1.0.102" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +tempfile = "3.27.0" +tokio = { version = "1.52.3", features = ["time"] } diff --git a/userland/brush/source/brush-core/LICENSE b/userland/brush/source/brush-core/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-core/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-core/examples/call-func.rs b/userland/brush/source/brush-core/examples/call-func.rs new file mode 100644 index 0000000..eae83ec --- /dev/null +++ b/userland/brush/source/brush-core/examples/call-func.rs @@ -0,0 +1,74 @@ +//! Example of instantiating a shell and calling a shell function in it. + +use anyhow::Result; + +async fn instantiate_shell() -> Result { + let shell = brush_core::Shell::builder().build().await?; + Ok(shell) +} + +async fn define_func(shell: &mut brush_core::Shell) -> Result<()> { + let script = r#"hello() { echo "Hello, world: $@"; return 42; } +"#; + + let result = shell + .run_string( + script, + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await?; + + eprintln!("[Function definition result: {}]", result.is_success()); + + Ok(()) +} + +async fn run_func(shell: &mut brush_core::Shell, suppress_stdout: bool) -> Result<()> { + let mut params = shell.default_exec_params(); + + if suppress_stdout { + params.set_fd( + brush_core::openfiles::OpenFiles::STDOUT_FD, + brush_core::openfiles::null()?, + ); + } + + let result = shell + .invoke_function("hello", std::iter::once("arg"), params) + .await?; + + eprintln!( + "[Function invocation result: {}]", + u8::from(result.exit_code) + ); + + Ok(()) +} + +async fn run(suppress_stdout: bool) -> Result<()> { + let mut shell = instantiate_shell().await?; + + define_func(&mut shell).await?; + + for (name, _) in shell.funcs().iter() { + eprintln!("[Found function: {name}]"); + } + + run_func(&mut shell, suppress_stdout).await?; + + Ok(()) +} + +fn main() -> Result<()> { + const SUPPRESS_STDOUT: bool = true; + + // Construct a runtime for us to run async code on. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + rt.block_on(run(SUPPRESS_STDOUT))?; + + Ok(()) +} diff --git a/userland/brush/source/brush-core/examples/readme.rs b/userland/brush/source/brush-core/examples/readme.rs new file mode 100644 index 0000000..87600ba --- /dev/null +++ b/userland/brush/source/brush-core/examples/readme.rs @@ -0,0 +1,30 @@ +//! The embedding snippet shown in the repository README. `tests/readme_snippet.rs` +//! checks that the README's copy matches the marked region below, so the README +//! can never show code that doesn't build. + +use anyhow::Result; + +async fn run() -> Result<()> { + // readme:start + let mut shell = brush_core::Shell::builder().build().await?; + + let result = shell + .run_string( + r#"greet() { echo "Hello, $1!"; }; greet world"#, + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await?; + + assert!(result.is_success()); + // readme:end + Ok(()) +} + +fn main() -> Result<()> { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + rt.block_on(run()) +} diff --git a/userland/brush/source/brush-core/sausage-process/deps/io.wit b/userland/brush/source/brush-core/sausage-process/deps/io.wit new file mode 100644 index 0000000..543cbfc --- /dev/null +++ b/userland/brush/source/brush-core/sausage-process/deps/io.wit @@ -0,0 +1,299 @@ +package wasi:io@0.2.12; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams.stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types.filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// Returns success when all of the contents written are successfully + /// flushed to output. If an error occurs at any point before all + /// contents are successfully flushed, that error is returned as soon as + /// possible. If writing and flushing the complete contents causes the + /// stream to become closed, this call should return success, and + /// subsequent calls to check-write or other interfaces should return + /// stream-error::closed. + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// Functionality is equivelant to `blocking-write-and-flush` with + /// contents given as a list of len containing only zeroes. + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/userland/brush/source/brush-core/sausage-process/process.wit b/userland/brush/source/brush-core/sausage-process/process.wit new file mode 100644 index 0000000..f993026 --- /dev/null +++ b/userland/brush/source/brush-core/sausage-process/process.wit @@ -0,0 +1,42 @@ +package sausage:process@0.1.0; + +/// Programs share one world. These resources provide lifecycle and bounded stdio transport. +interface host { + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + enum change-kind { created, modified, removed, overflow } + record change { path: string, kind: change-kind } + resource watcher { + constructor(paths: list); + subscribe: func() -> pollable; + changes: func() -> list; + } + enum stdio { pipe, inherit, null } + record request { + program: string, + argv: list, + env: list>, + cwd: string, + /// None inherits the parent's group. Zero creates a group. A nonzero ID joins a group. + group: option, + stdin: stdio, + stdout: stdio, + stderr: stdio, + } + resource child { + pid: func() -> u32; + group: func() -> u32; + stdin: func() -> output-stream; + stdout: func() -> input-stream; + stderr: func() -> input-stream; + subscribe: func() -> pollable; + status: func() -> option; + wait: func() -> s32; + kill: func(); + } + spawn: func(request: request) -> result; + kill: func(pid: u32) -> result<_, string>; +} +world program { + import host; +} diff --git a/userland/brush/source/brush-core/src/arithmetic.rs b/userland/brush/source/brush-core/src/arithmetic.rs new file mode 100644 index 0000000..cca76f6 --- /dev/null +++ b/userland/brush/source/brush-core/src/arithmetic.rs @@ -0,0 +1,423 @@ +//! Arithmetic evaluation + +use std::borrow::Cow; + +use crate::{ExecutionParameters, Shell, env, expansion, extensions, variables}; +use brush_parser::ast; + +/// Maximum recursion depth for arithmetic variable dereference chains +/// (e.g., a=b, b=c, c=a would cycle through variable dereferences). +const MAX_VARIABLE_DEREF_DEPTH: u32 = 1024; + +/// Represents an error that occurs during evaluation of an arithmetic expression. +#[derive(Debug, thiserror::Error)] +pub enum EvalError { + /// Division by zero. + #[error("division by zero")] + DivideByZero, + + /// Negative exponent. + #[error("exponent less than 0")] + NegativeExponent, + + /// Failed to tokenize an arithmetic expression. + #[error("failed to tokenize expression")] + FailedToTokenizeExpression, + + /// Failed to expand an arithmetic expression. + #[error("failed to expand expression: {0}")] + FailedToExpandExpression(String), + + /// Failed to access an element of an array. + #[error("failed to access array")] + FailedToAccessArray, + + /// Failed to update the shell environment in an assignment operator. + #[error("failed to update environment")] + FailedToUpdateEnvironment, + + /// Failed to parse an arithmetic expression. + #[error("failed to parse expression: {0}")] + ParseError(String), + + /// Error expanding an unset variable. + #[error("expanding unset variable: {0}")] + ExpandingUnsetVariable(String), + + /// Expression recursion level exceeded. + #[error("expression recursion level exceeded")] + RecursionLimitExceeded, +} + +/// Trait implemented by arithmetic expressions that can be evaluated. +pub(crate) trait ExpandAndEvaluate { + /// Evaluate the given expression, returning the resulting numeric value. + /// + /// # Arguments + /// + /// * `shell` - The shell to use for evaluation. + /// * `trace_if_needed` - Whether to trace the evaluation. + async fn eval( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + trace_if_needed: bool, + ) -> Result; +} + +impl ExpandAndEvaluate for ast::UnexpandedArithmeticExpr { + async fn eval( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + trace_if_needed: bool, + ) -> Result { + expand_and_eval(shell, params, self.value.as_str(), trace_if_needed).await + } +} + +/// Evaluate the given arithmetic expression, returning the resulting numeric value. +/// +/// # Arguments +/// +/// * `shell` - The shell to use for evaluation. +/// * `expr` - The unexpanded arithmetic expression to evaluate. +/// * `trace_if_needed` - Whether to trace the evaluation. +pub(crate) async fn expand_and_eval( + shell: &mut Shell, + params: &ExecutionParameters, + expr: &str, + trace_if_needed: bool, +) -> Result { + // Per documentation, first shell-expand it. + let options = expansion::ExpanderOptions { + tilde_expand: false, + ..Default::default() + }; + let expanded_self = expansion::basic_expand_word_with_options(shell, params, expr, &options) + .await + .map_err(|_e| EvalError::FailedToExpandExpression(expr.to_owned()))?; + + // Now parse. + let expr = brush_parser::arithmetic::parse(&expanded_self) + .map_err(|_e| EvalError::ParseError(expanded_self))?; + + // Trace if applicable. + if trace_if_needed && shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("(( {expr} ))")) + .await; + } + + // Now evaluate. + expr.eval(shell) +} + +/// Trait implemented by evaluatable arithmetic expressions. +pub trait Evaluatable { + /// Evaluate the given arithmetic expression, returning the resulting numeric value. + /// + /// # Arguments + /// + /// * `shell` - The shell to use for evaluation. + fn eval(&self, shell: &mut Shell) -> Result; +} + +impl Evaluatable for ast::ArithmeticExpr { + fn eval(&self, shell: &mut Shell) -> Result { + eval_expr_impl(self, shell, 0) + } +} + +fn eval_expr_impl( + expr: &ast::ArithmeticExpr, + shell: &mut Shell, + depth: u32, +) -> Result { + let value = match expr { + ast::ArithmeticExpr::Literal(l) => *l, + ast::ArithmeticExpr::Reference(lvalue) => deref_lvalue(shell, lvalue, depth)?, + ast::ArithmeticExpr::UnaryOp(op, operand) => apply_unary_op(shell, *op, operand, depth)?, + ast::ArithmeticExpr::BinaryOp(op, left, right) => { + apply_binary_op(shell, *op, left, right, depth)? + } + ast::ArithmeticExpr::Conditional(condition, then_expr, else_expr) => { + let conditional_eval = eval_expr_impl(condition, shell, depth)?; + + // Ensure we only evaluate the branch indicated by the condition. + if conditional_eval != 0 { + eval_expr_impl(then_expr, shell, depth)? + } else { + eval_expr_impl(else_expr, shell, depth)? + } + } + ast::ArithmeticExpr::Assignment(lvalue, rhs) => { + let expr_eval = eval_expr_impl(rhs, shell, depth)?; + assign(shell, lvalue, expr_eval, depth)? + } + ast::ArithmeticExpr::UnaryAssignment(op, lvalue) => { + apply_unary_assignment_op(shell, lvalue, *op, depth)? + } + ast::ArithmeticExpr::BinaryAssignment(op, lvalue, operand) => { + let value = apply_binary_op( + shell, + *op, + &ast::ArithmeticExpr::Reference(lvalue.clone()), + operand, + depth, + )?; + assign(shell, lvalue, value, depth)? + } + }; + + Ok(value) +} + +fn get_var_value<'a>( + shell: &'a Shell, + name: &str, +) -> Result, EvalError> { + let value = shell.env_var(name).map(|var| var.resolve_value(shell)); + + if let Some(value) = value + && value.is_set() + { + return Ok(value.to_cow_str(shell).to_string().into()); + } + + if shell.options().treat_unset_variables_as_error { + return Err(EvalError::ExpandingUnsetVariable(name.into())); + } + + Ok("".into()) +} + +fn deref_lvalue( + shell: &mut Shell, + lvalue: &ast::ArithmeticTarget, + depth: u32, +) -> Result { + let value_str: Cow<'_, str> = match lvalue { + ast::ArithmeticTarget::Variable(name) => get_var_value(shell, name.as_str())?, + ast::ArithmeticTarget::ArrayElement(name, index_expr) => { + let index_str = eval_expr_impl(index_expr, shell, depth)?.to_string(); + + shell + .env() + .get(name) + .map_or_else( + || Ok(None), + |(_, v)| v.value().get_at(index_str.as_str(), shell), + ) + .map_err(|_err| EvalError::FailedToAccessArray)? + .unwrap_or(Cow::Borrowed("")) + } + }; + + let parsed_value = brush_parser::arithmetic::parse(value_str.as_ref()) + .map_err(|_err| EvalError::ParseError(value_str.to_string()))?; + + // Literals don't need depth tracking — they can't cause recursion. + // Only increment depth when the parsed value requires further evaluation + // (i.e., it references other variables), matching bash's behavior. + if matches!(parsed_value, ast::ArithmeticExpr::Literal(_)) { + return eval_expr_impl(&parsed_value, shell, depth); + } + + let new_depth = depth + 1; + if new_depth > MAX_VARIABLE_DEREF_DEPTH { + return Err(EvalError::RecursionLimitExceeded); + } + + eval_expr_impl(&parsed_value, shell, new_depth) +} + +fn apply_unary_op( + shell: &mut Shell, + op: ast::UnaryOperator, + operand: &ast::ArithmeticExpr, + depth: u32, +) -> Result { + let operand_eval = eval_expr_impl(operand, shell, depth)?; + + match op { + ast::UnaryOperator::UnaryPlus => Ok(operand_eval), + ast::UnaryOperator::UnaryMinus => Ok(operand_eval.wrapping_neg()), + ast::UnaryOperator::BitwiseNot => Ok(!operand_eval), + ast::UnaryOperator::LogicalNot => Ok(bool_to_i64(operand_eval == 0)), + } +} + +fn apply_binary_op( + shell: &mut Shell, + op: ast::BinaryOperator, + left: &ast::ArithmeticExpr, + right: &ast::ArithmeticExpr, + depth: u32, +) -> Result { + // First, special-case short-circuiting operators. For those, we need + // to ensure we don't eagerly evaluate both operands. After we + // get these out of the way, we can easily just evaluate operands + // for the other operators. + match op { + ast::BinaryOperator::LogicalAnd => { + let left = eval_expr_impl(left, shell, depth)?; + if left == 0 { + return Ok(bool_to_i64(false)); + } + + let right = eval_expr_impl(right, shell, depth)?; + return Ok(bool_to_i64(right != 0)); + } + ast::BinaryOperator::LogicalOr => { + let left = eval_expr_impl(left, shell, depth)?; + if left != 0 { + return Ok(bool_to_i64(true)); + } + + let right = eval_expr_impl(right, shell, depth)?; + return Ok(bool_to_i64(right != 0)); + } + _ => (), + } + + // The remaining operators unconditionally operate both operands. + let left = eval_expr_impl(left, shell, depth)?; + let right = eval_expr_impl(right, shell, depth)?; + + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_sign_loss)] + match op { + ast::BinaryOperator::Power => { + if right >= 0 { + Ok(wrapping_pow_u64(left, right as u64)) + } else { + Err(EvalError::NegativeExponent) + } + } + ast::BinaryOperator::Multiply => Ok(left.wrapping_mul(right)), + ast::BinaryOperator::Divide => { + if right == 0 { + Err(EvalError::DivideByZero) + } else { + Ok(left.wrapping_div(right)) + } + } + ast::BinaryOperator::Modulo => { + if right == 0 { + Err(EvalError::DivideByZero) + } else { + Ok(left.wrapping_rem(right)) + } + } + ast::BinaryOperator::Comma => Ok(right), + ast::BinaryOperator::Add => Ok(left.wrapping_add(right)), + ast::BinaryOperator::Subtract => Ok(left.wrapping_sub(right)), + ast::BinaryOperator::ShiftLeft => Ok(left.wrapping_shl(right as u32)), + ast::BinaryOperator::ShiftRight => Ok(left.wrapping_shr(right as u32)), + ast::BinaryOperator::LessThan => Ok(bool_to_i64(left < right)), + ast::BinaryOperator::LessThanOrEqualTo => Ok(bool_to_i64(left <= right)), + ast::BinaryOperator::GreaterThan => Ok(bool_to_i64(left > right)), + ast::BinaryOperator::GreaterThanOrEqualTo => Ok(bool_to_i64(left >= right)), + ast::BinaryOperator::Equals => Ok(bool_to_i64(left == right)), + ast::BinaryOperator::NotEquals => Ok(bool_to_i64(left != right)), + ast::BinaryOperator::BitwiseAnd => Ok(left & right), + ast::BinaryOperator::BitwiseXor => Ok(left ^ right), + ast::BinaryOperator::BitwiseOr => Ok(left | right), + ast::BinaryOperator::LogicalAnd => unreachable!("LogicalAnd covered above"), + ast::BinaryOperator::LogicalOr => unreachable!("LogicalOr covered above"), + } +} + +fn apply_unary_assignment_op( + shell: &mut Shell, + lvalue: &ast::ArithmeticTarget, + op: ast::UnaryAssignmentOperator, + depth: u32, +) -> Result { + let value = deref_lvalue(shell, lvalue, depth)?; + + match op { + ast::UnaryAssignmentOperator::PrefixIncrement => { + let new_value = value.wrapping_add(1); + assign(shell, lvalue, new_value, depth)?; + Ok(new_value) + } + ast::UnaryAssignmentOperator::PrefixDecrement => { + let new_value = value.wrapping_sub(1); + assign(shell, lvalue, new_value, depth)?; + Ok(new_value) + } + ast::UnaryAssignmentOperator::PostfixIncrement => { + let new_value = value.wrapping_add(1); + assign(shell, lvalue, new_value, depth)?; + Ok(value) + } + ast::UnaryAssignmentOperator::PostfixDecrement => { + let new_value = value.wrapping_sub(1); + assign(shell, lvalue, new_value, depth)?; + Ok(value) + } + } +} + +fn assign( + shell: &mut Shell, + lvalue: &ast::ArithmeticTarget, + value: i64, + depth: u32, +) -> Result { + match lvalue { + ast::ArithmeticTarget::Variable(name) => { + shell + .env_mut() + .update_or_add( + name.as_str(), + variables::ShellValueLiteral::Scalar(value.to_string()), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + .map_err(|_err| EvalError::FailedToUpdateEnvironment)?; + } + ast::ArithmeticTarget::ArrayElement(name, index_expr) => { + let index_str = eval_expr_impl(index_expr, shell, depth)?.to_string(); + + shell + .env_mut() + .update_or_add_array_element( + name.as_str(), + index_str, + value.to_string(), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + .map_err(|_err| EvalError::FailedToUpdateEnvironment)?; + } + } + + Ok(value) +} + +const fn bool_to_i64(value: bool) -> i64 { + if value { 1 } else { 0 } +} + +// N.B. We implement our own version of wrapping_pow that takes a 64-bit exponent. +// This seems to be the best way to guarantee that we handle overflow cases +// with exponents correctly. +const fn wrapping_pow_u64(mut base: i64, mut exponent: u64) -> i64 { + let mut result: i64 = 1; + + while exponent > 0 { + if exponent % 2 == 1 { + result = result.wrapping_mul(base); + } + + base = base.wrapping_mul(base); + exponent /= 2; + } + + result +} diff --git a/userland/brush/source/brush-core/src/braceexpansion.rs b/userland/brush/source/brush-core/src/braceexpansion.rs new file mode 100644 index 0000000..7835496 --- /dev/null +++ b/userland/brush/source/brush-core/src/braceexpansion.rs @@ -0,0 +1,105 @@ +use brush_parser::word; +use itertools::Itertools; + +pub(crate) fn generate_and_combine_brace_expansions( + pieces: Vec, +) -> impl IntoIterator { + let expansions: Vec> = pieces + .into_iter() + .map(|piece| expand_brace_expr_or_text(piece).collect()) + .collect(); + + expansions + .into_iter() + .multi_cartesian_product() + .map(|v| v.join("")) +} + +fn expand_brace_expr_or_text( + beot: word::BraceExpressionOrText, +) -> Box> { + match beot { + word::BraceExpressionOrText::Expr(members) => { + // Chain all member iterators together + Box::new(members.into_iter().flat_map(expand_brace_expr_member)) + } + word::BraceExpressionOrText::Text(text) => Box::new(std::iter::once(text)), + } +} + +fn expand_brace_expr_member(bem: word::BraceExpressionMember) -> Box> { + match bem { + word::BraceExpressionMember::NumberSequence { + start, + end, + increment, + zero_padded_width, + } => { + let increment = increment.unsigned_abs().max(1); + + // A bound written with a leading zero asks for every member to be padded + // out to the width of the longer bound, so `{01..10}` counts `01 02 ...` + // and not `1 2 ...`. The sign takes one of those columns. + let format = move |n: i64| match zero_padded_width { + Some(width) => std::format!("{n:0width$}"), + None => n.to_string(), + }; + + if start <= end { + // Iterate from start up to end by incrementing, stopping rather than + // overflowing when the next member would pass `i64::MAX`. Stepping in + // `i64` also keeps an increment wider than `usize` from truncating on + // 32-bit targets. + Box::new( + std::iter::successors(Some(start), move |&n| { + n.checked_add_unsigned(increment) + .filter(|&next| next <= end) + }) + .map(format), + ) + } else { + // Iterate from start down to end by decrementing, stopping rather than + // overflowing when the next member would fall below `i64::MIN`. + Box::new( + std::iter::successors(Some(start), move |&n| { + n.checked_sub_unsigned(increment) + .filter(|&next| next >= end) + }) + .map(format), + ) + } + } + + word::BraceExpressionMember::CharSequence { + start, + end, + increment, + } => { + let increment = increment.unsigned_abs().max(1); + + if start <= end { + // An increment too large for a `usize` (on 32-bit targets) steps past + // every character, so it saturates instead of truncating to zero. + let increment = usize::try_from(increment).unwrap_or(usize::MAX); + Box::new((start..=end).step_by(increment).map(|c| c.to_string())) + } else { + // Iterate from start down to end by decrementing. An increment too + // large for a `u32` steps past every character, so it ends the sequence + // after its first member instead of being truncated. + let increment = u32::try_from(increment).unwrap_or(u32::MAX); + Box::new( + std::iter::successors(Some(start), move |&c| { + let next = char::from_u32((c as u32).checked_sub(increment)?)?; + (next >= end).then_some(next) + }) + .map(|c| c.to_string()), + ) + } + } + + word::BraceExpressionMember::Child(elements) => { + // Chain all element iterators together + Box::new(generate_and_combine_brace_expansions(elements).into_iter()) + } + } +} diff --git a/userland/brush/source/brush-core/src/builtins.rs b/userland/brush/source/brush-core/src/builtins.rs new file mode 100644 index 0000000..73455ac --- /dev/null +++ b/userland/brush/source/brush-core/src/builtins.rs @@ -0,0 +1,249 @@ +//! The contract between the shell and its builtins. +//! +//! A builtin is a type that is its own parsed arguments: it implements +//! [`ParseArgs`] (an engine adapter can, from a derive) and [`Builtin`], +//! whose `execute` runs it, and is registered with [`Registration::of`]. +//! One type can serve several names (`declare` serves four), and tell them +//! apart by the name it was invoked under. +//! +//! `brush-core` names no argument-parsing engine: a builtin that uses clap +//! implements [`ParseArgs`] through the `brush-clap-adapter` crate, and one +//! that parses by hand implements it directly. + +pub use futures::future::BoxFuture; +use std::io::Write; + +use crate::{BuiltinError, CommandArg, commands, error, extensions, results}; + +mod args; +mod registry; +mod state; + +pub use args::{Invocation, ParseArgs, UsageError}; +pub use registry::Registry; +pub(crate) use state::BuiltinStates; + +/// Type of help content, typically associated with a built-in command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContentType { + /// Detailed help content for the command. + DetailedHelp, + /// Short usage information for the command. + ShortUsage, + /// Short description for the command. + ShortDescription, + /// man-style help page. + ManPage, +} + +/// Options for retrieving built-in command content. +#[derive(Default)] +pub struct ContentOptions { + /// Whether or not the content should be colorized. + pub colorized: bool, +} + +/// A builtin's role in declarations: whether its assignment-shaped operands +/// are declarations. +/// +/// The shell reads this *before* expanding a command's arguments, because it +/// changes how assignment-shaped words expand: for a declaration utility, a +/// word like `x=$v` expands as an assignment (no field splitting or globbing) +/// and arrives as [`CommandArg::Assignment`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum DeclarationRole { + /// None: assignment-shaped words are ordinary words. + #[default] + None, + /// A declaration utility (`declare`, `export`, `local`, `readonly`, + /// `typeset`). + Utility, +} + +/// The common shape of a builtin: a type that is its own parsed arguments. +/// +/// Its parsing half is [`ParseArgs`], which an engine adapter can implement +/// from a derive; [`execute`](Self::execute) is the rest. Register it with +/// [`Registration::of`]. Each invocation is parsed into a value of the type, +/// which is then executed, and consumed. One type may be registered under +/// several names, and tell them apart by the name it was invoked under +/// ([`Invocation::name`], [`ExecutionContext::command_name`](crate::ExecutionContext::command_name)). +/// +/// # Shells +/// +/// A builtin is implemented for the shells it runs in, by their extensions +/// type (`SE`). One meant to run in any shell implements it for every `SE` +/// (`impl Builtin for Cd`), as brush's own builtins +/// do; an embedder's builtin can instead implement it for its own shell's +/// type alone (`impl Builtin for Cd`), to rely on that type. +/// +/// # State +/// +/// State a builtin keeps between invocations lives in the shell; see +/// [`ExecutionContext::state_mut`](crate::ExecutionContext::state_mut). +pub trait Builtin: ParseArgs + 'static { + /// The error type returned by the builtin. + type Error: BuiltinError + 'static; + + /// Executes the builtin in the provided context, consuming its parsed + /// arguments. + /// + /// # Arguments + /// + /// * `context` - The context in which the builtin is being executed. + /// + /// # Errors + /// + /// Returns an error for a failure that stops the builtin; the shell + /// reports it, attributed to the builtin. + // NOTE: we use desugared async here because we need a Send marker + fn execute( + self, + context: commands::ExecutionContext<'_, SE>, + ) -> impl Future> + Send; +} + +/// How a [`Registration`] runs its builtin: parses and runs one invocation, +/// whose arguments start with the name it was invoked under. +type ExecuteFn = for<'a> fn( + commands::ExecutionContext<'a, SE>, + Vec, +) -> BoxFuture<'a, Result>; + +/// How a [`Registration`] gets its builtin's help; see [`ParseArgs::content`]. +type ContentFn = fn(&str, ContentType, &ContentOptions) -> Result; + +/// The [`ExecuteFn`] for the builtin `B`. +fn execute_boxed>( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> BoxFuture<'_, Result> { + Box::pin(invoke::(context, args)) +} + +/// Parses and runs one invocation of the builtin `B`. `args` starts with the +/// name it was invoked under. +async fn invoke>( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + let mut args = args.into_iter(); + let name = args + .next() + .map_or_else(|| context.command_name.clone(), String::from); + + let builtin = match B::parse(Invocation::new(&name, args)) { + Ok(builtin) => builtin, + Err(usage) => { + let _ = if usage.is_help() { + writeln!(context.stdout(), "{usage}") + } else { + writeln!(context.stderr(), "{usage}") + }; + return Ok(results::ExecutionExitCode::InvalidUsage.into()); + } + }; + + builtin + .execute(context) + .await + .map_err(|e| error::ErrorKind::BuiltinError(Box::new(e), name).into()) +} + +/// Encapsulates a registration for a built-in command. +#[derive(Clone)] +pub struct Registration { + /// Runs the builtin. + execute: ExecuteFn, + + /// Gets the builtin's help. + content: ContentFn, + + /// Whether the builtin is enabled; see [`Registration::is_enabled`]. + enabled: bool, + + /// Is the builtin classified as "special" by specification? + special_builtin: bool, + + /// The builtin's role in declarations; kept here, as the shell asks for it + /// while expanding a command's arguments. + declaration_role: DeclarationRole, +} + +impl Registration { + /// Returns an enabled registration for the [`Builtin`] `B`. + #[doc(alias = "new")] + pub const fn of>() -> Self { + Self { + execute: execute_boxed::, + content: B::content, + enabled: true, + special_builtin: false, + declaration_role: B::DECLARATION_ROLE, + } + } + + /// Updates the given registration to mark it for a special builtin. + #[must_use] + pub const fn special(self) -> Self { + Self { + special_builtin: true, + ..self + } + } + + /// Runs the builtin. + /// + /// # Arguments + /// + /// * `context` - The context in which the builtin runs. + /// * `args` - The arguments to the builtin, starting with its name. + pub fn execute<'a>( + &self, + context: commands::ExecutionContext<'a, SE>, + args: Vec, + ) -> BoxFuture<'a, Result> { + (self.execute)(context, args) + } + + /// Returns the builtin's help content of the given type. + /// + /// # Arguments + /// + /// * `name` - The name the builtin is registered under. + /// * `content_type` - The type of content to retrieve. + /// * `options` - Additional options for content retrieval. + pub fn content( + &self, + name: &str, + content_type: ContentType, + options: &ContentOptions, + ) -> Result { + (self.content)(name, content_type, options) + } + + /// Returns the builtin's role in declarations. + pub const fn declaration_role(&self) -> DeclarationRole { + self.declaration_role + } + + /// Returns whether the builtin is enabled; `enable -n` disables one. + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + /// Enables or disables the builtin. + /// + /// # Arguments + /// + /// * `enabled` - Whether the builtin is to be enabled. + pub const fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + /// Returns whether the builtin is classified as "special" by POSIX. + pub const fn is_special(&self) -> bool { + self.special_builtin + } +} diff --git a/userland/brush/source/brush-core/src/builtins/args.rs b/userland/brush/source/brush-core/src/builtins/args.rs new file mode 100644 index 0000000..9907a14 --- /dev/null +++ b/userland/brush/source/brush-core/src/builtins/args.rs @@ -0,0 +1,266 @@ +//! Engine-neutral contract for parsing builtin command arguments. +//! +//! A builtin turns its arguments into a typed value through +//! [`ParseArgs::parse`], given an [`Invocation`]: the name it was invoked +//! under and the arguments that followed. Parsing sees only those words, not +//! the shell's state, so a parser can be tested on words alone. +//! +//! The shell does no option handling on a builtin's behalf. It expands the +//! arguments and passes them through exactly as expanded; deciding which are +//! options, operands, or declarations is entirely up to the implementation. +//! +//! `brush-core` does not depend on any parsing engine; see [`ParseArgs`]. + +use crate::builtins::{ContentOptions, ContentType, DeclarationRole}; +use crate::{CommandArg, error}; + +/// One invocation of a builtin, as handed to its parser. +/// +/// Its fields are private, so it can carry more later without breaking parsers. +#[derive(Clone, Debug)] +pub struct Invocation<'a> { + name: &'a str, + args: Vec, +} + +impl<'a> Invocation<'a> { + /// Creates an invocation. The shell creates them; this is public so that a + /// builtin's parser can be tested on its own: + /// `ShiftCommand::parse(Invocation::new("shift", ["2"]))`. + /// + /// # Arguments + /// + /// * `name` - The name the builtin was invoked under. + /// * `args` - The arguments following the name: words (`&str` or `String`) + /// or [`CommandArg`]s. + pub fn new(name: &'a str, args: impl IntoIterator>) -> Self { + Self { + name, + args: args.into_iter().map(Into::into).collect(), + } + } + + /// Returns the name the builtin was invoked under. It is never repeated in + /// the arguments. + pub const fn name(&self) -> &'a str { + self.name + } + + /// Returns the arguments following the name, as expanded. Each is a + /// [`CommandArg::String`] unless the builtin is a declaration utility (see + /// [`DeclarationRole`]), whose assignment-shaped arguments arrive as + /// [`CommandArg::Assignment`]. + pub fn args(&self) -> &[CommandArg] { + &self.args + } + + /// Takes the arguments. To treat them all as strings, convert each with + /// [`String::from`]. + pub fn into_args(self) -> Vec { + self.args + } +} + +/// Misuse of a builtin: a problem with how it was invoked. +/// +/// A parser returns one from [`ParseArgs::parse`]. The shell writes its +/// message to the builtin's standard error, as it is, and the command fails +/// with the invalid-usage status (2). A request for help ([`UsageError::help`]) +/// goes to standard output instead, with the same status, as in bash. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("{message}")] +pub struct UsageError { + message: String, + help: bool, +} + +impl UsageError { + /// Misuse, described by `message`. + /// + /// # Arguments + /// + /// * `message` - What was wrong, as the shell is to print it. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + help: false, + } + } + + /// A request for help (`--help`), answered with `text`. + /// + /// # Arguments + /// + /// * `text` - The help, as the shell is to print it. + pub fn help(text: impl Into) -> Self { + Self { + message: text.into(), + help: true, + } + } + + /// Returns whether this is a request for help rather than misuse. + pub const fn is_help(&self) -> bool { + self.help + } +} + +/// The parsing half of a builtin: how its arguments are read, and its help. +/// +/// See [`super::Builtin`]. Engine adapters implement it from a derive (e.g. +/// `brush_clap_adapter::impl_parse_args!`); a builtin can also implement it by +/// hand: +/// +/// ``` +/// use brush_core::builtins::{ContentOptions, ContentType, Invocation, ParseArgs, UsageError}; +/// +/// struct ShiftCommand { +/// n: Option, +/// } +/// +/// impl ParseArgs for ShiftCommand { +/// fn content( +/// name: &str, +/// content_type: ContentType, +/// _options: &ContentOptions, +/// ) -> Result { +/// Ok(match content_type { +/// ContentType::ShortUsage => format!("{name}: {name} [n]\n"), +/// _ => format!("{name} - Shift positional parameters.\n"), +/// }) +/// } +/// +/// fn parse(invocation: Invocation<'_>) -> Result { +/// let args: Vec = invocation.into_args().into_iter().map(String::from).collect(); +/// match args.as_slice() { +/// [] => Ok(Self { n: None }), +/// [n] => n +/// .parse() +/// .map(|n| Self { n: Some(n) }) +/// .map_err(|_| UsageError::new(format!("shift: {n}: numeric argument required"))), +/// _ => Err(UsageError::new("shift: too many arguments")), +/// } +/// } +/// } +/// +/// let shift = ShiftCommand::parse(Invocation::new("shift", ["2"])).unwrap(); +/// assert_eq!(shift.n, Some(2)); +/// let err = ShiftCommand::parse(Invocation::new("shift", ["1", "2"])).err().unwrap(); +/// assert_eq!(err.to_string(), "shift: too many arguments"); +/// ``` +pub trait ParseArgs: Sized + Send { + /// The builtin's role in declarations; see [`DeclarationRole`]. + const DECLARATION_ROLE: DeclarationRole = DeclarationRole::None; + + /// Returns the builtin's help content of the given type, as invoked under + /// `name`. + /// + /// # Arguments + /// + /// * `name` - The name the builtin was invoked under. + /// * `content_type` - The type of content to retrieve. + /// * `options` - Additional options for content retrieval. + /// + /// # Errors + /// + /// Returns an error if the content can't be produced. + fn content( + name: &str, + content_type: ContentType, + options: &ContentOptions, + ) -> Result; + + /// Parses an invocation's arguments. + /// + /// # Arguments + /// + /// * `invocation` - The invocation. + /// + /// # Errors + /// + /// Returns an error if the arguments are invalid. + fn parse(invocation: Invocation<'_>) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use brush_parser::ast; + + fn assignment(text_name: &str, value: &str) -> CommandArg { + CommandArg::Assignment(ast::Assignment { + name: ast::AssignmentName::VariableName(text_name.to_owned()), + value: ast::AssignmentValue::Scalar(ast::Word::new(value)), + append: false, + loc: crate::SourceSpan::default(), + }) + } + + #[test] + fn an_invocation_takes_words_or_arguments() { + let from_strs = Invocation::new("cmd", ["a", "b"]); + let from_strings = Invocation::new("cmd", vec![String::from("a"), String::from("b")]); + let from_args = Invocation::new("cmd", [CommandArg::from("a"), "b".into()]); + for invocation in [from_strs, from_strings, from_args] { + assert_eq!(invocation.name(), "cmd"); + let args: Vec<_> = invocation.args().iter().map(ToString::to_string).collect(); + assert_eq!(args, ["a", "b"]); + } + } + + #[test] + fn an_invocation_may_have_no_arguments() { + let invocation = Invocation::new("cmd", Vec::::new()); + assert!(invocation.args().is_empty()); + assert!(invocation.into_args().is_empty()); + } + + #[test] + fn an_invocation_s_name_outlives_it() { + let invocation = Invocation::new("cmd", ["a"]); + let name = invocation.name(); + let args = invocation.into_args(); + assert_eq!((name, args.len()), ("cmd", 1)); + } + + #[test] + fn an_invocation_keeps_its_arguments_order_and_kind() { + let invocation = Invocation::new("cmd", ["-a".into(), assignment("x", "1 2"), "y".into()]); + let args = invocation.clone().into_args(); + assert!(matches!(&args[0], CommandArg::String(s) if s == "-a")); + assert!(matches!(&args[1], CommandArg::Assignment(a) if a.to_string() == "x=1 2")); + assert!(matches!(&args[2], CommandArg::String(s) if s == "y")); + assert_eq!( + invocation.args().len(), + 3, + "a clone leaves the original whole" + ); + } + + #[test] + fn a_usage_error_displays_its_message_as_it_is() { + let err = UsageError::new("cmd: -z: invalid option\ncmd: usage: cmd [-a]"); + assert_eq!( + err.to_string(), + "cmd: -z: invalid option\ncmd: usage: cmd [-a]" + ); + assert_eq!(UsageError::new(String::from("x")), UsageError::new("x")); + assert_ne!(UsageError::new("x"), UsageError::new("y")); + } + + #[test] + fn a_help_request_is_told_apart_from_misuse() { + let help = UsageError::help("cmd: cmd [-a]"); + assert!(help.is_help()); + assert_eq!(help.to_string(), "cmd: cmd [-a]"); + assert!(!UsageError::new("cmd: bad").is_help()); + assert_ne!(help, UsageError::new("cmd: cmd [-a]")); + } + + #[test] + fn a_usage_error_is_an_error_with_no_source() { + let err: Box = Box::new(UsageError::new("cmd: bad")); + assert!(err.source().is_none()); + assert_eq!(err.to_string(), "cmd: bad"); + } +} diff --git a/userland/brush/source/brush-core/src/builtins/registry.rs b/userland/brush/source/brush-core/src/builtins/registry.rs new file mode 100644 index 0000000..b9d8695 --- /dev/null +++ b/userland/brush/source/brush-core/src/builtins/registry.rs @@ -0,0 +1,225 @@ +//! The builtins registered with a shell. + +use std::collections::HashMap; + +use super::Registration; +use crate::extensions; + +/// The builtins registered with a shell, by name; see +/// [`Shell::builtins`](crate::Shell::builtins) and +/// [`Shell::builtins_mut`](crate::Shell::builtins_mut). +/// +/// How it stores them is private, so that can change without breaking +/// anyone. A clone (as a subshell's) is independent of the registry it was +/// cloned from: registering, enabling or disabling a builtin in one doesn't +/// change the other. +#[derive(Clone, Default)] +pub struct Registry { + registrations: HashMap>, +} + +impl Registry { + /// Returns the builtin registered under `name`, if any. It may be disabled + /// (see [`Registration::is_enabled`]). + /// + /// # Arguments + /// + /// * `name` - The builtin's name. + pub fn get(&self, name: &str) -> Option<&Registration> { + self.registrations.get(name) + } + + /// Returns the builtin registered under `name`, if any, to change it (to + /// enable or disable it, say). + /// + /// # Arguments + /// + /// * `name` - The builtin's name. + pub fn get_mut(&mut self, name: &str) -> Option<&mut Registration> { + self.registrations.get_mut(name) + } + + /// Registers a builtin under `name`, replacing any registered under it. + /// + /// The registration replaced is gone with all it said about the builtin: + /// a special builtin replaced by a registration made without + /// [`Registration::special`] is no longer special. + /// + /// # Arguments + /// + /// * `name` - The builtin's name. + /// * `registration` - The builtin's registration. + pub fn register(&mut self, name: impl Into, registration: Registration) { + self.registrations.insert(name.into(), registration); + } + + /// Registers a builtin under `name` unless one is registered under it + /// already, which is left as it is. + /// + /// # Arguments + /// + /// * `name` - The builtin's name. + /// * `registration` - The builtin's registration. + pub fn register_if_absent(&mut self, name: impl Into, registration: Registration) { + self.registrations + .entry(name.into()) + .or_insert(registration); + } + + /// Returns the registered builtins with their names, enabled or not, in + /// order of name (by byte value, so `.`, `:` and `[` come before letters). + pub fn iter(&self) -> impl Iterator)> { + // Stored unordered, for fast lookup by name; listing them is rare + // enough that sorting each time costs nothing that matters. + let mut entries: Vec<_> = self + .registrations + .iter() + .map(|(name, registration)| (name.as_str(), registration)) + .collect(); + entries.sort_unstable_by_key(|(name, _)| *name); + entries.into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builtins::{ + Builtin, ContentOptions, ContentType, Invocation, ParseArgs, UsageError, + }; + use crate::{ExecutionContext, ExecutionResult, extensions::DefaultShellExtensions}; + + /// A builtin that does nothing; registrations of it differ only in what + /// they say about it. + struct Nothing; + + impl ParseArgs for Nothing { + fn content( + name: &str, + _content_type: ContentType, + _options: &ContentOptions, + ) -> Result { + Ok(format!("{name}\n")) + } + + fn parse(_invocation: Invocation<'_>) -> Result { + Ok(Self) + } + } + + impl Builtin for Nothing { + type Error = crate::Error; + + #[allow( + clippy::unused_async_trait_impl, + reason = "the trait's method is async; this one has nothing to wait for" + )] + async fn execute( + self, + _context: ExecutionContext<'_, SE>, + ) -> Result { + Ok(ExecutionResult::success()) + } + } + + type TestRegistry = Registry; + + fn plain() -> Registration { + Registration::of::() + } + + fn special() -> Registration { + Registration::of::().special() + } + + fn names(registry: &TestRegistry) -> Vec<&str> { + registry.iter().map(|(name, _)| name).collect() + } + + #[test] + fn an_empty_registry_finds_nothing() { + let mut registry = TestRegistry::default(); + assert!(registry.get("x").is_none()); + assert!(registry.get_mut("x").is_none()); + assert_eq!(registry.iter().count(), 0); + } + + #[test] + fn a_registered_builtin_is_found_by_its_name_alone() { + let mut registry = TestRegistry::default(); + registry.register("x", special()); + assert!(registry.get("x").is_some_and(Registration::is_special)); + assert!(registry.get("X").is_none()); + assert!(registry.get("x ").is_none()); + } + + #[test] + fn register_replaces_what_was_registered() { + let mut registry = TestRegistry::default(); + registry.register("x", special()); + registry.register("x", plain()); + assert!(registry.get("x").is_some_and(|r| !r.is_special())); + assert_eq!(names(®istry), ["x"]); + } + + #[test] + fn register_if_absent_keeps_what_was_registered() { + let mut registry = TestRegistry::default(); + registry.register_if_absent("x", special()); + assert!(registry.get("x").is_some_and(Registration::is_special)); + + registry.register_if_absent("x", plain()); + assert!(registry.get("x").is_some_and(Registration::is_special)); + assert_eq!(names(®istry), ["x"]); + } + + #[test] + fn a_builtin_is_disabled_and_enabled_through_its_registration() { + let mut registry = TestRegistry::default(); + registry.register("x", plain()); + registry.register("y", plain()); + + registry + .get_mut("x") + .into_iter() + .for_each(|r| r.set_enabled(false)); + assert!(registry.get("x").is_some_and(|r| !r.is_enabled())); + assert!(registry.get("y").is_some_and(Registration::is_enabled)); + // A disabled builtin is still listed. + assert_eq!(names(®istry), ["x", "y"]); + + registry + .get_mut("x") + .into_iter() + .for_each(|r| r.set_enabled(true)); + assert!(registry.get("x").is_some_and(Registration::is_enabled)); + } + + #[test] + fn builtins_are_listed_in_order_of_name() { + let mut registry = TestRegistry::default(); + for name in ["wait", "[", "alias", ":", "echo", ".", "Z", "a"] { + registry.register(name, plain()); + } + assert_eq!( + names(®istry), + [".", ":", "Z", "[", "a", "alias", "echo", "wait"] + ); + } + + #[test] + fn a_clone_changes_independently() { + let mut registry = TestRegistry::default(); + registry.register("x", plain()); + + let mut copy = registry.clone(); + copy.get_mut("x") + .into_iter() + .for_each(|r| r.set_enabled(false)); + copy.register("y", plain()); + + assert!(registry.get("x").is_some_and(Registration::is_enabled)); + assert_eq!(names(®istry), ["x"]); + assert_eq!(names(©), ["x", "y"]); + } +} diff --git a/userland/brush/source/brush-core/src/builtins/state.rs b/userland/brush/source/brush-core/src/builtins/state.rs new file mode 100644 index 0000000..d76b5e2 --- /dev/null +++ b/userland/brush/source/brush-core/src/builtins/state.rs @@ -0,0 +1,80 @@ +//! State a builtin keeps between invocations, which follows the shell's +//! subshell rules. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; + +/// A builtin's state, as the shell stores it: any `Clone + Send + Sync` value. +/// +/// The shell clones it when it clones itself (for a subshell, a pipeline +/// stage, or a command substitution), so a change made in a subshell stays +/// there, as a change to a variable does. +/// +/// `Box` satisfies the blanket implementation itself, so the +/// shell calls these through an explicit dereference of the box. +pub(crate) trait AnyState: Any + Send + Sync { + /// Clones the state, boxed. + fn clone_box(&self) -> Box; + /// The state, for downcasting. + fn as_any(&self) -> &dyn Any; + /// The state, for downcasting mutably. + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl AnyState for T { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.as_ref().clone_box() + } +} + +/// Every builtin's state, keyed by the state's type alone: the type is the +/// state's identity, whatever name the builtin that uses it was invoked under. +/// So one type registered under several names (`declare` and `typeset`) keeps +/// one state, and related builtins share one by sharing its type. +#[derive(Clone, Default)] +pub(crate) struct BuiltinStates { + states: HashMap>, +} + +impl BuiltinStates { + /// Returns the state of type `S`, if a builtin has used it. + pub(crate) fn get(&self) -> Option<&S> { + let state = self.states.get(&TypeId::of::())?; + (**state).as_any().downcast_ref() + } + + /// Sets the state of type `S`, replacing any there was. + pub(crate) fn set(&mut self, state: S) { + self.states.insert(TypeId::of::(), Box::new(state)); + } + + /// Returns the state of type `S`, seeding it with `S::default()` on first + /// use. + #[allow( + clippy::unwrap_used, + reason = "the state under a type's id is of that type" + )] + pub(crate) fn get_mut_or_default( + &mut self, + ) -> &mut S { + let state = self + .states + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(S::default())); + (**state).as_any_mut().downcast_mut().unwrap() + } +} diff --git a/userland/brush/source/brush-core/src/callstack.rs b/userland/brush/source/brush-core/src/callstack.rs new file mode 100644 index 0000000..33b9bf7 --- /dev/null +++ b/userland/brush/source/brush-core/src/callstack.rs @@ -0,0 +1,787 @@ +//! Call stack representations. + +use crate::{functions, traps}; +use std::{ + borrow::Cow, + collections::{HashSet, VecDeque}, + sync::Arc, +}; + +use brush_parser::ast::SourceLocation; + +/// Encapsulates info regarding a script call. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ScriptCall { + /// The type of script call. + pub call_type: ScriptCallType, + /// The source info for the script called. + pub source_info: crate::SourceInfo, +} + +impl ScriptCall { + /// Returns the name of the script that was called. + pub fn name(&self) -> Cow<'_, str> { + self.source_info.source.as_str().into() + } +} + +/// The type of script call. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ScriptCallType { + /// A script was sourced. + Source, + /// A script was executed. + Run, +} + +impl std::fmt::Display for ScriptCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.call_type { + ScriptCallType::Source => write!(f, "source({})", self.source_info), + ScriptCallType::Run => write!(f, "script({})", self.source_info), + } + } +} + +/// Represents the type of a frame, indicating how it was invoked from +/// a different source context. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FrameType { + /// A script was called (sourced or executed). + Script(ScriptCall), + /// A function was called. + Function(FunctionCall), + /// A trap handler was invoked. + TrapHandler(traps::TrapSignal), + /// A string was eval'd. + Eval, + /// A command-line string (i.e., -c) was executed. + CommandString, + /// An interactive command session was started. + InteractiveSession, +} + +impl FrameType { + /// Returns a name for the frame (i.e., script path or function name). + pub fn name(&self) -> Cow<'_, str> { + match self { + Self::Script(call) => call.name(), + Self::Function(call) => call.name(), + Self::TrapHandler(_) => "trap".into(), + Self::Eval => "eval".into(), + Self::CommandString => "-c".into(), + Self::InteractiveSession => "interactive".into(), + } + } + + /// Returns `true` if the frame is for a function call. + pub const fn is_function(&self) -> bool { + matches!(self, Self::Function(..)) + } + + /// Returns `true` if the frame is for a script call. + pub const fn is_script(&self) -> bool { + matches!(self, Self::Script(..)) + } + + /// Returns `true` if the frame is for a trap handler. + pub const fn is_trap_handler(&self) -> bool { + matches!(self, Self::TrapHandler(_)) + } + + /// Returns `true` if the frame is for an interactive session. + pub const fn is_interactive_session(&self) -> bool { + matches!(self, Self::InteractiveSession) + } + + /// Returns `true` if the frame is for a command string being executed. + pub const fn is_command_string(&self) -> bool { + matches!(self, Self::CommandString) + } + + /// Returns `true` if the frame is for a sourced script. + pub const fn is_sourced_script(&self) -> bool { + matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Source)) + } + + /// Returns `true` if the frame is for a run script. + pub const fn is_run_script(&self) -> bool { + matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Run)) + } +} + +impl std::fmt::Display for FrameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Script(call) => call.fmt(f), + Self::Function(call) => call.fmt(f), + Self::TrapHandler(_) => write!(f, "trap"), + Self::Eval => write!(f, "eval"), + Self::CommandString => write!(f, "-c"), + Self::InteractiveSession => write!(f, "interactive"), + } + } +} + +/// Describes the target of a function call. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FunctionCall { + /// The name of the function invoked. + pub function_name: String, + /// The invoked function. + pub function: functions::Registration, +} + +impl FunctionCall { + /// Returns the name of the function that was called. + pub fn name(&self) -> Cow<'_, str> { + self.function_name.as_str().into() + } +} + +impl std::fmt::Display for FunctionCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "func({})", self.function_name) + } +} + +/// Represents a single frame in a `CallStack`. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Frame { + /// The type of frame. + pub frame_type: FrameType, + /// The source information for the frame. The locations associated with AST nodes + /// executed in this frame should be interpreted as being relative to this + /// source info. + pub source_info: crate::SourceInfo, + /// The location of the entry point into this frame, within the frame of + /// reference of `source_info`. May be `None` if the entry point is not known. + pub entry: Option>, + /// Information about the currently executing location. For the topmost frame on + /// the stack, this represents the current execution location. For older frames, + /// this represents the site from which a control transfer was made to the next + /// younger frame. May be `None` if the current location is not known. When present, + /// it is relative to the frame of reference of `source_info`. + pub current: Option>, + /// Positional arguments (not including $0). May not be present for all frames. + pub args: Vec, + /// Optionally, indicates an additional line offset within the current source context. + pub current_line_offset: usize, +} + +impl Frame { + /// Returns the adjusted source info for this frame, combining the + /// frame's `source_info` and `current_line_offset`, if present. + pub fn adjusted_source_info(&self) -> crate::SourceInfo { + self.pos_as_source_info(None) + } + + /// Returns the current position as a new `SourceInfo`, combining the + /// frame's `source_info` and `current` position. + pub fn current_pos_as_source_info(&self) -> crate::SourceInfo { + self.pos_as_source_info(self.current.as_ref()) + } + + fn pos_as_source_info(&self, pos: Option<&Arc>) -> crate::SourceInfo { + let mut new_start = if let Some(existing_start) = &self.source_info.start { + if let Some(current) = pos { + Some(Arc::new(crate::SourcePosition { + index: existing_start.index + current.index, + line: existing_start.line + (current.line - 1), + column: if current.line <= 1 { + existing_start.column + (current.column - 1) + } else { + current.column + }, + })) + } else { + Some(existing_start.clone()) + } + } else { + pos.cloned() + }; + + if self.current_line_offset > 0 { + new_start = if let Some(new_start) = new_start { + let mut pos = (*new_start).clone(); + pos.line += self.current_line_offset; + + Some(Arc::new(pos)) + } else { + Some(Arc::new(crate::SourcePosition { + index: 0, + line: self.current_line_offset + 1, + column: 1, + })) + }; + } + + crate::SourceInfo { + source: self.source_info.source.clone(), + start: new_start, + } + } + + /// Returns the current line number. + pub fn current_line(&self) -> Option { + let start_line = self.source_info.start.as_ref().map_or(1, |pos| pos.line); + let current_line = self.current.as_ref().map(|pos| pos.line)?; + + Some(start_line.saturating_sub(1) + current_line + self.current_line_offset) + } + + /// Returns the current line number, relative to the frame's entry. + pub fn current_frame_relative_line(&self) -> Option { + let current_line = self.current.as_ref().map(|pos| pos.line)?; + let entry_line = self.entry.as_ref().map_or(1, |pos| pos.line); + + Some(current_line.saturating_sub(entry_line) + self.current_line_offset + 1) + } +} + +/// Options for formatting a call stack. +#[derive(Default)] +pub struct FormatOptions { + /// Whether or not to show args. + pub show_args: bool, + /// Whether or not to show frame entry points. + pub show_entry_points: bool, +} + +/// Helper struct for formatting a call stack with custom options. +/// +/// This struct implements `Display` and can be used to write a formatted +/// call stack to any type that implements `io::Write`. +pub struct FormatCallStack<'a> { + stack: &'a CallStack, + options: &'a FormatOptions, +} + +impl<'a> FormatCallStack<'a> { + /// Creates a new formatter for the given call stack with the specified options. + /// + /// # Arguments + /// + /// * `stack` - The call stack to format. + /// * `options` - The formatting options to use. + pub const fn new(stack: &'a CallStack, options: &'a FormatOptions) -> Self { + Self { stack, options } + } +} + +impl std::fmt::Display for FormatCallStack<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.stack.fmt_with_options(f, self.options) + } +} + +/// Encapsulates a script call stack. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CallStack { + frames: VecDeque, + func_call_depth: usize, + script_source_depth: usize, + active_trap_signals: HashSet, + trap_delivery_suppress_count: usize, +} + +impl CallStack { + /// Creates a formatter for this call stack with the given options. + /// + /// # Arguments + /// + /// * `options` - The formatting options to use. + pub const fn format<'a>(&'a self, options: &'a FormatOptions) -> FormatCallStack<'a> { + FormatCallStack::new(self, options) + } + + /// Formats the call stack with the given options. + /// + /// # Arguments + /// + /// * `f` - The formatter to write to. + /// * `options` - The formatting options. + fn fmt_with_options( + &self, + f: &mut std::fmt::Formatter<'_>, + options: &FormatOptions, + ) -> std::fmt::Result { + if self.is_empty() { + return Ok(()); + } + + color_print::cwriteln!(f, "Call stack (most recent first):")?; + + for (index, frame) in self.iter().enumerate() { + let si = frame.current_pos_as_source_info(); + + color_print::cwrite!( + f, + " #{index}| {}", + si.source + )?; + + if let Some(pos) = &si.start { + color_print::cwrite!(f, ":{},{}", pos.line, pos.column)?; + } + + color_print::cwrite!(f, " ({}", frame.frame_type)?; + + if options.show_entry_points { + if let Some(entry) = &frame.entry { + let entry_si = frame.pos_as_source_info(Some(entry)); + if let Some(entry_start) = &entry_si.start { + color_print::cwrite!( + f, + " entered at {}:{}", + entry_si.source, + entry_start + )?; + } + } + } + + color_print::cwriteln!(f, ")")?; + + if !frame.args.is_empty() && options.show_args { + for (i, arg) in frame.args.iter().enumerate() { + color_print::cwriteln!( + f, + " ${}: {}", + i + 1, + arg + )?; + } + } + } + + Ok(()) + } +} + +impl std::fmt::Display for CallStack { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_with_options(f, &FormatOptions::default()) + } +} + +impl std::ops::Index for CallStack { + type Output = Frame; + + fn index(&self, index: usize) -> &Self::Output { + &self.frames[index] + } +} + +impl CallStack { + /// Creates a new empty script call stack. + pub fn new() -> Self { + Self::default() + } + + /// Removes the top from from the stack. If the stack is empty, does nothing and + /// returns `None`; otherwise, returns the removed call frame. + pub fn pop(&mut self) -> Option { + let frame = self.frames.pop_front()?; + + if frame.frame_type.is_function() { + self.func_call_depth = self.func_call_depth.saturating_sub(1); + } + + if frame.frame_type.is_sourced_script() { + self.script_source_depth = self.script_source_depth.saturating_sub(1); + } + + if let FrameType::TrapHandler(signal) = &frame.frame_type { + self.active_trap_signals.remove(signal); + } + + Some(frame) + } + + /// Returns a reference to the current (topmost) call frame in the stack. + /// Returns `None` if the stack is empty. + pub fn current_frame(&self) -> Option<&Frame> { + self.frames.front() + } + + /// Returns the position in the current (topmost) call frame in the stack, + /// expressed as a new `SourceInfo`. Note that this may not be identical + /// to that frame's `SourceInfo` since it may include an offset representing + /// the current execution position within that source. + pub fn current_pos_as_source_info(&self) -> crate::SourceInfo { + let Some(frame) = self.frames.front() else { + return crate::SourceInfo::default(); + }; + + frame.current_pos_as_source_info() + } + + /// Updates the currently executing position in the top stack frame. + pub fn set_current_pos(&mut self, position: Option>) { + if let Some(frame) = self.frames.front_mut() { + frame.current = position; + } + } + + /// Increments the current line offset in the top stack frame by the given delta. + /// + /// # Arguments + /// + /// * `delta` - The number of lines to increment the current line offset by. + pub(crate) fn increment_current_line_offset(&mut self, delta: usize) { + let Some(frame) = self.frames.front_mut() else { + return; + }; + + frame.current_line_offset += delta; + } + + /// Pushes a new script call frame onto the stack. + /// + /// # Arguments + /// + /// * `call_type` - The type of script call (sourced or executed). + /// * `source_info` - The source of the script. + /// * `args` - The positional arguments for the script call. + pub fn push_script( + &mut self, + call_type: ScriptCallType, + source_info: &crate::SourceInfo, + args: impl IntoIterator, + ) { + self.frames.push_front(Frame { + frame_type: FrameType::Script(ScriptCall { + call_type, + source_info: source_info.to_owned(), + }), + args: args.into_iter().collect(), + source_info: source_info.to_owned(), + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + + if matches!(call_type, ScriptCallType::Source) { + self.script_source_depth += 1; + } + } + + /// Pushes a new trap handler frame onto the stack. + /// + /// # Arguments + /// + /// * `signal` - The signal being handled. + /// * `handler` - The trap handler being invoked, if any. + pub fn push_trap_handler( + &mut self, + signal: traps::TrapSignal, + handler: Option<&traps::TrapHandler>, + ) { + let source_info = + handler.map_or_else(crate::SourceInfo::default, |h| h.source_info.clone()); + + self.frames.push_front(Frame { + frame_type: FrameType::TrapHandler(signal), + args: vec![], + source_info, + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + + self.active_trap_signals.insert(signal); + } + + /// Pushes a new eval frame onto the stack. + pub fn push_eval(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::Eval, + args: vec![], + source_info: crate::SourceInfo::from("eval"), // TODO(source-info): fill this out + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new command string frame onto the stack. + pub fn push_command_string(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::CommandString, + args: vec![], + source_info: crate::SourceInfo::from("environment"), + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new interactive session frame onto the stack. + pub fn push_interactive_session(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::InteractiveSession, + args: vec![], + current_line_offset: 0, + source_info: crate::SourceInfo::from("main"), + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new function call frame onto the stack. + /// + /// # Arguments + /// + /// * `name` - The name of the function being called. + /// * `function` - The function being called. + /// * `args` - The positional arguments for the function call. + pub fn push_function( + &mut self, + name: impl Into, + function: &functions::Registration, + args: impl IntoIterator, + ) { + self.frames.push_front(Frame { + frame_type: FrameType::Function(FunctionCall { + function_name: name.into(), + function: function.to_owned(), + }), + args: args.into_iter().collect(), + source_info: function.source().clone(), + entry: function.definition().location().map(|span| span.start), + current: None, // TODO(source-info): fill this out + current_line_offset: 0, + }); + + self.func_call_depth += 1; + } + + /// Iterates through the function calls on the stack. + pub fn iter_function_calls(&self) -> impl Iterator { + self.iter().filter_map(|frame| { + if let FrameType::Function(call) = &frame.frame_type { + Some(call) + } else { + None + } + }) + } + + /// Iterates through the script calls on the stack. + pub fn iter_script_calls(&self) -> impl Iterator { + self.iter().filter_map(|frame| { + if let FrameType::Script(call) = &frame.frame_type { + Some(call) + } else { + None + } + }) + } + + /// Returns whether or not the current script stack frame is a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.iter_script_calls() + .next() + .is_some_and(|call| matches!(call.call_type, ScriptCallType::Source)) + } + + /// Returns the current depth of function calls in the call stack. + pub const fn function_call_depth(&self) -> usize { + self.func_call_depth + } + + /// Returns the current depth of sourced script calls in the call stack. + pub const fn script_source_depth(&self) -> usize { + self.script_source_depth + } + + /// Returns whether the given trap signal is currently being handled + /// (i.e., there is an active frame on the stack for this signal). + pub fn is_trap_signal_active(&self, signal: traps::TrapSignal) -> bool { + self.active_trap_signals.contains(&signal) + } + + /// Clears the set of active trap signals. This should be called when + /// creating subshells so they start with fresh trap execution state + /// independent of the parent shell's currently-executing traps. + pub fn clear_active_trap_signals(&mut self) { + self.active_trap_signals.clear(); + } + + /// Returns whether the given trap signal is currently suppressed. + pub const fn is_trap_delivery_suppressed(&self) -> bool { + self.trap_delivery_suppress_count > 0 + } + + /// Acquires a block on trap delivery, preventing traps from being delivered until + /// the block is released. Multiple blocks may be acquired, and trap delivery will + /// remain suppressed until all blocks have been released. + pub const fn acquire_trap_delivery_block(&mut self) { + self.trap_delivery_suppress_count += 1; + } + + /// Releases a block on trap delivery; note that trap delivery will remain + /// suppressed until all blocks have been released. + pub const fn release_trap_delivery_block(&mut self) { + self.trap_delivery_suppress_count = self.trap_delivery_suppress_count.saturating_sub(1); + } + + /// Returns whether or not the shell is actively executing in a shell function. + pub fn in_function(&self) -> bool { + self.iter_function_calls().next().is_some() + } + + /// Returns the current depth of the call stack. + pub fn depth(&self) -> usize { + self.frames.len() + } + + /// Returns whether or not the call stack is empty. + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + /// Returns an iterator over the call frames, starting from the most + /// recent. + pub fn iter(&self) -> impl Iterator { + self.frames.iter() + } + + /// Returns a mutable iterator over the call frames, starting from the most + /// recent. + pub fn iter_mut(&mut self) -> impl Iterator { + self.frames.iter_mut() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::SourceInfo; + use pretty_assertions::assert_matches; + + #[test] + fn test_call_stack_new() { + let stack = CallStack::new(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_default() { + let stack = CallStack::default(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_push_pop() { + let mut stack = CallStack::new(); + + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + assert!(!stack.is_empty()); + assert_eq!(stack.depth(), 1); + + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + assert_eq!(stack.depth(), 2); + + let frame = stack.pop().unwrap(); + assert_matches!( + frame.frame_type, + FrameType::Script(ScriptCall { + call_type: ScriptCallType::Run, + source_info: SourceInfo { + source: file_path, + .. + }, + }) if &file_path == "script2.sh" + ); + assert_eq!(stack.depth(), 1); + + let frame = stack.pop().unwrap(); + assert_matches!( + frame.frame_type, + FrameType::Script(ScriptCall { + call_type: ScriptCallType::Source, + source_info: SourceInfo { + source: file_path, + .. + }, + }) if &file_path == "script1.sh" + ); + assert_eq!(stack.depth(), 0); + assert!(stack.is_empty()); + } + + #[test] + fn test_call_stack_pop_empty() { + let mut stack = CallStack::new(); + assert!(stack.pop().is_none()); + } + + #[test] + fn test_in_sourced_script() { + let mut stack = CallStack::new(); + assert!(!stack.in_sourced_script()); + + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + assert!(!stack.in_sourced_script()); + + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + assert!(stack.in_sourced_script()); + + stack.pop(); + assert!(!stack.in_sourced_script()); + } + + #[test] + fn test_call_stack_iter() { + let mut stack = CallStack::new(); + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script3.sh")), + vec![], + ); + + let frames: Vec<_> = stack.iter().collect(); + assert_eq!(frames.len(), 3); + assert_matches!(&frames[0].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script3.sh"); + assert_matches!(&frames[1].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script2.sh"); + assert_matches!(&frames[2].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script1.sh"); + } +} diff --git a/userland/brush/source/brush-core/src/commands.rs b/userland/brush/source/brush-core/src/commands.rs new file mode 100644 index 0000000..4dfed9d --- /dev/null +++ b/userland/brush/source/brush-core/src/commands.rs @@ -0,0 +1,1008 @@ +//! Command execution + +use std::{ + borrow::Cow, + fmt::{Display, Write as _}, + path::{Path, PathBuf}, +}; + +use brush_parser::ast; + +use crate::{ + ErrorKind, ExecutionControlFlow, ExecutionExitCode, ExecutionParameters, ExecutionResult, + Shell, ShellFd, builtins, commands, env, error, escape, + extensions::{self, ProcessLauncher as _, ShellExtensions, TaskSpawner as _}, + functions, + interp::{self, Execute, ProcessGroupPolicy}, + openfiles::{self, OpenFiles}, + pathsearch, processes, + results::ExecutionSpawnResult, + sys, trace_categories, traps, variables, +}; + +/// Encapsulates the result of waiting for a command to complete. +pub enum CommandWaitResult { + /// The command completed. + CommandCompleted(ExecutionResult), + /// The command was stopped before it completed. + CommandStopped(ExecutionResult, processes::ChildProcess), +} + +/// Represents the context for executing a command. +pub struct ExecutionContext<'a, SE: ShellExtensions = extensions::DefaultShellExtensions> { + /// The shell in which the command is being executed. + pub shell: &'a mut Shell, + /// The name of the command being executed. + pub command_name: String, + /// The parameters for the execution. + pub params: ExecutionParameters, +} + +impl<'a, SE: ShellExtensions> ExecutionContext<'a, SE> { + /// Returns a context for running `command_name` in `shell` with `params`; + /// e.g., to run a builtin directly, outside of a script. + /// + /// # Arguments + /// + /// * `shell` - The shell to run the command in. + /// * `command_name` - The name the command is invoked under. + /// * `params` - The parameters to run it with. + pub const fn new( + shell: &'a mut Shell, + command_name: String, + params: ExecutionParameters, + ) -> Self { + Self { + shell, + command_name, + params, + } + } +} + +impl ExecutionContext<'_, SE> { + /// Returns the builtin's state of type `S`, seeded with `S::default()` the + /// first time it's asked for. The state is kept in the shell, keyed by its + /// type alone, and cloned with the shell: a change made in a subshell + /// stays there, as a change to a variable does. + /// + /// The type is the state's identity, so give each state a type of its own + /// (a newtype, not a bare `usize`): a builtin registered under several + /// names keeps one state, and builtins that share a state type share the + /// state. Builtin state is kept out of a serialized shell (with the `serde` + /// feature), so a shell restored from one starts each state afresh. + pub fn state_mut(&mut self) -> &mut S { + self.shell.builtin_states_mut().get_mut_or_default() + } + + /// Returns the standard input file; usable with `write!` et al. + pub fn stdin(&self) -> impl std::io::Read + 'static { + self.params.stdin(self.shell) + } + + /// Returns the standard output file; usable with `write!` et al. + pub fn stdout(&self) -> impl std::io::Write + 'static { + self.params.stdout(self.shell) + } + + /// Returns the standard error file; usable with `write!` et al. + pub fn stderr(&self) -> impl std::io::Write + 'static { + self.params.stderr(self.shell) + } + + /// Returns the file descriptor with the given number. Returns `None` + /// if the file descriptor is not open. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor number to retrieve. + pub fn try_fd(&self, fd: ShellFd) -> Option { + self.params.try_fd(self.shell, fd) + } + + /// Iterates over all open file descriptors. + pub fn iter_fds(&self) -> impl Iterator { + self.params.iter_fds(self.shell) + } +} + +/// An argument to a command. +#[derive(Clone, Debug)] +pub enum CommandArg { + /// A simple string argument. + String(String), + /// An assignment/declaration; typically treated as a string, but will + /// be specially handled by a limited set of built-in commands. + Assignment(ast::Assignment), +} + +impl Display for CommandArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(s) => f.write_str(s), + Self::Assignment(a) => write!(f, "{a}"), + } + } +} + +impl From for CommandArg { + fn from(s: String) -> Self { + Self::String(s) + } +} + +impl From<&String> for CommandArg { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl From<&str> for CommandArg { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for String { + /// Converts the argument to a string; an assignment renders as it was + /// written. Like [`ToString::to_string`], but takes ownership instead of + /// copying. + fn from(value: CommandArg) -> Self { + match value { + CommandArg::String(s) => s, + CommandArg::Assignment(a) => a.to_string(), + } + } +} + +impl CommandArg { + const fn to_string_len_hint(&self) -> usize { + match self { + Self::String(s) => s.len(), + Self::Assignment(_) => 0, + } + } + + pub(crate) fn quote_for_tracing(&self) -> Cow<'_, str> { + match self { + Self::String(s) => escape::quote_if_needed(s, escape::QuoteMode::SingleQuote), + Self::Assignment(a) => { + let mut s = a.name.to_string(); + let op = if a.append { "+=" } else { "=" }; + s.push_str(op); + s.push_str(&escape::quote_if_needed( + a.value.to_string().as_str(), + escape::QuoteMode::SingleQuote, + )); + s.into() + } + } + } +} + +/// Encapsulates a possibly-owned reference to a `Shell` for command execution. +pub enum ShellForCommand<'a, SE: extensions::ShellExtensions> { + /// The command is run in the same shell as its parent; the provided + /// mutable reference allows modifying the parent shell. + ParentShell(&'a mut Shell), + /// The command is run in its own owned shell (which is also provided). + OwnedShell { + /// The owned shell. + target: Box>, + /// The parent shell. + parent: &'a mut Shell, + }, +} + +impl std::ops::Deref for ShellForCommand<'_, SE> { + type Target = Shell; + + fn deref(&self) -> &Self::Target { + match self { + ShellForCommand::ParentShell(shell) => shell, + ShellForCommand::OwnedShell { target, .. } => target, + } + } +} + +impl std::ops::DerefMut for ShellForCommand<'_, SE> { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + ShellForCommand::ParentShell(shell) => shell, + ShellForCommand::OwnedShell { target, .. } => target, + } + } +} + +/// The environment to start an external program with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProgramEnvironment { + /// No variables at all (as for `exec -c`). + Empty, + /// The shell's exported variables and functions, as they are (as for `exec`). + Exported, + /// The shell's exported variables and functions, with `_` set to the program's + /// path, as for a command the shell runs. + Command, +} + +/// Composes the [`processes::LaunchRequest`] for the process that runs the given +/// external command. +/// +/// The process gets the shell's working directory and the context's open files, +/// and runs in the shell's process group; callers may change that. +/// +/// # Arguments +/// +/// * `context` - The execution context in which the command is being composed. +/// * `program` - The resolved path of the program to execute. +/// * `argv0` - The value to use for `argv[0]` (may be different from the command). +/// * `args` - The arguments to pass to the command. +/// * `env` - The environment to start the program with. +pub fn compose_launch_request, SE: extensions::ShellExtensions>( + context: &ExecutionContext<'_, SE>, + program: &Path, + argv0: &str, + args: &[S], + env: ProgramEnvironment, +) -> processes::LaunchRequest { + processes::LaunchRequest { + program: program.to_owned(), + argv0: argv0.to_owned(), + args: args.iter().map(|arg| arg.as_ref().to_owned()).collect(), + env: match env { + ProgramEnvironment::Empty => vec![], + ProgramEnvironment::Exported => compose_env(context.shell, None), + ProgramEnvironment::Command => { + compose_env(context.shell, Some(&program.to_string_lossy())) + } + }, + working_dir: context.shell.working_dir().to_owned(), + open_files: context.iter_fds().into(), + process_group: processes::ProcessGroup::Inherit, + kill_on_drop: context.shell.options().kill_external_commands_on_drop, + } +} + +/// Composes the environment for an external command: the shell's exported +/// variables and functions. +/// +/// # Arguments +/// +/// * `shell` - The shell whose exported state should be used. +/// * `program` - The program's path, to set `_` to, if it's to be set. +fn compose_env( + shell: &Shell, + program: Option<&str>, +) -> Vec<(String, String)> { + let mut env = vec![]; + + // Add in exported variables, but `_`, if it's to be set below. + for (k, v) in shell.env().iter_exported() { + // NOTE: To match bash behavior, we only include exported variables + // that are set (i.e., have a value). This means a variable that + // shows up in `declare -p` but has no *set* value will be omitted. + if v.value().is_set() && (program.is_none() || k != "_") { + env.push((k.clone(), v.value().to_cow_str(shell).into_owned())); + } + } + + if let Some(program) = program { + env.push(("_".to_owned(), program.to_owned())); + } + + // Add in exported functions. + for (func_name, registration) in shell.funcs().iter() { + if registration.is_exported() { + let var_name = std::format!("BASH_FUNC_{func_name}%%"); + let value = std::format!("() {}", registration.definition().body); + env.push((var_name, value)); + } + } + + env +} + +pub(crate) async fn on_preexecute( + cmd: &mut commands::SimpleCommand<'_, impl extensions::ShellExtensions>, +) -> Result<(), error::Error> { + // Set BASH_COMMAND before invoking the DEBUG trap (and generally before + // executing commands). + let full_cmd = format_command_args(&cmd.args); + cmd.shell.env_mut().update_or_add( + "BASH_COMMAND", + variables::ShellValueLiteral::Scalar(full_cmd), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + // Fire the DEBUG trap if one is registered. + if cmd.shell.traps().handles(traps::TrapSignal::Debug) { + let _ = cmd + .shell + .invoke_trap_handler(traps::TrapSignal::Debug, &cmd.params) + .await?; + } + + Ok(()) +} + +fn format_command_args(args: &[CommandArg]) -> String { + let capacity = args + .iter() + .map(CommandArg::to_string_len_hint) + .sum::() + + args.len().saturating_sub(1); + let mut result = String::with_capacity(capacity); + + for (i, arg) in args.iter().enumerate() { + if i > 0 { + result.push(' '); + } + let _ = write!(result, "{arg}"); + } + + result +} + +/// Represents a simple command to be executed. +pub struct SimpleCommand<'a, SE: extensions::ShellExtensions> { + /// The shell to run the command in. + shell: ShellForCommand<'a, SE>, + + /// The execution parameters for the command. + pub params: ExecutionParameters, + + /// The name of the command to execute. + pub command_name: String, + + /// The arguments to the command, including the command itself. + pub args: Vec, + + /// Whether to consider shell functions when looking up the command name. + /// If true, shell functions will be checked; if false, they will be ignored. + pub use_functions: bool, + + /// Optional list of directories to search for external commands. If left + /// `None`, the default search logic will be used. + pub path_dirs: Option>, + + /// The process group ID to use for externally executed commands. This may be + /// `None`, in which case the default behavior will be used. + pub process_group_id: Option, + + /// Optional override for the `argv[0]` value presented to an externally + /// spawned process. When `None`, `command_name` is used. + pub argv0: Option, +} + +impl<'a, SE: extensions::ShellExtensions> SimpleCommand<'a, SE> { + /// Creates a new `SimpleCommand` instance. + /// + /// # Arguments + /// + /// * `shell` - The shell in which to execute the command. + /// * `params` - The execution parameters for the command. + /// * `command_name` - The name of the command to execute. + /// * `args` - The arguments to the command, including the command itself. + pub fn new( + shell: ShellForCommand<'a, SE>, + params: ExecutionParameters, + command_name: String, + args: I, + ) -> Self + where + I: IntoIterator, + { + Self { + shell, + params, + command_name, + args: args.into_iter().collect(), + use_functions: true, + path_dirs: None, + process_group_id: None, + argv0: None, + } + } + + /// Executes the simple command. + /// + /// The command may be a builtin, a shell function, or an externally + /// executed command. This function's implementation is responsible for + /// dispatching it appropriately according to the context provided. + #[allow( + clippy::missing_panics_doc, + reason = "these unwrap calls should not panic" + )] + pub async fn execute(mut self) -> Result { + // First see if it's the name of a builtin. + let builtin = self.shell.builtins().get(&self.command_name).cloned(); + + // If we're in POSIX mode and found a special builtin (that's not disabled), then invoke it + // without considering functions. + if self.shell.options().posix_mode + && builtin + .as_ref() + .is_some_and(|r| r.is_enabled() && r.is_special()) + { + #[allow(clippy::unwrap_used, reason = "we just checked that builtin is Some")] + let builtin = builtin.unwrap(); + return self.execute_via_builtin(builtin).await; + } + + // Assuming we weren't requested not to do so, check if it's the name of + // a shell function. + if self.use_functions { + if let Some(func_registration) = + self.shell.funcs().get(self.command_name.as_str()).cloned() + { + return self.execute_via_function(func_registration).await; + } + } + + // If we haven't yet resolved the command name and found a builtin that's not disabled, + // then invoke it. + if let Some(builtin) = builtin { + if builtin.is_enabled() { + return self.execute_via_builtin(builtin).await; + } + } + + // We still haven't found a command to invoke. We'll need to look for an external command. + // If we were given path directories to search, look through them for a match. + // Otherwise, use our default search logic. + let path = match &self.path_dirs { + Some(path_dirs) if !sys::fs::contains_path_separator(&self.command_name) => { + pathsearch::resolve_command(path_dirs, self.command_name.as_str()) + } + _ => self.shell.resolve_external_command(&self.command_name), + }; + + if let Some(path) = path { + self.execute_via_external(&path) + } else { + // Bash updates $_ even when the command is not found, so mirror + // that here before reporting the error. + let last_arg = Self::take_last_arg(&self.args); + self.shell.update_last_arg_variable(last_arg); + + Err(ErrorKind::CommandNotFound(self.command_name).into()) + } + } + + /// Extracts the owned string representation of the last argument of a + /// command, suitable for recording into `$_`. + fn take_last_arg(args: &[CommandArg]) -> Option { + args.last().map(ToString::to_string) + } + + async fn execute_via_builtin( + self, + builtin: builtins::Registration, + ) -> Result { + match self.shell { + ShellForCommand::OwnedShell { target, .. } => { + Ok(Self::execute_via_builtin_in_owned_shell( + *target, + self.params, + builtin, + self.command_name, + self.args, + ) + .await) + } + ShellForCommand::ParentShell(..) => { + self.execute_via_builtin_in_parent_shell(builtin).await + } + } + } + + // Not an `async fn`: its future would hold `shell`, and that would grow the + // future of every simple command, not just this rare case. + fn execute_via_builtin_in_owned_shell( + mut shell: Shell, + params: ExecutionParameters, + builtin: builtins::Registration, + command_name: String, + args: Vec, + ) -> impl Future + use { + let last_arg = Self::take_last_arg(&args); + // Builtins may block (e.g., reading input), so keep them from holding up the + // rest of the pipeline. + let spawner = shell.task_spawner().clone(); + let task = spawner.spawn_blocking(async move { + // The stage runs in a shell of its own, as a subshell does, so an + // error ends the stage alone: it's reported on the stage's + // standard error and becomes the stage's status. + let stderr = params.try_stderr(&shell); + let cmd_context = ExecutionContext::new(&mut shell, command_name, params); + + let result = execute_builtin_command(&builtin, cmd_context, args).await; + let result = shell.end_subshell(result, || stderr); + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + Ok(result) + }); + + async move { ExecutionSpawnResult::StartedTask(task.await) } + } + + async fn execute_via_builtin_in_parent_shell( + self, + builtin: builtins::Registration, + ) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext::new(&mut shell, self.command_name, self.params); + + let result = execute_builtin_command(&builtin, cmd_context, self.args).await; + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + let result = result?; + + Ok(result.into()) + } + + async fn execute_via_function( + self, + func_registration: functions::Registration, + ) -> Result { + match self.shell { + ShellForCommand::OwnedShell { target, .. } => { + Ok(Self::execute_via_function_in_owned_shell( + *target, + self.params, + func_registration, + self.command_name, + self.args, + ) + .await) + } + ShellForCommand::ParentShell(..) => { + self.execute_via_function_in_parent_shell(func_registration) + .await + } + } + } + + // As a non-last pipeline stage, a function must run as a background task + // (mirroring `execute_via_builtin_in_owned_shell` above) rather than + // being awaited inline in `spawn_pipeline_processes`'s own per-stage + // loop: awaiting it inline there blocks that loop until the function's + // body fully returns, so the *next* pipeline stage (the one that would + // actually drain this function's stdout pipe) is never even spawned + // until this one is done. A function that writes more to its stdout + // pipe than the OS pipe buffer holds (~64KiB on Linux) before returning + // then blocks on that `write()` forever, since nothing is reading the + // other end yet -- a plain deadlock, not a slow completion. Confirmed + // empirically with a two-line repro having nothing to do with any + // external command or builtin: `f() { for i in $(seq 1 5000); do echo + // line $i; done; }; f | cat` hangs indefinitely. Real bash always runs + // every pipeline stage as its own concurrently-running process, so this + // case never arises there. + fn execute_via_function_in_owned_shell( + mut shell: Shell, + params: ExecutionParameters, + func_registration: functions::Registration, + command_name: String, + args: Vec, + ) -> impl Future + use { + let last_arg = Self::take_last_arg(&args); + let spawner = shell.task_spawner().clone(); + let task = spawner.spawn_blocking(async move { + // The stage runs in a shell of its own, as a subshell does, so an + // error ends the stage alone. + let stderr = params.try_stderr(&shell); + let cmd_context = ExecutionContext::new(&mut shell, command_name, params); + + let spawn_result = + invoke_shell_function(func_registration, cmd_context, &args[1..]).await; + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + let result = match spawn_result { + Ok(ExecutionSpawnResult::Completed(result)) => Ok(result), + Err(error) => Err(error), + Ok( + ExecutionSpawnResult::StartedProcess(_) | ExecutionSpawnResult::StartedTask(_), + ) => { + // invoke_shell_function() only ever returns Completed + // today; this guards against a silent behavior change + // rather than dropping already-started work. + return error::unimp( + "invoke_shell_function returned an unexpected spawn result", + ); + } + }; + Ok(shell.end_subshell(result, || stderr)) + }); + + async move { ExecutionSpawnResult::StartedTask(task.await) } + } + + async fn execute_via_function_in_parent_shell( + self, + func_registration: functions::Registration, + ) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext::new(&mut shell, self.command_name, self.params); + + // Strip the function name off args. + let result = invoke_shell_function(func_registration, cmd_context, &self.args[1..]).await; + + // $_ is reset *after* the function body runs, to the last argument of + // the invocation (or the function name itself if zero args). Any + // mutations made inside the body are overwritten — this matches bash, + // where the caller observes only the invocation's last argument. + shell.update_last_arg_variable(last_arg); + + result + } + + fn execute_via_external(self, path: &Path) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext::new(&mut shell, self.command_name, self.params); + + let result = execute_external_command( + cmd_context, + path, + self.process_group_id, + self.argv0.as_deref(), + &self.args[1..], + ); + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + result + } +} + +pub(crate) fn execute_external_command( + context: ExecutionContext<'_, impl extensions::ShellExtensions>, + executable_path: &Path, + process_group_id: Option, + argv0_override: Option<&str>, + args: &[CommandArg], +) -> Result { + // Filter out the args; we only want strings. + let cmd_args = args + .iter() + .filter_map(|e| { + if let CommandArg::String(s) = e { + Some(s) + } else { + None + } + }) + .collect::>(); + + // Before we lose ownership of the open files, figure out if stdin will be a terminal. + let child_stdin_is_terminal = context + .try_fd(openfiles::OpenFiles::STDIN_FD) + .is_some_and(|f| f.is_terminal()); + + // Figure out if we should be setting up a new process group. + let new_pg = matches!( + context.params.process_group_policy, + ProcessGroupPolicy::NewProcessGroup + ); + + // argv[0] defaults to context.command_name (the user-facing name of the + // command) unless the caller specified an explicit override. + let argv0 = argv0_override.unwrap_or(context.command_name.as_str()); + + let mut request = compose_launch_request( + &context, + executable_path, + argv0, + cmd_args.as_slice(), + ProgramEnvironment::Command, + ); + + // Set up process group state. + request.process_group = if new_pg { + if child_stdin_is_terminal && context.shell.options().external_cmd_leads_session { + processes::ProcessGroup::NewSession + } else { + processes::ProcessGroup::New { + take_foreground: child_stdin_is_terminal, + } + } + } else if let Some(pgid) = process_group_id { + // We need to join an established process group. + processes::ProcessGroup::Join(pgid) + } else { + processes::ProcessGroup::Inherit + }; + + // When tracing is enabled, report. + tracing::debug!( + target: trace_categories::COMMANDS, + "Spawning: cmd='{} {}'", + executable_path.display(), + request.args.join(" ") + ); + + match context.shell.process_launcher().spawn(request) { + Ok(child) => Ok(ExecutionSpawnResult::StartedProcess(child)), + Err(spawn_err) => { + if context.shell.options().interactive { + sys::terminal::move_self_to_foreground()?; + } + + Err(spawn_error(context, spawn_err)) + } + } +} + +/// Translates an error from starting an external command into a shell error. +fn spawn_error( + context: ExecutionContext<'_, impl extensions::ShellExtensions>, + spawn_err: std::io::Error, +) -> error::Error { + if spawn_err.kind() == std::io::ErrorKind::NotFound { + if !context.shell.working_dir().exists() { + error::ErrorKind::WorkingDirMissing(context.shell.working_dir().to_owned()).into() + } else { + error::ErrorKind::CommandNotFound(context.command_name).into() + } + } else { + error::ErrorKind::FailedToExecuteCommand(context.command_name, spawn_err).into() + } +} + +async fn execute_builtin_command( + builtin: &builtins::Registration, + context: ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + // In POSIX mode, special builtins that return errors are to be treated as fatal. + let mark_errors_fatal = builtin.is_special() && context.shell.options().posix_mode; + + match builtin.execute(context, args).await { + Ok(result) => Ok(result), + Err(e) => { + // Broken pipe errors should silently return the appropriate exit code + if let Some(io_err) = e.as_io_error() { + if io_err.kind() == std::io::ErrorKind::BrokenPipe { + return Ok(ExecutionExitCode::from(io_err).into()); + } + } + + Err(if mark_errors_fatal { e.into_fatal() } else { e }) + } + } +} + +pub(crate) async fn invoke_shell_function( + function: functions::Registration, + mut context: ExecutionContext<'_, impl extensions::ShellExtensions>, + args: &[CommandArg], +) -> Result { + let ast::FunctionBody(body, redirects) = &function.definition().body; + + // Apply any redirects specified at function definition-time. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + interp::setup_redirect(context.shell, &mut context.params, redirect).await?; + } + } + + let positional_args = args.iter().map(|a| a.to_string()); + + // Enter the function; it's left however its body ends, even if it's cancelled. + let mut shell = + context + .shell + .enter_function(context.command_name.as_str(), &function, positional_args)?; + + // A function executes within the current shell process and shares its caller's open files, + // so the parameters are passed through by shared reference rather than cloned. This prevents + // direct mutation of the caller's `ExecutionParameters` open-file table, though the function + // may still change the shell's persistent open files via builtins (e.g. `exec`). + let result = body.execute(&mut shell, &context.params).await; + + // Leave the function: pop its frame and its local variables. + drop(shell); + + // Get the actual execution result from the body of the function. + let mut result = result?; + + // Handle control-flow. + match result.next_control_flow { + ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => { + return error::unimp("break or continue returned from function invocation"); + } + ExecutionControlFlow::ReturnFromFunctionOrScript => { + // It's now been handled. + result.next_control_flow = ExecutionControlFlow::Normal; + } + _ => {} + } + + Ok(result.into()) +} + +pub(crate) async fn invoke_command_in_subshell_and_get_output( + shell: &mut Shell, + params: &ExecutionParameters, + s: String, +) -> Result { + // Instantiate a subshell to run the command in. + let mut subshell = shell.clone(); + + // Command substitutions don't inherit errexit by default. Only inherit it when + // command_subst_inherits_errexit is enabled, otherwise disable errexit in the subshell. + if !shell.options().command_subst_inherits_errexit { + subshell.options_mut().exit_on_nonzero_command_exit = false; + } + + // Get our own set of parameters we can customize and use. + let mut params = params.clone(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Set up pipe so we can read the output. + let (reader, writer) = sys::pipes::pipe()?; + params.set_fd(OpenFiles::STDOUT_FD, writer.into()); + + let mut async_reader = sys::async_pipe::AsyncPipeReader::new(reader)?; + + // The command may block writing to the pipe (e.g., a builtin writing more than the + // pipe's buffer holds), so don't run it on a thread that drives the runtime's I/O: + // that thread must stay free to deliver readiness to the reader below. + let cmd_join_handle = shell + .task_spawner() + .spawn_blocking(run_substitution_command(subshell, params, s)) + .await; + + let output_str = async_reader.read_to_string().await?; + + // Now observe the command's completion. + let run_result = cmd_join_handle.await?; + let cmd_result = run_result?; + + // Store the status. + shell.set_last_exit_status(cmd_result.exit_code.into()); + + // Note: $_ is naturally isolated from the parent because we cloned the + // shell to run the substitution. + + Ok(output_str) +} + +async fn run_substitution_command( + mut shell: Shell, + mut params: ExecutionParameters, + command: String, +) -> Result { + // Parse the string into a whole shell program. + let parse_result = shell.parse_string(command); + + // Check for a command that is only an input redirection ("< file"). + // If detected, emulate `cat file` to stdout and return immediately. + // If we failed to parse, then we'll fall below and handle it there. + if let Ok(program) = &parse_result { + if let Some(redir) = try_unwrap_bare_input_redir_program(program) { + interp::setup_redirect(&mut shell, &mut params, redir).await?; + std::io::copy(&mut params.stdin(&shell), &mut params.stdout(&shell))?; + return Ok(ExecutionResult::new(0)); + } + } + + // TODO(source-info): review this + let source_info = crate::SourceInfo::from("main"); + + // Handle the parse result using default shell behavior. + shell + .run_parsed_result(parse_result, &source_info, ¶ms) + .await +} + +// Detects a subshell command that consists solely of a single input redirection +// (e.g., "< file"), returning the IoRedirect when present. +fn try_unwrap_bare_input_redir_program(program: &ast::Program) -> Option<&ast::IoRedirect> { + // We're looking for exactly one complete command... + let [complete] = program.complete_commands.as_slice() else { + return None; + }; + + // ...a single list item... + let ast::CompoundList(items) = complete; + let [item] = items.as_slice() else { + return None; + }; + + // ...with a single pipeline (no && or || chaining)... + let and_or = &item.0; + if !and_or.additional.is_empty() { + return None; + } + + // ...not negated... + let pipeline = &and_or.first; + if pipeline.bang { + return None; + } + + // ...with a single command in the pipeline... + let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else { + return None; + }; + + // ...with no program word/name and no suffix... + if simple_cmd.word_or_name.is_some() || simple_cmd.suffix.is_some() { + return None; + } + + // ...and exactly one prefix containing an I/O redirect... + let prefix = simple_cmd.prefix.as_ref()?; + let [ast::CommandPrefixOrSuffixItem::IoRedirect(redir)] = prefix.0.as_slice() else { + return None; + }; + + // ...that is a file input redirection to a filename, targeting stdin. + match redir { + ast::IoRedirect::File( + fd, + ast::IoFileRedirectKind::Read, + ast::IoFileRedirectTarget::Filename(..), + ) if fd.is_none_or(|fd| fd == openfiles::OpenFiles::STDIN_FD) => Some(redir), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assignment(name: ast::AssignmentName, value: &str, append: bool) -> CommandArg { + CommandArg::Assignment(ast::Assignment { + name, + value: ast::AssignmentValue::Scalar(ast::Word::new(value)), + append, + loc: crate::SourceSpan::default(), + }) + } + + #[test] + fn a_word_converts_to_itself() { + assert_eq!(String::from(CommandArg::from("a b")), "a b"); + assert_eq!(String::from(CommandArg::from(&String::from("x"))), "x"); + assert_eq!(String::from(CommandArg::from(String::new())), ""); + } + + #[test] + fn an_assignment_converts_to_its_text() { + let name = || ast::AssignmentName::VariableName("x".to_owned()); + assert_eq!(String::from(assignment(name(), "1 2", false)), "x=1 2"); + assert_eq!(String::from(assignment(name(), "1", true)), "x+=1"); + assert_eq!(String::from(assignment(name(), "", false)), "x="); + + let element = ast::AssignmentName::ArrayElementName("a".to_owned(), "1".to_owned()); + assert_eq!(String::from(assignment(element, "v", false)), "a[1]=v"); + } + + #[test] + fn conversion_matches_display() { + let args = [ + CommandArg::from("w"), + assignment(ast::AssignmentName::VariableName("x".to_owned()), "1", true), + ]; + for arg in args { + assert_eq!(arg.to_string(), String::from(arg)); + } + } +} diff --git a/userland/brush/source/brush-core/src/completion.rs b/userland/brush/source/brush-core/src/completion.rs new file mode 100644 index 0000000..b504a86 --- /dev/null +++ b/userland/brush/source/brush-core/src/completion.rs @@ -0,0 +1,1641 @@ +//! Implements programmable command completion support. +//! +//! There are two ways in: +//! +//! - [`Shell::complete`] completes a line at a cursor, as interactive completion does. +//! - [`Spec::generate`] generates a spec's candidates for a word on its own, as the +//! `compgen` builtin does. +//! +//! Completing a line follows bash and readline: +//! +//! 1. The word to complete (the text candidates replace, and a completion function's +//! `$2`) and the words of `COMP_WORDS` are found separately, and needn't agree: after +//! `--opt=`, the word to complete is empty, while `COMP_WORDS` ends with `=`. +//! 2. The spec to use is looked up: `complete -E` if there's nothing before the cursor +//! (and it isn't at the start of a word), `-I` for the initial word, else the command's, +//! else `-D`. It's looked up again each time a completion function asks for completion +//! to restart, so specs it registered are used. +//! 3. The spec generates candidates with a completion in progress on the shell, so +//! `compopt` can change its options. With no spec, basic completion completes +//! variables, file names, and commands. + +use itertools::Itertools; +use std::{ + borrow::Cow, + collections::{BTreeSet, HashMap}, + path::Path, +}; +use strum::IntoEnumIterator; + +use crate::{ + Shell, commands, env, error, escape, expansion, extensions, interfaces, jobs, namedoptions, + patterns, + sys::{self, users}, + trace_categories, traps, + variables::{self, ShellValue, ShellVariable}, +}; +use brush_parser::unquote_str; + +mod quoting; +mod words; + +use words::{LineWords, find_completion_word, find_line_words}; + +// `compgen -W` splits unquoted literal IFS characters before expanding each resulting word. +fn split_completion_word_list( + word_list: &str, + ifs: &str, + parser_options: &brush_parser::ParserOptions, +) -> Result, error::Error> { + // Like bash, quoting left open at the end runs to the end of the list. + let mut word_list = word_list.to_owned(); + quoting::close(&mut word_list); + let word_list = word_list.as_str(); + + let pieces = brush_parser::word::parse(word_list, parser_options)?; + let mut words = vec![]; + let mut current_word = String::new(); + + for piece in pieces { + let source = word_list + .get(piece.start_index..piece.end_index) + .ok_or_else(|| { + error::ErrorKind::InternalError(String::from( + "word parser returned an invalid source span", + )) + })?; + + if matches!(piece.piece, brush_parser::word::WordPiece::Text(_)) { + for c in source.chars() { + if ifs.contains(c) { + if !current_word.is_empty() { + words.push(std::mem::take(&mut current_word)); + } + } else { + current_word.push(c); + } + } + } else { + current_word.push_str(source); + } + } + + if !current_word.is_empty() { + words.push(current_word); + } + + Ok(words) +} + +/// Type of action to take to generate completion candidates. Each one's name (e.g. +/// `arrayvar`) is what `complete -A` takes. +#[derive( + Clone, + Copy, + Debug, + Eq, + PartialEq, + strum_macros::EnumIter, + strum_macros::EnumMessage, + strum_macros::EnumString, + strum_macros::IntoStaticStr, + strum_macros::VariantNames, +)] +#[strum(serialize_all = "lowercase")] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum CompleteAction { + /// Complete with valid aliases. + Alias, + /// Complete with names of array shell variables. + ArrayVar, + /// Complete with names of key bindings. + Binding, + /// Complete with names of shell builtins. + Builtin, + /// Complete with names of executable commands. + Command, + /// Complete with directory names. + Directory, + /// Complete with names of disabled shell builtins. + Disabled, + /// Complete with names of enabled shell builtins. + Enabled, + /// Complete with names of exported shell variables. + Export, + /// Complete with filenames. + File, + /// Complete with names of shell functions. + Function, + /// Complete with valid user groups. + Group, + /// Complete with names of valid shell help topics. + HelpTopic, + /// Complete with the system's hostname(s). + HostName, + /// Complete with the command names of shell-managed jobs. + Job, + /// Complete with valid shell keywords. + Keyword, + /// Complete with the command names of running shell-managed jobs. + Running, + /// Complete with names of system services. + Service, + /// Complete with the names of options settable via set -o. + SetOpt, + /// Complete with the names of options settable via shopt. + ShOpt, + /// Complete with the names of trappable signals. + Signal, + /// Complete with the command names of stopped shell-managed jobs. + Stopped, + /// Complete with valid usernames. + User, + /// Complete with names of shell variables. + Variable, +} + +/// Options influencing how command completions are generated. Each one's name (e.g. +/// `nospace`) is what `complete -o` takes; they're declared in the order bash lists them. +#[derive( + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + strum_macros::EnumIter, + strum_macros::EnumMessage, + strum_macros::EnumString, + strum_macros::IntoStaticStr, + strum_macros::VariantNames, +)] +#[strum(serialize_all = "lowercase")] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum CompleteOption { + /// Perform rest of default completions if no completions are generated. + BashDefault, + /// Use default filename completion if no completions are generated. + Default, + /// Treat completions as directory names. + DirNames, + /// Treat completions as filenames. + FileNames, + /// Quote completions even if they aren't file names. Not yet implemented: it's + /// accepted and shown, but has no effect. + FullQuote, + /// Suppress default auto-quotation of completions. + NoQuote, + /// Do not sort completions. + NoSort, + /// Do not append a trailing space to completions at the end of the input line. + NoSpace, + /// Also generate directory completions. + PlusDirs, +} + +/// Encapsulates the shell's programmable command completion configuration. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + /// The specs for completing commands' arguments, by command name. + commands: HashMap, + /// The specs used in place of a command's. + specials: HashMap, + + /// Fallback options to use when 'default' completions are requested (not to be + /// confused with the 'default' completion spec, nor 'bashdefault' completions). + pub fallback_options: FallbackOptions, +} + +/// Options for fallback completions. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FallbackOptions { + /// If true, mark directory completions with a trailing slash. + pub mark_directories: bool, + /// If true, mark symlinked directory completions with a trailing slash. + pub mark_symlinked_directories: bool, +} + +impl Default for FallbackOptions { + fn default() -> Self { + Self { + mark_directories: true, + mark_symlinked_directories: false, + } + } +} + +/// Options for generating completions: which [`CompleteOption`]s are enabled. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GenerationOptions { + enabled: BTreeSet, +} + +impl FromIterator for GenerationOptions { + /// Returns options with just the given ones enabled. + fn from_iter>(options: I) -> Self { + Self { + enabled: options.into_iter().collect(), + } + } +} + +impl GenerationOptions { + /// Returns whether `option` is enabled. + pub fn get(&self, option: CompleteOption) -> bool { + self.enabled.contains(&option) + } + + /// Enables or disables `option`. + pub fn set(&mut self, option: CompleteOption, enabled: bool) { + if enabled { + self.enabled.insert(option); + } else { + self.enabled.remove(&option); + } + } +} + +/// Encapsulates a command completion specification; provides policy for how to +/// generate completions for a given input. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Spec { + // + // Options + /// Options to use for completion. + pub options: GenerationOptions, + + // + // Generators + /// Actions to take to generate completions. + pub actions: Vec, + /// Optionally, a glob pattern whose expansion will be used as completions. + pub glob_pattern: Option, + /// Optionally, a list of words to use as completions. + pub word_list: Option, + /// Optionally, the name of a shell function to invoke to generate completions. + pub function_name: Option, + /// Optionally, the name of a command to execute to generate completions. + pub command: Option, + + // + // Filters + /// Optionally, a pattern to filter completions (`complete -X`), as given: candidates + /// it matches are removed, or, if it starts with a `!` (that doesn't start an extglob + /// pattern), those it doesn't match. + pub filter_pattern: Option, + + // + // Transformers + /// Optionally, provides a prefix to be prepended to all completion candidates. + pub prefix: Option, + /// Optionally, provides a suffix to be appended to all completion candidates. + pub suffix: Option, +} + +/// A shell's programmable completion state. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub(crate) struct State { + /// The completion specs and settings. + pub(crate) config: Config, + /// The programmable completion in progress, if any. + pub(crate) in_progress: Option, +} + +/// A programmable completion whose spec is generating candidates. +/// +/// That includes while its completion function runs. Like bash, the `compopt` builtin +/// shows and changes its options. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InProgressCompletion { + /// Bash's name for the spec (see [`SpecName::as_str`]). + spec_name: String, + /// The options in effect, which start as the spec's. + pub(crate) options: GenerationOptions, +} + +impl InProgressCompletion { + /// Returns the name of the spec being completed with: the one it's registered under, + /// which may not be the name of the command being completed (e.g. for a command + /// completed by its file name, or with the default spec). + pub fn spec_name(&self) -> SpecName<'_> { + SpecName::parse(&self.spec_name) + } + + /// Returns the options in effect. + pub const fn options(&self) -> &GenerationOptions { + &self.options + } +} + +/// Keeps a completion in progress on a shell until dropped, then restores the one (if any) +/// it replaced -- even if the completion is cancelled partway through, e.g. by Ctrl-C. +struct InProgressScope<'a, SE: extensions::ShellExtensions> { + shell: &'a mut Shell, + prev: Option, +} + +impl<'a, SE: extensions::ShellExtensions> InProgressScope<'a, SE> { + const fn start(shell: &'a mut Shell, in_progress: InProgressCompletion) -> Self { + let prev = shell.in_progress_completion_mut().replace(in_progress); + Self { shell, prev } + } + + /// Ends the completion in progress early, returning its options as `compopt` left + /// them, if it's still in progress. + fn take_options(&mut self) -> Option { + self.shell + .in_progress_completion_mut() + .take() + .map(|in_progress| in_progress.options) + } +} + +impl Drop for InProgressScope<'_, SE> { + fn drop(&mut self) { + *self.shell.in_progress_completion_mut() = self.prev.take(); + } +} + +/// Enters a scope on `shell` holding the `COMP_*` variables `vars` for a completion function +/// or command (exported, if `export`), until the returned guard is dropped -- even if the +/// completion is cancelled. +fn enter_comp_vars_scope( + shell: &mut Shell, + vars: impl IntoIterator, + export: bool, +) -> Result> + '_, error::Error> { + let mut scoped = shell.enter_scope(env::EnvironmentScope::Command); + for (name, value) in vars { + let mut var = ShellVariable::new(value); + if export { + var.export(); + } + scoped + .env_mut() + .add(name, var, env::EnvironmentScope::Command)?; + } + Ok(scoped) +} + +/// What a spec generates candidates for. +#[derive(Clone, Copy, Debug)] +struct Context<'a> { + /// The word to generate candidates for. When completing a line, this is the word being + /// completed; for [`Spec::generate`], it's the word given, dequoted. + word: &'a str, + /// The line a completion function or command is told about: the one being completed, + /// or for [`Spec::generate`], like bash, an empty one being completed for `compgen`. + line: LineContext<'a>, +} + +/// A line being completed, which a completion function or command is told about in its +/// arguments and the `COMP_*` variables. +#[derive(Clone, Copy, Debug)] +struct LineContext<'a> { + /// The line (`COMP_LINE`). + input: &'a str, + /// The cursor's byte offset in the line (`COMP_POINT`). + cursor: usize, + /// The words of `COMP_WORDS`, and which the cursor is in (`COMP_CWORD`). + words: &'a LineWords<'a>, + /// The name of the command being completed for (`$1`): the first word or, for the + /// empty-line and initial-word specs, bash's names for them. + command_name: Option<&'a str>, + /// The kind of completion (`COMP_TYPE`), as bash's char code for it (e.g. Tab for + /// normal completion), or 0 for `compgen`. + comp_type: u8, + /// The key that triggered completion (`COMP_KEY`), or 0 for `compgen`. + comp_key: u8, +} + +impl<'a> LineContext<'a> { + /// Returns the line `input`, being completed at `cursor`, whose words are `words`. + fn new(input: &'a str, cursor: usize, words: &'a LineWords<'a>) -> Self { + Self { + input, + cursor, + words, + command_name: words.command_name(), + comp_type: b'\t', + comp_key: b'\t', + } + } + + /// Returns the arguments a completion function or command gets for completing `word`: + /// like bash, the command name, the word, and the word before it, each empty if + /// there's none. + fn args(&self, word: &'a str) -> [&'a str; 3] { + [ + self.command_name.unwrap_or(""), + word, + self.words.preceding_word().unwrap_or(""), + ] + } + + /// Returns the `COMP_*` variables that describe the line to a completion function or + /// command, except for `COMP_WORDS` and `COMP_CWORD` (see [`Self::comp_words_vars`]). + fn comp_vars(&self) -> Vec<(&'static str, ShellValue)> { + vec![ + ("COMP_LINE", self.input.into()), + ("COMP_POINT", self.cursor.to_string().into()), + ("COMP_KEY", self.comp_key.to_string().into()), + ("COMP_TYPE", self.comp_type.to_string().into()), + ] + } + + /// Returns the `COMP_WORDS` and `COMP_CWORD` variables, which only completion + /// functions get. + fn comp_words_vars(&self) -> [(&'static str, ShellValue); 2] { + let cword = self + .words + .cword + .map_or_else(|| "-1".to_owned(), |i| i.to_string()); + [ + ("COMP_WORDS", self.words.comp_words.clone().into()), + ("COMP_CWORD", cword.into()), + ] + } +} + +/// Candidates a spec generated (`T`), or else a completion function's request, by +/// returning 124, that completion restart. +enum Generated { + /// The candidates generated. + Candidates(T), + /// A request that completion restart. + Restart, +} + +/// The candidates a spec's generators produced. +struct SpecCandidates { + /// The candidates. + candidates: Vec, + /// Whether generating them makes them file names: like bash, if the spec's `file` action + /// ran, or its `directory` action found any (see [`CompleteOption::FileNames`]). + file_names: bool, +} + +impl Spec { + /// Generates this spec's completion candidates for `word`, a word on its own (not + /// part of a line being completed), as the `compgen` builtin does: with any fallbacks + /// its options ask for added, and like bash, in the order generated (sorting is for + /// showing them to a user). A completion function or command run for `word` is told, + /// like bash's, about an empty line being completed for `compgen`. + /// + /// This doesn't start a completion in progress, so the `compopt` builtin can't change + /// the options applied to the candidates. Like bash, a completion function that asks + /// for completion to restart generates no candidates. + /// + /// # Arguments + /// + /// * `shell` - The shell instance to use for completion generation. + /// * `word` - The word to generate candidates for, as typed (with any quoting). + pub async fn generate( + &self, + shell: &mut Shell, + word: &str, + ) -> Result, crate::error::Error> { + let word = unquote_str(word); + let context = Context { + word: &word, + line: LineContext { + input: "", + cursor: 0, + words: &words::NO_WORDS, + command_name: Some("compgen"), + comp_type: 0, + comp_key: 0, + }, + }; + + let Generated::Candidates(SpecCandidates { candidates, .. }) = + self.generate_candidates(shell, &context).await? + else { + return Ok(Vec::new()); + }; + + let (candidates, _options) = self + .apply_options(shell, &context, candidates, self.options.clone()) + .await; + Ok(candidates) + } + + /// Completes the word being completed in `context`'s line, with a completion in + /// progress for it (see [`InProgressCompletion`]) while its candidates are generated, so + /// the `compopt` builtin can change the options applied to them. `spec_name` is bash's + /// name for this spec. Returns the candidates, sorted unless `nosort`, and how to + /// process them. + async fn complete( + &self, + shell: &mut Shell, + context: &Context<'_>, + spec_name: String, + ) -> Result, ProcessingOptions)>, crate::error::Error> { + let mut scope = InProgressScope::start( + shell, + InProgressCompletion { + spec_name, + options: self.options.clone(), + }, + ); + + let Generated::Candidates(SpecCandidates { + candidates, + file_names, + }) = self.generate_candidates(scope.shell, context).await? + else { + return Ok(Generated::Restart); + }; + + // Apply the options as `compopt` left them, and as generating the candidates did. + let mut options = scope.take_options().unwrap_or_else(|| self.options.clone()); + if file_names { + options.set(CompleteOption::FileNames, true); + } + let sort = !options.get(CompleteOption::NoSort); + let (mut candidates, processing_options) = self + .apply_options(scope.shell, context, candidates, options) + .await; + if sort { + candidates.sort(); + } + Ok(Generated::Candidates((candidates, processing_options))) + } + + /// Generates this spec's candidates, before its options are applied. + async fn generate_candidates( + &self, + shell: &mut Shell, + context: &Context<'_>, + ) -> Result, crate::error::Error> { + // Generate completions based on any provided actions (and on words). + let SpecCandidates { + mut candidates, + file_names, + } = self.generate_action_completions(shell, context).await?; + if let Some(word_list) = &self.word_list { + let params = shell.default_exec_params(); + let unexpanded_words = + split_completion_word_list(word_list, &shell.ifs(), &shell.parser_options())?; + let options = crate::expansion::ExpanderOptions { + pathname_expand: false, + ..Default::default() + }; + let mut words = vec![]; + for word in unexpanded_words { + words.extend( + crate::expansion::full_expand_and_split_word_with_options( + shell, ¶ms, word, &options, + ) + .await?, + ); + } + candidates.extend( + words + .into_iter() + .filter(|word| word.starts_with(context.word)), + ); + } + + if let Some(glob_pattern) = &self.glob_pattern { + let pattern = shell_pattern(shell, glob_pattern.as_str()); + + let expansions = pattern + .expand( + shell.working_dir(), + Some(&patterns::Pattern::accept_all_expand_filter), + &patterns::FilenameExpansionOptions::default(), + )? + .into_paths(); + + candidates.extend(expansions); + } + if let Some(function_name) = &self.function_name { + match call_completion_function(shell, function_name, context).await? { + Generated::Candidates(new_candidates) => candidates.extend(new_candidates), + Generated::Restart => return Ok(Generated::Restart), + } + } + if let Some(command) = &self.command { + candidates.extend(call_completion_command(shell, command, context).await?); + } + + // Apply filter pattern, if present. Anything the filter selects gets removed. + if let Some(filter_pattern) = &self.filter_pattern + && !filter_pattern.is_empty() + { + let mut updated = Vec::new(); + + // Like bash, a leading `!` (unless extglob is on and it starts a `!(...)` + // pattern) inverts the filter, which then keeps what it matches. + let (pattern, keep_matches) = match filter_pattern.strip_prefix('!') { + Some(rest) if !(shell.options().extended_globbing && rest.starts_with('(')) => { + (rest, true) + } + _ => (filter_pattern.as_str(), false), + }; + + for candidate in candidates { + let matches = completion_filter_pattern_matches( + pattern, + candidate.as_str(), + context.word, + shell, + )?; + + if matches == keep_matches { + updated.push(candidate); + } + } + + candidates = updated; + } + + // Add prefix and/or suffix, if present. + if self.prefix.is_some() || self.suffix.is_some() { + let empty = String::new(); + let prefix = self.prefix.as_ref().unwrap_or(&empty); + let suffix = self.suffix.as_ref().unwrap_or(&empty); + + let mut updated = Vec::with_capacity(candidates.len() * (prefix.len() + suffix.len())); + for candidate in candidates { + updated.push(std::format!("{prefix}{candidate}{suffix}")); + } + + candidates = updated; + } + + Ok(Generated::Candidates(SpecCandidates { + candidates, + file_names, + })) + } + + /// Applies `options` to the candidates this spec generated, adding any fallbacks they + /// ask for; returns them and how to process them. + async fn apply_options( + &self, + shell: &Shell, + context: &Context<'_>, + mut candidates: Vec, + options: GenerationOptions, + ) -> (Vec, ProcessingOptions) { + let mut processing_options = ProcessingOptions { + treat_as_filenames: options.get(CompleteOption::FileNames), + no_autoquote_filenames: options.get(CompleteOption::NoQuote), + no_trailing_space_at_end_of_line: options.get(CompleteOption::NoSpace), + }; + + // plusdirs always adds directory names; dirnames only does so when nothing else matched. + if options.get(CompleteOption::PlusDirs) + || (options.get(CompleteOption::DirNames) && candidates.is_empty()) + { + let mut dir_candidates = + get_file_completions(shell, context.word, /* must_be_dir */ true).await; + + // If directories are all we have, let them be marked as such. + if candidates.is_empty() && shell.completion_config().fallback_options.mark_directories + { + processing_options.treat_as_filenames = true; + } + + candidates.append(&mut dir_candidates); + } + + // If we still have no candidates, and bashdefault completions were requested, then generate + // those. + if candidates.is_empty() && options.get(CompleteOption::BashDefault) { + // TODO(completions): it's not clear what default "bash" completions means. From basic + // testing, this doesn't seem to include basic file and directory name + // completion. + tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -o bashdefault"); + } + + if options.get(CompleteOption::FullQuote) { + tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -o fullquote"); + } + + // If we still have no candidates, and default completions were requested, then generate + // those. + if candidates.is_empty() && options.get(CompleteOption::Default) { + // N.B. We approximate "default" readline completion behavior by getting file and + // dir completions. + let must_be_dir = options.get(CompleteOption::DirNames); + + let mut default_candidates = + get_file_completions(shell, context.word, must_be_dir).await; + candidates.append(&mut default_candidates); + + if shell.completion_config().fallback_options.mark_directories { + processing_options.treat_as_filenames = true; + } + } + + (candidates, processing_options) + } + + /// Generates the candidates of this spec's actions. + #[expect(clippy::too_many_lines, reason = "a flat match over the actions")] + async fn generate_action_completions( + &self, + shell: &Shell, + context: &Context<'_>, + ) -> Result { + let prefix = context.word; + let mut candidates = Vec::new(); + let mut file_names = false; + + for action in &self.actions { + let c = &mut candidates; + match action { + // Aliases and functions are stored unordered; bash enumerates them sorted + // by name. + CompleteAction::Alias => { + extend_matching(c, shell.aliases().keys().sorted(), prefix); + } + CompleteAction::ArrayVar => extend_matching( + c, + shell + .env() + .iter() + .filter(|(_, var)| var.value().is_array()) + .map(|(name, _)| name), + prefix, + ), + CompleteAction::Binding => extend_matching( + c, + interfaces::InputFunction::iter().map(<&'static str>::from), + prefix, + ), + // For now, we only have help topics for built-in commands. + CompleteAction::Builtin | CompleteAction::HelpTopic => { + extend_matching(c, shell.builtins().iter().map(|(name, _)| name), prefix); + } + CompleteAction::Command => c.extend(command_completions(shell, prefix)), + CompleteAction::Directory => { + let dirs = get_file_completions(shell, context.word, true).await; + file_names |= !dirs.is_empty(); + c.extend(dirs); + } + CompleteAction::Disabled => extend_matching( + c, + shell + .builtins() + .iter() + .filter(|(_, registration)| !registration.is_enabled()) + .map(|(name, _)| name), + prefix, + ), + CompleteAction::Enabled => extend_matching( + c, + shell + .builtins() + .iter() + .filter(|(_, registration)| registration.is_enabled()) + .map(|(name, _)| name), + prefix, + ), + CompleteAction::Export => extend_matching( + c, + shell + .env() + .iter() + .filter(|(_, var)| var.is_exported()) + .map(|(name, _)| name), + prefix, + ), + CompleteAction::File => { + file_names = true; + c.extend(get_file_completions(shell, context.word, false).await); + } + CompleteAction::Function => extend_matching( + c, + shell.funcs().iter().map(|(name, _)| name).sorted(), + prefix, + ), + CompleteAction::Group => extend_matching(c, users::get_all_groups()?, prefix), + // N.B. We only retrieve one hostname. + CompleteAction::HostName => extend_matching( + c, + sys::network::get_hostname() + .ok() + .map(|name| name.to_string_lossy().into_owned()), + prefix, + ), + CompleteAction::Job => extend_matching( + c, + shell.jobs().jobs.iter().map(|job| job.command_name()), + prefix, + ), + CompleteAction::Keyword => extend_matching(c, shell.get_keywords(), prefix), + CompleteAction::Running => extend_matching( + c, + shell + .jobs() + .jobs + .iter() + .filter(|job| matches!(job.state, jobs::JobState::Running)) + .map(|job| job.command_name()), + prefix, + ), + CompleteAction::Service => { + tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -A service"); + } + CompleteAction::SetOpt => extend_matching( + c, + namedoptions::options(namedoptions::ShellOptionKind::SetO) + .iter() + .map(|option| option.name), + prefix, + ), + CompleteAction::ShOpt => extend_matching( + c, + namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .iter() + .map(|option| option.name), + prefix, + ), + CompleteAction::Signal => extend_matching( + c, + traps::TrapSignal::iterator().map(traps::TrapSignal::as_str), + prefix, + ), + CompleteAction::Stopped => extend_matching( + c, + shell + .jobs() + .jobs + .iter() + .filter(|job| matches!(job.state, jobs::JobState::Stopped)) + .map(|job| job.command_name()), + prefix, + ), + CompleteAction::User => extend_matching(c, users::get_all_users()?, prefix), + CompleteAction::Variable => { + extend_matching(c, shell.env().iter().map(|(name, _)| name), prefix); + } + } + } + + Ok(SpecCandidates { + candidates, + file_names, + }) + } +} + +/// Runs the completion command `command` (`complete -C`) to generate candidates for the +/// word `context` is generating them for: one per line of its output. +async fn call_completion_command( + shell: &mut Shell, + command: &str, + context: &Context<'_>, +) -> Result, error::Error> { + let line = context.line; + + // Compose the full command line. + let mut command_line = command.to_owned(); + for arg in line.args(context.word) { + command_line.push(' '); + command_line.push_str(&escape::quote_if_needed( + arg, + escape::QuoteMode::SingleQuote, + )); + } + + // Run the command in a subshell, with the variables exported to it and traps blocked. + // Like a command substitution, it would set `$?`; the user didn't run it, so leave that + // as it was. + let status = shell.save_command_status(); + let mut scoped = enter_comp_vars_scope(shell, line.comp_vars(), true)?; + let mut blocked = scoped.block_trap_delivery(); + let params = blocked.default_exec_params(); + let output = + commands::invoke_command_in_subshell_and_get_output(&mut blocked, ¶ms, command_line) + .await; + drop(blocked); + drop(scoped); + shell.restore_command_status(status); + + Ok(output?.lines().map(str::to_owned).collect()) +} + +/// Runs the completion function `function_name` (`complete -F`) to generate candidates for +/// the word `context` is generating them for: those it leaves in `COMPREPLY`, unless it +/// asks for completion to restart. +async fn call_completion_function( + shell: &mut Shell, + function_name: &str, + context: &Context<'_>, +) -> Result>, error::Error> { + let line = context.line; + let mut vars = line.comp_vars(); + vars.extend(line.comp_words_vars()); + + tracing::debug!(target: trace_categories::COMPLETION, "[calling completion func '{function_name}']: {}", + vars.iter().map(|(k, v)| std::format!("{k}={v:?}")).collect::>().join(" ")); + + // Set the variables in a scope of their own, left when the function returns -- or when + // the completion is cancelled -- and block traps while it runs. + let names: Vec<_> = vars.iter().map(|(name, _)| *name).collect(); + let mut scoped = enter_comp_vars_scope(shell, vars, false)?; + let mut blocked = scoped.block_trap_delivery(); + let params = blocked.default_exec_params(); + let invoke_result = blocked + .invoke_function(function_name, line.args(context.word).iter(), params) + .await + .map(|result| u8::from(result.exit_code)); + + // The function's done: unblock traps, and unset the variables. + drop(blocked); + drop(scoped); + + // Like bash, also unset any the function set again after unsetting them, or that were + // set before it ran: either way, they're outside the scope just left. + for name in names { + let _ = shell.env_mut().unset(name); + } + + tracing::debug!(target: trace_categories::COMPLETION, "[completion function '{function_name}' returned: {invoke_result:?}]"); + + let result = invoke_result.unwrap_or_else(|e| { + tracing::warn!(target: trace_categories::COMPLETION, "error while running completion function '{function_name}': {e}"); + 1 // Report back a non-zero exit code. + }); + + // Like bash, take `COMPREPLY` out of the shell even if it's discarded below. + let reply = shell.env_mut().unset("COMPREPLY")?; + + // When the function returns the special value 124, then it's a request + // for us to restart the completion process. + if result == 124 { + return Ok(Generated::Restart); + } + + tracing::debug!(target: trace_categories::COMPLETION, "[completion function yielded: {reply:?}]"); + + let candidates = match reply.as_ref().map(|reply| reply.value()) { + Some(variables::ShellValue::IndexedArray(values)) => { + values.values().map(|v| v.to_owned()).collect() + } + Some(variables::ShellValue::String(s)) => vec![s.to_owned()], + _ => Vec::new(), + }; + Ok(Generated::Candidates(candidates)) +} + +/// Represents a set of generated command completions. +#[derive(Debug, Default)] +pub struct Completions { + /// The index in the input line where the completions should be inserted. Represented + /// as a byte offset into the input line; must be at a clean character boundary. + pub insertion_index: usize, + /// The number of elements in the input line that should be removed before insertion. + /// Represented as a byte count; must capture an exact character boundary. + pub delete_count: usize, + /// The ordered set of completions. + pub candidates: Vec, + /// Options for processing the candidates. + pub options: ProcessingOptions, +} + +/// Options governing how command completion candidates are processed after being generated. +#[derive(Debug)] +pub struct ProcessingOptions { + /// Treat completions as file names. + pub treat_as_filenames: bool, + /// Don't auto-quote completions that are file names. + pub no_autoquote_filenames: bool, + /// Don't append a trailing space to completions at the end of the input line. + pub no_trailing_space_at_end_of_line: bool, +} + +impl Default for ProcessingOptions { + fn default() -> Self { + Self { + treat_as_filenames: true, + no_autoquote_filenames: false, + no_trailing_space_at_end_of_line: false, + } + } +} + +/// A completion spec used in place of a command's. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, strum_macros::EnumIter)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum SpecialSpec { + /// The spec used when no command's spec applies (`complete -D`). + Default, + /// The spec used when the command line is empty (`complete -E`). + EmptyLine, + /// The spec used for the initial word of a command line (`complete -I`). + InitialWord, +} + +impl SpecialSpec { + /// Returns the name that stands in for a command's with this spec, as bash's does: + /// the `complete` and `compopt` builtins accept it in place of a command name, and a + /// completion function for the empty-line or initial-word spec gets it as `$1`. + pub const fn command_name(self) -> &'static str { + match self { + Self::Default => "_DefaultCmD_", + Self::EmptyLine => "_EmptycmD_", + Self::InitialWord => "_InitialWorD_", + } + } + + /// Returns the special spec that `name`, bash's name for it, names, if any. + pub fn from_command_name(name: &str) -> Option { + Self::iter().find(|special| special.command_name() == name) + } +} + +/// Names a completion spec in a [`Config`]. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum SpecName<'a> { + /// The spec for completing the named command's arguments. + Command(&'a str), + /// One of the special specs. + Special(SpecialSpec), +} + +impl<'a> SpecName<'a> { + /// Returns the spec named `name`: a command's, or the special spec that bash's name for + /// it (see [`SpecialSpec::command_name`]) names. + pub fn parse(name: &'a str) -> Self { + SpecialSpec::from_command_name(name).map_or(Self::Command(name), Self::Special) + } + + /// Returns the name: a command's, or for a special spec, bash's name for it. + pub const fn as_str(self) -> &'a str { + match self { + Self::Command(command) => command, + Self::Special(special) => special.command_name(), + } + } +} + +impl Config { + /// Removes all registered completion specs. + pub fn clear(&mut self) { + self.commands.clear(); + self.specials.clear(); + } + + /// Ensures the named completion spec is no longer registered; returns whether a + /// removal operation was required. + /// + /// # Arguments + /// + /// * `name` - The name of the completion spec to remove. + pub fn remove(&mut self, name: SpecName<'_>) -> bool { + match name { + SpecName::Command(command) => self.commands.remove(command).is_some(), + SpecName::Special(special) => self.specials.remove(&special).is_some(), + } + } + + /// Returns an iterator over the completion specs and their names, in no particular + /// order. + pub fn iter(&self) -> impl Iterator, &Spec)> { + let commands = self + .commands + .iter() + .map(|(command, spec)| (SpecName::Command(command), spec)); + let specials = self + .specials + .iter() + .map(|(special, spec)| (SpecName::Special(*special), spec)); + commands.chain(specials) + } + + /// If present, returns the named completion spec. + /// + /// # Arguments + /// + /// * `name` - The name of the completion spec. + pub fn get(&self, name: SpecName<'_>) -> Option<&Spec> { + match name { + SpecName::Command(command) => self.commands.get(command), + SpecName::Special(special) => self.specials.get(&special), + } + } + + /// If present, returns a mutable reference to the named completion spec. + /// + /// # Arguments + /// + /// * `name` - The name of the completion spec. + pub fn get_mut(&mut self, name: SpecName<'_>) -> Option<&mut Spec> { + match name { + SpecName::Command(command) => self.commands.get_mut(command), + SpecName::Special(special) => self.specials.get_mut(&special), + } + } + + /// Registers the provided completion spec under the given name, replacing any + /// already registered there. + /// + /// # Arguments + /// + /// * `name` - The name of the completion spec. + /// * `spec` - The completion spec. + pub fn set(&mut self, name: SpecName<'_>, spec: Spec) { + match name { + SpecName::Command(command) => { + self.commands.insert(command.to_owned(), spec); + } + SpecName::Special(special) => { + self.specials.insert(special, spec); + } + } + } + + /// Returns the completion spec to use for completing `line`, the name it's registered + /// under, and the name of the command it completes for (a completion function's `$1`): + /// the empty-line spec (`complete -E`) if there's nothing before the cursor and it isn't + /// at the start of a word, else the spec for the initial word (`complete -I`) if + /// completing that, else its command's spec (by name, or by file name if the command is + /// a path), else the default spec (`complete -D`). Like bash, the empty-line and + /// initial-word specs complete for commands named after them. + fn find_spec<'l>( + &self, + line: &LineContext<'l>, + ) -> Option<(&Spec, SpecName<'_>, Option<&'l str>)> { + let special = |special: SpecialSpec| { + let spec = self.specials.get(&special)?; + Some(( + spec, + SpecName::Special(special), + Some(special.command_name()), + )) + }; + + // Like bash, it's the empty line only if the cursor is at its very start, and not at + // the start of a word there: with whitespace before the cursor, or a word just after + // it, the initial word is completed. + if line.cursor == 0 && line.input.chars().next().is_none_or(char::is_whitespace) { + return special(SpecialSpec::EmptyLine); + } + + if matches!(line.words.cword, None | Some(0)) { + return special(SpecialSpec::InitialWord); + } + + let command_name = line.command_name?; + let (spec, name) = self + .commands + .get_key_value(command_name) + .or_else(|| { + let file_name = Path::new(command_name).file_name()?; + self.commands + .get_key_value(file_name.to_string_lossy().as_ref()) + }) + .map(|(name, spec)| (spec, SpecName::Command(name))) + .or_else(|| { + let spec = self.specials.get(&SpecialSpec::Default)?; + Some((spec, SpecName::Special(SpecialSpec::Default))) + })?; + Some((spec, name, Some(command_name))) + } +} + +/// Completes `input` at `cursor`: see [`Shell::complete`]. +pub(crate) async fn complete( + shell: &mut Shell, + input: &str, + cursor: usize, +) -> Result { + /// How many times a completion function may ask for completion to restart. + const MAX_RESTARTS: u32 = 10; + + if !input.is_char_boundary(cursor) { + return Err(error::ErrorKind::InvalidCompletionPosition(cursor).into()); + } + + let word_breaks = word_break_chars(shell); + let word = find_completion_word(input, &word_breaks, cursor); + let line_words = find_line_words(input, &word_breaks, cursor); + let context = Context { + word: &word.text, + line: LineContext::new(input, cursor, &line_words), + }; + + // Complete, restarting as often as completion functions ask to. If they never stop + // asking, there's nothing to complete with. + let mut completed = None; + for _ in 0..=MAX_RESTARTS { + if let Generated::Candidates(candidates_and_options) = complete_word(shell, context).await { + completed = Some(candidates_and_options); + break; + } + } + let (candidates, options) = completed.unwrap_or_else(|| { + tracing::warn!(target: trace_categories::COMPLETION, "completion kept restarting; giving up"); + (Vec::new(), ProcessingOptions::default()) + }); + + Ok(Completions { + insertion_index: word.range.start, + delete_count: word.range.len(), + candidates, + options, + }) +} + +fn word_break_chars(shell: &Shell) -> Vec { + const FALLBACK: &str = " \t\n\"\'@><=;|&(:"; + + shell + .env_str("COMP_WORDBREAKS") + .unwrap_or_else(|| FALLBACK.into()) + .chars() + .collect() +} + +/// Completes the word being completed in `context`'s line, with the completion spec that +/// applies to it if there is one, or else with basic completion. Returns the candidates and +/// how to process them. +async fn complete_word( + shell: &mut Shell, + mut context: Context<'_>, +) -> Generated<(Vec, ProcessingOptions)> { + // Look the spec up afresh each time: a completion function that asks for completion to + // restart may have registered a new one. + let Some((spec, spec_name, command_name)) = shell.completion_config().find_spec(&context.line) + else { + return Generated::Candidates(get_completions_using_basic_lookup(shell, &context).await); + }; + let spec = spec.clone(); + let spec_name = spec_name.as_str().to_owned(); + context.line.command_name = command_name; + + spec.complete(shell, &context, spec_name) + .await + .unwrap_or_else(|err| { + tracing::debug!(target: trace_categories::COMPLETION, "completion spec failed: {err}"); + Generated::Candidates((Vec::new(), ProcessingOptions::default())) + }) +} + +async fn get_file_completions( + shell: &Shell, + token_to_complete: &str, + must_be_dir: bool, +) -> Vec { + // Basic-expand the token-to-be-completed; it won't have been expanded to this point. + let mut throwaway_shell = shell.clone(); + let params = throwaway_shell.default_exec_params(); + let options = expansion::ExpanderOptions { + execute_command_substitutions: false, + ..Default::default() + }; + let expanded_token = expansion::basic_expand_word_with_options( + &mut throwaway_shell, + ¶ms, + &unquote_str(token_to_complete), + &options, + ) + .await + .unwrap_or_else(|_err| token_to_complete.to_owned()); + + // Normalize path separators before building the glob pattern, because backslash + // is the escape character in glob syntax and must not be confused with a Windows + // path separator. + let expanded_token = sys::fs::normalize_path_separators(&expanded_token).into_owned(); + + let glob = std::format!("{expanded_token}*"); + + let path_filter = |path: &Path| !must_be_dir || shell.absolute_path(path).is_dir(); + + let pattern = shell_pattern(shell, glob); + + let mut completions: Vec = pattern + .expand( + shell.working_dir(), + Some(&path_filter), + &patterns::FilenameExpansionOptions::default(), + ) + .unwrap_or_default() + .into_paths() + .into_iter() + .map(|p| match sys::fs::normalize_path_separators(&p) { + std::borrow::Cow::Borrowed(_) => p, + std::borrow::Cow::Owned(normalized) => normalized, + }) + .collect(); + + match expanded_token.as_str() { + "." => { + completions.push(".".into()); + completions.push("..".into()); + } + ".." => { + completions.push("..".into()); + } + _ => {} + } + + completions.sort(); + completions.dedup(); + completions +} + +/// Attempts to complete a variable name from the given token. +/// Returns the candidates and how to process them if the token looks like a variable reference being typed, +/// or `None` if file/command completion should be used instead. +/// +/// # Arguments +/// +/// * `shell` - The shell instance to use for variable lookup. +/// * `token` - The token being completed. May be empty. +fn try_get_variable_completions( + shell: &Shell, + token: &str, +) -> Option<(Vec, ProcessingOptions)> { + // Determine if this is a braced or unbraced variable reference + let (var_prefix, use_braces) = if let Some(prefix) = token.strip_prefix("${") { + // For braced: only complete if brace isn't closed yet + if prefix.contains('}') { + return None; + } + (prefix, true) + } else { + let prefix = token.strip_prefix('$')?; + (prefix, false) + }; + + // If there's a path separator, this is a path like $HOME/foo, not a variable to complete + if sys::fs::contains_path_separator(var_prefix) { + return None; + } + + // Find matching variables + let mut candidates: Vec = shell + .env() + .iter() + .filter(|(key, _)| key.starts_with(var_prefix)) + .map(|(key, _)| { + if use_braces { + format!("${{{key}}}") + } else { + format!("${key}") + } + }) + .collect(); + candidates.sort(); + + // Variable completions should not be treated as filenames (no escaping needed) + let options = ProcessingOptions { + treat_as_filenames: false, + ..ProcessingOptions::default() + }; + + Some((candidates, options)) +} + +/// Returns the names of the commands that start with `prefix`, in the order bash lists +/// them: aliases, keywords, functions, enabled builtins, then executables in the path. +fn command_completions( + shell: &Shell, + prefix: &str, +) -> Vec { + let mut names = Vec::new(); + + // Aliases and functions are stored unordered; bash enumerates each sorted by name. The + // shell's builtins are already listed in order of name. + extend_matching(&mut names, shell.aliases().keys().sorted(), prefix); + extend_matching(&mut names, shell.get_keywords(), prefix); + extend_matching( + &mut names, + shell.funcs().iter().map(|(name, _)| name).sorted(), + prefix, + ); + extend_matching( + &mut names, + shell + .builtins() + .iter() + .filter(|(_, registration)| registration.is_enabled()) + .map(|(name, _)| name), + prefix, + ); + names.extend( + shell + .find_executables_in_path_with_prefix( + prefix, + shell.options().case_insensitive_pathname_expansion, + ) + .filter_map(|path| Some(path.file_name()?.to_string_lossy().into_owned())), + ); + + names +} + +/// Adds the `names` that start with `prefix` to `candidates`. +fn extend_matching>( + candidates: &mut Vec, + names: impl IntoIterator, + prefix: &str, +) { + candidates.extend( + names + .into_iter() + .filter(|name| name.as_ref().starts_with(prefix)) + .map(|name| name.as_ref().to_owned()), + ); +} + +async fn get_completions_using_basic_lookup( + shell: &Shell, + context: &Context<'_>, +) -> (Vec, ProcessingOptions) { + let token = context.word; + + // Try variable completion first (e.g., $HO -> $HOME, ${HO -> ${HOME}) + if let Some(answer) = try_get_variable_completions(shell, token) { + return answer; + } + + // File completions + let mut candidates = get_file_completions(shell, token, false).await; + + // If this appears to be the command token (and if there's *some* prefix without + // a path separator) then also consider whether we should search the path for + // completions too. + // TODO(completions): Do a better job than just checking if index == 0. + let is_command_position = context.line.words.cword == Some(0) + && !token.is_empty() + && !sys::fs::contains_path_separator(token); + + if is_command_position { + candidates.extend(command_completions(shell, token)); + candidates.sort(); + } + + (candidates, ProcessingOptions::default()) +} + +fn completion_filter_pattern_matches( + pattern: &str, + candidate: &str, + token_being_completed: &str, + shell: &Shell, +) -> Result { + let pattern = replace_unescaped_ampersands(pattern, token_being_completed); + + // + // TODO(completions): Replace unescaped '&' with the word being completed. + // + + let pattern = shell_pattern(shell, pattern.as_ref()); + + let matches = pattern.exactly_matches(candidate)?; + + Ok(matches) +} + +fn replace_unescaped_ampersands<'a>(pattern: &'a str, replacement: &str) -> Cow<'a, str> { + let mut in_escape = false; + let mut insertion_points = vec![]; + + for (i, c) in pattern.char_indices() { + if !in_escape && c == '&' { + insertion_points.push(i); + } + in_escape = !in_escape && c == '\\'; + } + + if insertion_points.is_empty() { + return pattern.into(); + } + + let mut result = pattern.to_owned(); + for i in insertion_points.iter().rev() { + result.replace_range(*i..=*i, replacement); + } + + result.into() +} + +/// Returns `pattern`, set to match as the shell's options say. +fn shell_pattern( + shell: &Shell, + pattern: impl Into, +) -> patterns::Pattern { + pattern + .into() + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_pathname_expansion) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generation_options_enable_and_disable_each_option() { + let mut options: GenerationOptions = std::iter::once(CompleteOption::NoSpace).collect(); + assert!(options.get(CompleteOption::NoSpace)); + assert!(!options.get(CompleteOption::NoSort)); + + options.set(CompleteOption::NoSort, true); + options.set(CompleteOption::NoSpace, false); + assert!(options.get(CompleteOption::NoSort)); + assert!(!options.get(CompleteOption::NoSpace)); + assert_eq!( + options, + std::iter::once(CompleteOption::NoSort).collect::() + ); + } + + #[test] + fn special_specs_have_bash_names() { + assert_eq!(SpecialSpec::Default.command_name(), "_DefaultCmD_"); + assert_eq!(SpecialSpec::EmptyLine.command_name(), "_EmptycmD_"); + assert_eq!(SpecialSpec::InitialWord.command_name(), "_InitialWorD_"); + } + + #[test] + fn special_specs_are_each_stored_on_their_own() { + let mut config = Config::default(); + let specials = [ + (SpecialSpec::Default, "d"), + (SpecialSpec::EmptyLine, "e"), + (SpecialSpec::InitialWord, "i"), + ]; + + for (special, word) in specials { + assert!(config.get(SpecName::Special(special)).is_none()); + let spec = Spec { + word_list: Some(word.to_owned()), + ..Spec::default() + }; + config.set(SpecName::Special(special), spec); + } + + for (special, word) in specials { + let spec = config.get(SpecName::Special(special)); + assert_eq!(spec.and_then(|spec| spec.word_list.as_deref()), Some(word)); + + if let Some(spec) = config.get_mut(SpecName::Special(special)) { + spec.prefix = Some(word.to_uppercase()); + } + // Bash's name for the spec names the same one. + let spec = config.get(SpecName::parse(special.command_name())); + assert_eq!( + spec.and_then(|spec| spec.prefix.clone()), + Some(word.to_uppercase()) + ); + } + } + + #[test] + fn spec_names_parse_bash_names_for_special_specs() { + assert_eq!( + SpecName::parse("_DefaultCmD_"), + SpecName::Special(SpecialSpec::Default) + ); + assert_eq!(SpecName::parse("mycmd"), SpecName::Command("mycmd")); + for special in SpecialSpec::iter() { + let name = SpecName::Special(special); + assert_eq!(SpecName::parse(name.as_str()), name); + } + assert_eq!(SpecName::Command("mycmd").as_str(), "mycmd"); + } + + #[test] + fn special_specs_round_trip_through_bash_names() { + for special in SpecialSpec::iter() { + assert_eq!( + SpecialSpec::from_command_name(special.command_name()), + Some(special) + ); + } + assert_eq!( + SpecialSpec::from_command_name("_DefaultCmD_"), + Some(SpecialSpec::Default) + ); + assert_eq!(SpecialSpec::from_command_name("mycmd"), None); + } + + #[test] + fn completion_names_are_bash_names() { + for action in CompleteAction::iter() { + assert_eq!(<&str>::from(action).parse::(), Ok(action)); + } + for option in CompleteOption::iter() { + assert_eq!(<&str>::from(&option).parse::(), Ok(option)); + } + assert_eq!(<&str>::from(CompleteAction::ArrayVar), "arrayvar"); + assert_eq!(<&str>::from(CompleteOption::BashDefault), "bashdefault"); + } +} diff --git a/userland/brush/source/brush-core/src/completion/quoting.rs b/userland/brush/source/brush-core/src/completion/quoting.rs new file mode 100644 index 0000000..0238796 --- /dev/null +++ b/userland/brush/source/brush-core/src/completion/quoting.rs @@ -0,0 +1,166 @@ +//! Completion's quoting model, which follows readline's: finding the quotes in a line +//! being completed. +//! +//! It differs from the shell's own quoting in small ways (see [`scan`]). + +/// A quote left open where a word is being completed. +/// +/// Unlike readline, which knows only single and double quotes, completion knows `$'...'` +/// too. `$"..."` opens a quote as `"` does. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub(super) enum Quote { + /// A single quote (`'`). + Single, + /// A double quote (`"`). + Double, + /// An ANSI-C quote (`$'...'`), in which a backslash escapes any char. + AnsiC, +} + +impl Quote { + /// Returns the quote that `c` opens, if it's a quote char. + const fn from_char(c: char) -> Option { + match c { + '\'' => Some(Self::Single), + '"' => Some(Self::Double), + _ => None, + } + } + + /// Returns the char that closes the quote. + pub const fn as_char(self) -> char { + match self { + Self::Single | Self::AnsiC => '\'', + Self::Double => '"', + } + } + + /// Returns the text that opens the quote. + pub const fn opening(self) -> &'static str { + match self { + Self::Single => "'", + Self::Double => "\"", + Self::AnsiC => "$'", + } + } +} + +/// A char of a line, as quoting affects it (see [`scan`]). +pub(super) struct ScannedChar { + /// The byte offset of the char. + pub index: usize, + pub c: char, + /// Whether the char is unquoted, and doesn't quote anything itself (as a quote char + /// or a backslash does): only such a char can break words. + pub unquoted: bool, + /// The quote left open after this char, if any, and its byte offset. + pub open_quote: Option<(usize, Quote)>, + /// Whether the char is a backslash that escapes the char after it. + pub escapes_next: bool, +} + +/// Scans `input` for quoting, like readline: quotes and backslash escapes protect the chars +/// they quote. Unlike the shell's, a backslash escapes any char in double quotes. Unlike +/// readline's, an unquoted `$` before a `'` opens a `$'...'` quote -- unless it ends a `$$`. +pub(super) fn scan(input: &str) -> impl Iterator + '_ { + let mut open_quote: Option<(usize, Quote)> = None; + let mut escaped = false; + // The offset of the char before, if it's an unquoted `$` that could open a `$'...'`. + let mut dollar = None; + + input.char_indices().map(move |(index, c)| { + let mut unquoted = false; + let after_dollar = dollar.take(); + if escaped { + escaped = false; + } else if let Some((_, q)) = open_quote { + if c == '\\' && q != Quote::Single { + escaped = true; + } else if c == q.as_char() { + open_quote = None; + } + } else if c == '\\' { + escaped = true; + } else if let Some(q) = Quote::from_char(c) { + open_quote = Some(match (q, after_dollar) { + (Quote::Single, Some(dollar)) => (dollar, Quote::AnsiC), + _ => (index, q), + }); + } else { + unquoted = true; + // A `$` after one that could open a quote makes `$$` with it instead. + if c == '$' && after_dollar.is_none() { + dollar = Some(index); + } + } + + ScannedChar { + index, + c, + unquoted, + open_quote, + escapes_next: escaped, + } + }) +} + +/// Ends the quoting `text` leaves open, so it parses: drops a backslash left at the end with +/// nothing to escape, then closes the quote left open, if any. +pub(super) fn close(text: &mut String) { + let Some(last) = scan(text).last() else { + return; + }; + if last.escapes_next { + text.pop(); + } + if let Some((_, q)) = last.open_quote { + text.push(q.as_char()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn close_ends_open_quoting() { + let closed = |text: &str| { + let mut text = text.to_owned(); + close(&mut text); + text + }; + + assert_eq!(closed(""), ""); + assert_eq!(closed("abc"), "abc"); + assert_eq!(closed("'a b"), "'a b'"); + assert_eq!(closed(r#""a\"b"#), r#""a\"b""#); + // A backslash with nothing to escape is dropped, unless it's literal in single + // quotes; an escaped one stays. + assert_eq!(closed(r"a\"), "a"); + assert_eq!(closed(r#""a\"#), r#""a""#); + assert_eq!(closed(r"'a\"), r"'a\'"); + assert_eq!(closed(r"a\\"), r"a\\"); + assert_eq!(closed(r#""a\\"#), r#""a\\""#); + + // In `$'...'`, a backslash escapes any char, a quote too; `$$` is no `$'`. + assert_eq!(closed(r"$'a\'b"), r"$'a\'b'"); + assert_eq!(closed(r"$'a\"), r"$'a'"); + assert_eq!(closed(r"$$'a\'b"), r"$$'a\'b"); + assert_eq!(closed(r"$$$'a"), r"$$$'a'"); + assert_eq!(closed(r#""$'a"#), r#""$'a""#); + } + + #[test] + fn scan_finds_ansi_c_quotes() { + let open_quote = |text: &str| scan(text).last().and_then(|sc| sc.open_quote); + + assert_eq!(open_quote("$'a"), Some((0, Quote::AnsiC))); + assert_eq!(open_quote("x$'a"), Some((1, Quote::AnsiC))); + assert_eq!(open_quote(r"\$'a"), Some((2, Quote::Single))); + assert_eq!(open_quote("$$'a"), Some((2, Quote::Single))); + assert_eq!(open_quote("$$$'a"), Some((2, Quote::AnsiC))); + assert_eq!(open_quote("$$$$'a"), Some((4, Quote::Single))); + assert_eq!(open_quote("$\"a"), Some((1, Quote::Double))); + } +} diff --git a/userland/brush/source/brush-core/src/completion/words.rs b/userland/brush/source/brush-core/src/completion/words.rs new file mode 100644 index 0000000..69b2cec --- /dev/null +++ b/userland/brush/source/brush-core/src/completion/words.rs @@ -0,0 +1,341 @@ +//! Finds the words of an input line being completed, as bash does: both the word to +//! complete (readline's) and the words of `COMP_WORDS`. + +use std::ops::Range; + +use super::quoting; + +/// The words of `COMP_WORDS` in an input line being completed, and which the cursor is in. +#[derive(Debug)] +pub(super) struct LineWords<'a> { + /// The words of `COMP_WORDS`. + pub comp_words: Vec<&'a str>, + /// The index of the cursor's word in `comp_words` (`COMP_CWORD`), if there are any + /// words. + pub cword: Option, +} + +/// No words, as in an empty line. +pub(super) static NO_WORDS: LineWords<'static> = LineWords { + comp_words: Vec::new(), + cword: None, +}; + +impl<'a> LineWords<'a> { + /// Returns the command being completed for: the first word, if any. + pub fn command_name(&self) -> Option<&'a str> { + self.comp_words.first().copied() + } + + /// Returns the word before the cursor's word, if any; like bash, when completing the + /// first word, that word itself. + pub fn preceding_word(&self) -> Option<&'a str> { + let index = self.cword?.saturating_sub(1); + self.comp_words.get(index).copied() + } +} + +/// The word being completed: the text that completions replace. +/// +/// Like readline, it runs up to the cursor from just past an unclosed quote the cursor is +/// in, or else from just past the last unquoted word-break char (see `COMP_WORDBREAKS`), +/// so it may be only part of a shell word. +#[derive(Debug)] +pub(super) struct CompletionWord { + /// The word's text, without its unclosed quote; a completion function's `$2`. + pub text: String, + /// The byte range of the line that candidates replace: the word's text, and the + /// unclosed quote before it, if any. + pub range: Range, +} + +/// A word of `COMP_WORDS`, and where it is in the input line. +#[derive(Debug, Clone, Copy)] +struct CompWord<'a> { + /// The word's text. + text: &'a str, + /// The byte offset in the input line where the word starts. + start: usize, +} + +impl CompWord<'_> { + /// Returns the byte offset in the input line just past the word. + const fn end(&self) -> usize { + self.start + self.text.len() + } +} + +/// Finds the words of `COMP_WORDS` in `input`, with `word_breaks` as the word-break chars, +/// and which of them the cursor is in. They needn't agree with the word to complete (see +/// [`find_completion_word`] and the module docs of [`completion`](super)). +pub(super) fn find_line_words<'a>( + input: &'a str, + word_breaks: &[char], + cursor: usize, +) -> LineWords<'a> { + let mut comp_words = comp_words(input, word_breaks); + + // Like bash, the cursor's word in COMP_WORDS is the last one touching the cursor + // (so one starting there wins over one ending there). If none does, the cursor is + // in whitespace, so insert an empty word there -- unless there are no words at all. + let cword = (!comp_words.is_empty()).then(|| { + comp_words + .iter() + .rposition(|w| w.start <= cursor && cursor <= w.end()) + .unwrap_or_else(|| { + let index = comp_words.partition_point(|w| w.start < cursor); + comp_words.insert( + index, + CompWord { + text: "", + start: cursor, + }, + ); + index + }) + }); + + LineWords { + comp_words: comp_words.into_iter().map(|w| w.text).collect(), + cword, + } +} + +/// Returns whether a scanned char breaks words: if it's an unquoted word-break char. +fn is_break(scanned: "ing::ScannedChar, word_breaks: &[char]) -> bool { + scanned.unquoted && word_breaks.contains(&scanned.c) +} + +/// Finds the word to complete like readline: if the cursor is in an unclosed quote, it +/// starts just past that quote; otherwise, just past the last unquoted word-break char before the +/// cursor (or at it, if it's one of bash's special prefixes, `$` or `@`). +/// +/// `cursor` must be a char boundary in `input`. +#[allow( + clippy::string_slice, + reason = "the word starts at a char boundary, and the caller checks the cursor is one" +)] +pub(super) fn find_completion_word( + input: &str, + word_breaks: &[char], + cursor: usize, +) -> CompletionWord { + const SPECIAL_PREFIXES: [char; 2] = ['$', '@']; + + let mut word_start = 0; + let mut open_quote = None; + for scanned in quoting::scan(input).take_while(|s| s.index < cursor) { + if is_break(&scanned, word_breaks) { + word_start = scanned.index; + if !SPECIAL_PREFIXES.contains(&scanned.c) { + word_start += scanned.c.len_utf8(); + } + } + open_quote = scanned.open_quote; + } + + let (start, text_start) = match open_quote { + Some((index, q)) => (index, index + q.opening().len()), + None => (word_start, word_start), + }; + + CompletionWord { + text: input[text_start..cursor].to_owned(), + range: start..cursor, + } +} + +/// Splits `input` into the words of `COMP_WORDS`, like bash: runs of chars other than +/// word breaks are words, and so are runs of word-break chars other than whitespace; +/// whitespace separates words but isn't one. +#[allow(clippy::string_slice, reason = "used indices come from char_indices")] +fn comp_words<'a>(input: &'a str, word_breaks: &[char]) -> Vec> { + let mut tokens = vec![]; + let mut word_start = None; + let mut word_is_breaks = false; + + for scanned in quoting::scan(input) { + let (i, c) = (scanned.index, scanned.c); + let is_break = is_break(&scanned, word_breaks); + let is_whitespace = is_break && c.is_ascii_whitespace(); + + // A word ends at whitespace, or where it switches between word breaks and other + // chars. + if let Some(start) = word_start { + if is_whitespace || is_break != word_is_breaks { + tokens.push(CompWord { + text: &input[start..i], + start, + }); + word_start = None; + } + } + + if !is_whitespace && word_start.is_none() { + word_start = Some(i); + word_is_breaks = is_break; + } + } + + if let Some(start) = word_start { + tokens.push(CompWord { + text: &input[start..], + start, + }); + } + + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_word_like_readline() { + let word_with = |delims: &str, input: &str, cursor: usize| { + let delims: Vec = delims.chars().collect(); + let word = find_completion_word(input, &delims, cursor); + // The word is in a quote if it's replaced from just before its text. + let quote = (word.range.len() > word.text.len()) + .then(|| input.get(word.range.start..)?.chars().next()) + .flatten(); + (word.text, quote) + }; + let word = |input: &str, cursor: usize| word_with(" \t\n\"'><=;|&(:", input, cursor); + let unquoted = |text: &str| (text.to_owned(), None); + let quoted = |text: &str, q| (text.to_owned(), Some(q)); + + assert_eq!(word("", 0), unquoted("")); + assert_eq!(word("ls fo", 5), unquoted("fo")); + assert_eq!(word("ls fo", 4), unquoted("f")); + assert_eq!(word("ls ", 3), unquoted("")); + assert_eq!(word("ls --opt=", 9), unquoted("")); + assert_eq!(word("ls --opt=val", 12), unquoted("val")); + assert_eq!(word("ls --opt=val", 9), unquoted("")); + assert_eq!(word("ls --opt=val", 8), unquoted("--opt")); + assert_eq!(word("ls a:=b", 5), unquoted("")); + assert_eq!(word("ls a\\:b", 7), unquoted("a\\:b")); + assert_eq!(word("ls \"a b\"c=d", 11), unquoted("d")); + assert_eq!(word("ls 'a b'c", 9), unquoted("'a b'c")); + assert_eq!(word("ls é:ü", 8), unquoted("ü")); + + // A break char that's a special prefix starts the word. + let word = |input: &str, cursor: usize| word_with(" \t\n\"'><=;|&(:@", input, cursor); + assert_eq!(word("ls a@b", 6), unquoted("@b")); + assert_eq!(word("ls a:@b", 7), unquoted("@b")); + assert_eq!(word("ls a@:b", 7), unquoted("b")); + assert_eq!(word("ls a@", 5), unquoted("@")); + + // In an open quote, the word's text starts after the quote, wherever it opened. + assert_eq!(word("ls 'a b:c", 9), quoted("a b:c", '\'')); + assert_eq!(word("ls x'a b", 8), quoted("a b", '\'')); + assert_eq!(word("ls --x=\"a", 9), quoted("a", '"')); + assert_eq!(word(r#"ls "a\"b"#, 8), quoted(r#"a\"b"#, '"')); + // A `$'...'` quote opens at its `$`, and a backslash in it escapes a quote too. + assert_eq!(word("ls $'a b", 8), quoted("a b", '$')); + assert_eq!(word(r"ls $'a\'b c", 11), quoted(r"a\'b c", '$')); + // But `$$` is the shell's PID, so a quote after it is an ordinary one. + assert_eq!(word("ls $$'a b", 9), quoted("a b", '\'')); + // Backslashes are literal in single quotes, and an escaped quote opens nothing. + assert_eq!(word(r"ls 'a\'b", 8), unquoted(r"'a\'b")); + assert_eq!(word(r"ls \'a", 6), unquoted(r"\'a")); + } + + #[test] + fn line_words_like_bash() { + fn words( + input: &str, + cursor: usize, + ) -> (Vec<&str>, Option, Option<&str>, Option<&str>) { + let words = find_line_words(input, &[' ', '='], cursor); + ( + words.comp_words.clone(), + words.cword, + words.command_name(), + words.preceding_word(), + ) + } + + assert_eq!(words("", 0), (vec![], None, None, None)); + assert_eq!( + words("ls fo", 5), + (vec!["ls", "fo"], Some(1), Some("ls"), Some("ls")) + ); + // The first word's preceding word is itself. + assert_eq!( + words("ls", 2), + (vec!["ls"], Some(0), Some("ls"), Some("ls")) + ); + // In whitespace, an empty word is inserted at the cursor. + assert_eq!( + words("ls x", 3), + (vec!["ls", "", "x"], Some(1), Some("ls"), Some("ls")) + ); + assert_eq!( + words("ls ", 3), + (vec!["ls", ""], Some(1), Some("ls"), Some("ls")) + ); + // A word starting at the cursor wins over one ending there. + assert_eq!( + words("ls a=b", 5), + (vec!["ls", "a", "=", "b"], Some(3), Some("ls"), Some("=")) + ); + } + + #[test] + fn completion_tokenization() { + let words = |input: &str, word_breaks: &str| -> Vec<(String, usize)> { + let word_breaks: Vec = word_breaks.chars().collect(); + comp_words(input, &word_breaks) + .into_iter() + .map(|w| (w.text.to_owned(), w.start)) + .collect() + }; + let expected = |words: &[(&str, usize)]| -> Vec<(String, usize)> { + words + .iter() + .map(|&(text, start)| (text.to_owned(), start)) + .collect() + }; + + for (input, word_breaks, expected_words) in [ + ("one two", " ", expected(&[("one", 0), ("two", 4)])), + ("one \t two", " \t", expected(&[("one", 0), ("two", 6)])), + (" ", " ", expected(&[])), + (":", ":", expected(&[(":", 0)])), + ("a:::b", ": ", expected(&[("a", 0), (":::", 1), ("b", 4)])), + ("a:=", ":= ", expected(&[("a", 0), (":=", 1)])), + ( + "a: : :b", + ": ", + expected(&[("a", 0), (":", 1), (":", 3), (":", 5), ("b", 6)]), + ), + ( + "one two:three", + ": ", + expected(&[("one", 0), ("two", 4), (":", 7), ("three", 8)]), + ), + ("one'two", "'", expected(&[("one'two", 0)])), + // Like bash, a quote opens anywhere, even after a word-break char, and ends a + // run of them. + ( + "a'b c' x=\"d e", + " =\"'", + expected(&[("a'b c'", 0), ("x", 7), ("=", 8), ("\"d e", 9)]), + ), + ( + "one 'two:three'", + ": ", + expected(&[("one", 0), ("'two:three'", 4)]), + ), + ( + "one \\'two \"two four\"", + ": ", + expected(&[("one", 0), ("\\'two", 4), ("\"two four\"", 10)]), + ), + ] { + assert_eq!(words(input, word_breaks), expected_words, "{input:?}"); + } + } +} diff --git a/userland/brush/source/brush-core/src/env.rs b/userland/brush/source/brush-core/src/env.rs new file mode 100644 index 0000000..9a2ea54 --- /dev/null +++ b/userland/brush/source/brush-core/src/env.rs @@ -0,0 +1,571 @@ +//! Implements a shell variable environment. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::hash_map; + +use crate::Shell; +use crate::error; +use crate::extensions; +use crate::variables::{self, ShellValue, ShellValueUnsetType, ShellVariable}; + +/// Represents the policy for looking up variables in a shell environment. +#[derive(Clone, Copy)] +pub enum EnvironmentLookup { + /// Look anywhere. + Anywhere, + /// Look only in the global scope. + OnlyInGlobal, + /// Look only in the innermost scope of the given type (e.g. the current function's + /// local scope, for [`EnvironmentScope::Local`]). + OnlyInCurrent(EnvironmentScope), + /// Look only in local scopes. + OnlyInLocal, +} + +impl EnvironmentLookup { + /// Returns a filter that, given the environment's scopes' types from the innermost + /// out, says whether this policy looks in each. + fn scope_filter(self) -> impl FnMut(EnvironmentScope) -> bool { + let mut found_current = false; + move |scope_type| match self { + Self::Anywhere => true, + Self::OnlyInGlobal => scope_type == EnvironmentScope::Global, + Self::OnlyInLocal => scope_type == EnvironmentScope::Local, + Self::OnlyInCurrent(current) => { + let is_current = !found_current && scope_type == current; + found_current |= is_current; + is_current + } + } + } +} + +/// Represents a shell environment scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EnvironmentScope { + /// Scope local to a function instance + Local, + /// Globals + Global, + /// Transient overrides for a command invocation + Command, +} + +impl std::fmt::Display for EnvironmentScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local => write!(f, "local"), + Self::Global => write!(f, "global"), + Self::Command => write!(f, "command"), + } + } +} + +/// Represents the shell variable environment, composed of a stack of scopes. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellEnvironment { + /// Stack of scopes, with the top of the stack being the current scope. + scopes: Vec<(EnvironmentScope, ShellVariableMap)>, + /// Whether or not to auto-export variables on creation or modification. + export_variables_on_modification: bool, + /// Count of total entries (may include duplicates with shadowed variables). + entry_count: usize, +} + +impl Default for ShellEnvironment { + fn default() -> Self { + Self::new() + } +} + +impl ShellEnvironment { + /// Returns a new shell environment. + pub fn new() -> Self { + Self { + scopes: vec![(EnvironmentScope::Global, ShellVariableMap::default())], + export_variables_on_modification: false, + entry_count: 0, + } + } + + /// Returns how many scopes deep the environment is. + pub(crate) const fn scope_depth(&self) -> usize { + self.scopes.len() + } + + /// Pushes a new scope of the given type onto the environment's scope stack. + /// + /// # Arguments + /// + /// * `scope_type` - The type of scope to push. + pub fn push_scope(&mut self, scope_type: EnvironmentScope) { + self.scopes.push((scope_type, ShellVariableMap::default())); + } + + /// Pops the top-most scope off the environment's scope stack. + /// + /// # Arguments + /// + /// * `expected_scope_type` - The type of scope that is expected to be atop the stack. + pub fn pop_scope(&mut self, expected_scope_type: EnvironmentScope) -> Result<(), error::Error> { + // TODO(env): Should we panic instead on failure? It's effectively a broken invariant. + // Either way, don't pop a scope that isn't the one expected. + match self.scopes.last() { + Some((actual_scope_type, _)) if *actual_scope_type == expected_scope_type => { + self.scopes.pop(); + Ok(()) + } + Some((actual_scope_type, _)) => Err(error::ErrorKind::UnexpectedScopeType { + expected: expected_scope_type, + actual: *actual_scope_type, + } + .into()), + None => Err(error::ErrorKind::MissingScope.into()), + } + } + + // + // Iterators/Getters + // + + /// Returns an iterator over all exported variables defined in the variable. + pub fn iter_exported(&self) -> impl Iterator { + // We won't actually need to store all entries, but we expect it should be + // within the same order. + let mut visible_vars: HashMap<&String, &ShellVariable> = + HashMap::with_capacity(self.entry_count); + + for (_, var_map) in self.scopes.iter().rev() { + for (name, var) in var_map.iter().filter(|(_, v)| v.is_exported()) { + // Only insert the variable if it hasn't been seen yet. + if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) { + entry.insert(var); + } + } + } + + visible_vars.into_iter() + } + + /// Returns an iterator over all the variables defined in the environment. + pub fn iter(&self) -> impl Iterator { + self.iter_using_policy(EnvironmentLookup::Anywhere) + } + + /// Returns an iterator over all the variables defined in the environment, + /// using the given lookup policy. + /// + /// # Arguments + /// + /// * `lookup_policy` - The policy to use when looking up variables. + pub fn iter_using_policy( + &self, + lookup_policy: EnvironmentLookup, + ) -> impl Iterator { + // We won't actually need to store all entries, but we expect it should be + // within the same order. + let mut visible_vars: HashMap<&String, &ShellVariable> = + HashMap::with_capacity(self.entry_count); + + let mut looks_in = lookup_policy.scope_filter(); + for (_, var_map) in self.scopes.iter().rev().filter(|(t, _)| looks_in(*t)) { + for (name, var) in var_map.iter() { + // Only insert the variable if it hasn't been seen yet. + if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) { + entry.insert(var); + } + } + } + + visible_vars.into_iter() + } + + /// Tries to retrieve an immutable reference to the variable with the given name + /// in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get>(&self, name: S) -> Option<(EnvironmentScope, &ShellVariable)> { + // Look through scopes, from the top of the stack on down. + for (scope_type, map) in self.scopes.iter().rev() { + if let Some(var) = map.get(name.as_ref()) { + return Some((*scope_type, var)); + } + } + + None + } + + /// Tries to retrieve a mutable reference to the variable with the given name + /// in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get_mut>( + &mut self, + name: S, + ) -> Option<(EnvironmentScope, &mut ShellVariable)> { + // Look through scopes, from the top of the stack on down. + for (scope_type, map) in self.scopes.iter_mut().rev() { + if let Some(var) = map.get_mut(name.as_ref()) { + return Some((*scope_type, var)); + } + } + + None + } + + /// Tries to retrieve the string value of the variable with the given name in the + /// environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `shell` - The shell owning the environment. + pub fn get_str, SE: extensions::ShellExtensions>( + &self, + name: S, + shell: &Shell, + ) -> Option> { + self.get(name.as_ref()) + .map(|(_, v)| v.value().to_cow_str(shell)) + } + + /// Checks if a variable of the given name is set in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to check. + pub fn is_set>(&self, name: S) -> bool { + if let Some((_, var)) = self.get(name) { + !matches!(var.value(), ShellValue::Unset(_)) + } else { + false + } + } + + // + // Setters + // + + /// Tries to unset the variable with the given name in the environment, returning + /// whether or not such a variable existed. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to unset. + pub fn unset(&mut self, name: &str) -> Result, error::Error> { + let mut local_count = 0; + for (scope_type, map) in self.scopes.iter_mut().rev() { + if matches!(scope_type, EnvironmentScope::Local) { + local_count += 1; + } + + let unset_result = Self::try_unset_in_map(map, name)?; + + if unset_result.is_some() { + // If we end up finding a local in the top-most local frame, then we replace + // it with a placeholder. + if matches!(scope_type, EnvironmentScope::Local) && local_count == 1 { + map.set( + name, + ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)), + ); + } else if self.entry_count > 0 { + // Entry count should never be 0 here, but we're being defensive. + self.entry_count -= 1; + } + + return Ok(unset_result); + } + } + + Ok(None) + } + + /// Tries to unset an array element from the environment, using the given name and + /// element index for lookup. Returns whether or not an element was unset. + /// + /// # Arguments + /// + /// * `name` - The name of the array variable to unset an element from. + /// * `index` - The index of the element to unset. + pub fn unset_index(&mut self, name: &str, index: &str) -> Result { + if let Some((_, var)) = self.get_mut(name) { + var.unset_index(index) + } else { + Ok(false) + } + } + + fn try_unset_in_map( + map: &mut ShellVariableMap, + name: &str, + ) -> Result, error::Error> { + match map.get(name).map(|v| v.is_readonly()) { + Some(true) => Err(error::ErrorKind::ReadonlyVariable.into()), + Some(false) => Ok(map.unset(name)), + None => Ok(None), + } + } + + /// Tries to retrieve an immutable reference to a variable from the environment, + /// using the given name and lookup policy. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `lookup_policy` - The policy to use when looking up the variable. + pub fn get_using_policy>( + &self, + name: N, + lookup_policy: EnvironmentLookup, + ) -> Option<&ShellVariable> { + let mut looks_in = lookup_policy.scope_filter(); + self.scopes + .iter() + .rev() + .filter(|(t, _)| looks_in(*t)) + .find_map(|(_, var_map)| var_map.get(name.as_ref())) + } + + /// Tries to retrieve a mutable reference to a variable from the environment, + /// using the given name and lookup policy. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `lookup_policy` - The policy to use when looking up the variable. + pub fn get_mut_using_policy>( + &mut self, + name: N, + lookup_policy: EnvironmentLookup, + ) -> Option<&mut ShellVariable> { + let mut looks_in = lookup_policy.scope_filter(); + self.scopes + .iter_mut() + .rev() + .filter(|(t, _)| looks_in(*t)) + .find_map(|(_, var_map)| var_map.get_mut(name.as_ref())) + } + + /// Update a variable in the environment, or add it if it doesn't already exist. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to update or add. + /// * `value` - The value to assign to the variable. + /// * `updater` - A function to call to update the variable after assigning the value. + /// * `lookup_policy` - The policy to use when looking up the variable. + /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist. + pub fn update_or_add>( + &mut self, + name: N, + value: variables::ShellValueLiteral, + updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>, + lookup_policy: EnvironmentLookup, + scope_if_creating: EnvironmentScope, + ) -> Result<(), error::Error> { + let name = name.into(); + + let auto_export = self.export_variables_on_modification; + if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) { + var.assign(value, false)?; + if auto_export { + var.export(); + } + updater(var) + } else { + let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)); + var.assign(value, false)?; + if auto_export { + var.export(); + } + updater(&mut var)?; + + self.add(name, var, scope_if_creating) + } + } + + /// Update an array element in the environment, or add it if it doesn't already exist. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to update or add. + /// * `index` - The index of the element to update or add. + /// * `value` - The value to assign to the variable. + /// * `updater` - A function to call to update the variable after assigning the value. + /// * `lookup_policy` - The policy to use when looking up the variable. + /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist. + pub fn update_or_add_array_element>( + &mut self, + name: N, + index: String, + value: String, + updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>, + lookup_policy: EnvironmentLookup, + scope_if_creating: EnvironmentScope, + ) -> Result<(), error::Error> { + let name = name.into(); + + if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) { + var.assign_at_index(index, value, false)?; + updater(var) + } else { + let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)); + var.assign( + variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![( + Some(index), + value, + )])), + false, + )?; + updater(&mut var)?; + + self.add(name, var, scope_if_creating) + } + } + + /// Adds a variable to the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to add. + /// * `var` - The variable to add. + /// * `target_scope` - The scope to add the variable to. + pub fn add>( + &mut self, + name: N, + mut var: ShellVariable, + target_scope: EnvironmentScope, + ) -> Result<(), error::Error> { + if self.export_variables_on_modification { + var.export(); + } + + for (scope_type, map) in self.scopes.iter_mut().rev() { + if *scope_type == target_scope { + let prev_var = map.set(name, var); + if prev_var.is_none() { + self.entry_count += 1; + } + + return Ok(()); + } + } + + Err(error::ErrorKind::MissingScopeForNewVariable.into()) + } + + /// Sets a global variable in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to set. + /// * `var` - The variable to set. + pub fn set_global>( + &mut self, + name: N, + var: ShellVariable, + ) -> Result<(), error::Error> { + self.add(name, var, EnvironmentScope::Global) + } +} + +/// Represents a map from names to shell variables. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellVariableMap { + variables: HashMap, +} + +impl ShellVariableMap { + // + // Iterators/Getters + // + + /// Returns an iterator over all the variables in the map. + pub fn iter(&self) -> impl Iterator { + self.variables.iter() + } + + /// Tries to retrieve an immutable reference to the variable with the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get(&self, name: &str) -> Option<&ShellVariable> { + self.variables.get(name) + } + + /// Tries to retrieve a mutable reference to the variable with the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVariable> { + self.variables.get_mut(name) + } + + // + // Setters + // + + /// Tries to unset the variable with the given name, returning the removed + /// variable or None if it was not already set. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to unset. + pub fn unset(&mut self, name: &str) -> Option { + self.variables.remove(name) + } + + /// Sets a variable in the map. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to set. + /// * `var` - The variable to set. + pub fn set>(&mut self, name: N, var: ShellVariable) -> Option { + self.variables.insert(name.into(), var) + } +} + +/// Checks if the given name is a valid variable name. +pub fn valid_variable_name(s: &str) -> bool { + let mut cs = s.chars(); + match cs.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => { + cs.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + Some(_) | None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_variable_name() { + assert!(!valid_variable_name("")); + assert!(!valid_variable_name("1")); + assert!(!valid_variable_name(" a")); + assert!(!valid_variable_name(" ")); + + assert!(valid_variable_name("_")); + assert!(valid_variable_name("_a")); + assert!(valid_variable_name("_1")); + assert!(valid_variable_name("_a1")); + assert!(valid_variable_name("a")); + assert!(valid_variable_name("A")); + assert!(valid_variable_name("a1")); + assert!(valid_variable_name("A1")); + } +} diff --git a/userland/brush/source/brush-core/src/error.rs b/userland/brush/source/brush-core/src/error.rs new file mode 100644 index 0000000..bb0690e --- /dev/null +++ b/userland/brush/source/brush-core/src/error.rs @@ -0,0 +1,483 @@ +//! Error facilities + +use std::path::PathBuf; + +use crate::{Shell, ShellFd, extensions, results, sys}; + +/// Unified error type for this crate. Contains just a kind for now, +/// but will be extended later with additional context. +#[derive(thiserror::Error, Debug)] +#[error("{kind}")] +pub struct Error { + /// The kind of error. + #[source] + kind: Box, + + /// Whether or not the error should be considered a "fatal" error that would + /// result in abnormal exit of a non-interactive shell. + fatal: bool, +} + +/// Monolithic error type for the shell +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum ErrorKind { + /// A tilde expression was used without a valid HOME variable + #[error("cannot expand tilde expression with HOME not set")] + TildeWithoutValidHome, + + /// An attempt was made to assign a list to an array member + #[error("cannot assign list to array member")] + AssigningListToArrayMember, + + /// An attempt was made to convert an associative array to an indexed array. + #[error("cannot convert associative array to indexed array")] + ConvertingAssociativeArrayToIndexedArray, + + /// An attempt was made to convert an indexed array to an associative array. + #[error("cannot convert indexed array to associative array")] + ConvertingIndexedArrayToAssociativeArray, + + /// An error occurred while sourcing the indicated script file. + #[error("failed to source file: {0}")] + FailedSourcingFile(PathBuf, #[source] std::io::Error), + + /// The process or process group does not exist. + #[error("no such process")] + NoSuchProcess, + + /// The process or process group exists, but cannot be signaled. + #[error("operation not permitted")] + PermissionDenied, + + /// The shell failed to send a signal to a process. + #[error("failed to send signal to process")] + FailedToSendSignal, + + /// An attempt was made to assign a value to a special parameter. + #[error("cannot assign in this way")] + CannotAssignToSpecialParameter, + + /// Checked expansion error. + #[error("expansion error: {0}")] + CheckedExpansionError(String), + + /// A reference was made to an unknown shell function. + #[error("function not found: {0}")] + FunctionNotFound(String), + + /// Command was not found. + #[error("command not found: {0}")] + CommandNotFound(String), + + /// Not a builtin. + #[error("not a shell builtin: {0}")] + BuiltinNotFound(String), + + /// The working directory does not exist. + #[error("working directory does not exist: {0}")] + WorkingDirMissing(PathBuf), + + /// Failed to execute command. + #[error("failed to execute command '{0}': {1}")] + FailedToExecuteCommand(String, #[source] std::io::Error), + + /// History item was not found. + #[error("history item not found")] + HistoryItemNotFound, + + /// The requested functionality has not yet been implemented in this shell. + #[error("not yet implemented: {0}")] + Unimplemented(&'static str), + + /// An expected environment scope could not be found. + #[error("missing environment scope")] + MissingScope, + + /// The environment scope required for a new variable is not available. + #[error("environment scope required for new variable is not available")] + MissingScopeForNewVariable, + + /// An unexpected environment scope type was encountered. + #[error("unexpected environment scope type: expected '{expected}', found '{actual}'")] + UnexpectedScopeType { + /// The expected scope type. + expected: crate::env::EnvironmentScope, + /// The actual scope type. + actual: crate::env::EnvironmentScope, + }, + + /// The given path is not a directory. + #[error("not a directory: {0}")] + NotADirectory(PathBuf), + + /// The given path is a directory. + #[error("path is a directory")] + IsADirectory, + + /// The given variable is not an array. + #[error("variable is not an array")] + NotArray, + + /// The current user could not be determined. + #[error("no current user")] + NoCurrentUser, + + /// The requested input or output redirection is invalid. + #[error("invalid redirection target")] + InvalidRedirection, + + /// An error occurred while redirecting input or output with the given file. + #[error("failed to redirect to {0}: {1}")] + RedirectionFailure(String, String), + + /// An error occurred evaluating an arithmetic expression. + #[error("arithmetic evaluation error: {0}")] + EvalError(#[from] crate::arithmetic::EvalError), + + /// The given string could not be parsed as an integer. + #[error("failed to parse '{s}' as a {int_type_name}, base-{radix} integer: {inner}")] + IntParseError { + /// The string that failed to parse. + s: String, + /// The integer type being parsed. + int_type_name: &'static str, + /// The radix (base) used for parsing. + radix: u32, + /// The underlying parse error. + inner: std::num::ParseIntError, + }, + + /// The given integer could not be converted to the target type. + #[error("integer conversion error")] + TryIntParseError(#[from] std::num::TryFromIntError), + + /// A byte sequence could not be decoded as a valid UTF-8 string. + #[error("failed to decode utf-8")] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + /// A byte sequence could not be decoded as a valid UTF-8 string. + #[error("failed to decode utf-8")] + Utf8Error(#[from] std::str::Utf8Error), + + /// An attempt was made to modify a readonly variable. + #[error("cannot mutate readonly variable")] + ReadonlyVariable, + + /// The indicated pattern is invalid. + #[error("invalid pattern: '{0}'")] + InvalidPattern(String), + + /// A regular expression error occurred + #[error("regex error: {0}")] + RegexError(#[from] fancy_regex::Error), + + /// An invalid regular expression was provided. + #[error("invalid regex: {0}; expression: '{1}'")] + InvalidRegexError(fancy_regex::Error, String), + + /// An I/O error occurred. + #[error("i/o error: {0}")] + IoError(#[from] std::io::Error), + + /// Invalid substitution syntax. + #[error("bad substitution: {0}")] + BadSubstitution(String), + + /// An error occurred while creating a child process. + #[error("failed to create child process")] + ChildCreationFailure, + + /// An error occurred while formatting a string. + #[error(transparent)] + FormattingError(#[from] std::fmt::Error), + + /// An error occurred while parsing. + #[error("{1}: {0}")] + ParseError(crate::parser::ParseError, crate::SourceInfo), + + /// An error occurred while parsing a function body. + #[error("{0}: {1}")] + FunctionParseError(String, crate::parser::ParseError), + + /// An error occurred while parsing a word. + #[error(transparent)] + WordParseError(#[from] crate::parser::WordParseError), + + /// Unable to parse a test command. + #[error("invalid test command")] + TestCommandParseError(#[from] crate::parser::TestCommandParseError), + + /// An operand of an arithmetic comparison in a test command isn't an integer. + #[error("{0}: integer expression expected")] + IntegerExpressionExpected(String), + + /// Unable to parse a key binding specification. + #[error(transparent)] + BindingParseError(#[from] crate::parser::BindingParseError), + + /// A task the shell started couldn't run to completion (e.g., it panicked). + #[error("task error")] + TaskError(#[source] Box), + + /// An invalid signal was referenced. + #[error("{0}: invalid signal specification")] + InvalidSignal(String), + + /// A platform error occurred. + #[error("platform error: {0}")] + PlatformError(#[from] sys::PlatformError), + + /// An invalid umask was provided. + #[error("invalid umask value")] + InvalidUmask, + + /// The given open file cannot be read from. + #[error("cannot read from {0}")] + OpenFileNotReadable(&'static str), + + /// The given open file cannot be written to. + #[error("cannot write to {0}")] + OpenFileNotWritable(&'static str), + + /// Bad file descriptor. + #[error("bad file descriptor: {0}")] + BadFileDescriptor(ShellFd), + + /// Printf failure + #[error("printf failure: {0}")] + PrintfFailure(i32), + + /// Printf invalid usage + #[error("printf: {0}")] + PrintfInvalidUsage(String), + + /// Interrupted + #[error("interrupted")] + Interrupted, + + /// Maximum function call depth was exceeded. + #[error("maximum function call depth exceeded")] + MaxFunctionCallDepthExceeded, + + /// System time error. + #[error("system time error: {0}")] + TimeError(#[from] std::time::SystemTimeError), + + /// Array index out of range. + #[error("array index out of range: {0}")] + ArrayIndexOutOfRange(String), + + /// Unhandled key code. + #[error("unhandled key code: {0:?}")] + UnhandledKeyCode(Vec), + + /// An error occurred in a built-in command. + #[error("{1}: {0}")] + BuiltinError(Box, String), + + /// Operation not supported on this platform. + #[error("operation not supported on this platform: {0}")] + NotSupportedOnThisPlatform(&'static str), + + /// Command history is not enabled in this shell. + #[error("command history is not enabled in this shell")] + HistoryNotEnabled, + + /// Expanding an unset variable. + #[error("expanding unset variable: {0}")] + ExpandingUnsetVariable(String), + + /// An internal error occurred. + #[error("internal shell error: {0}")] + InternalError(String), + + /// A position to complete at isn't a char boundary in the line being completed. + #[error("completion position {0} isn't a char boundary in the input line")] + InvalidCompletionPosition(usize), + + /// Attempted to perform an operation that requires an interactive session. + #[error("operation requires an interactive session")] + NotInInteractiveSession, + + /// Attempted to perform an operation that requires command-string mode. + #[error("operation requires command-string mode")] + NotExecutingCommandString, + + /// Too much data was provided to an operation. + #[error("too much data")] + TooMuchData, + + /// Cannot convert open file to native file descriptor. + #[error("cannot convert open file to native file descriptor")] + CannotConvertToNativeFd, + + /// History file is too large to import. + #[error("history file is too large to import")] + HistoryFileTooLargeToImport, + + /// Too many open files. + #[error("too many open files")] + TooManyOpenFiles, + + /// The function name shadows a special built-in command. + #[error("function name '{}' shadows a special built-in command", .name)] + FunctionNameShadowsSpecialBuiltin { + /// Name of the function. + name: String, + }, + + /// A glob pattern failed to match any files (failglob). + #[error("no match: {0}")] + NoMatch(String), +} + +/// Trait implementable by built-in commands to represent errors. +pub trait BuiltinError: std::error::Error + ConvertibleToExitCode + Send + Sync { + /// Try to extract a reference to the underlying `std::io::Error`, if any. + /// Implementations should return `None` if there is no inner I/O error. + /// They should not attempt to *synthesize* an I/O error if one does not + /// naturally exist. + fn as_io_error(&self) -> Option<&std::io::Error> { + None + } +} + +impl BuiltinError for Error { + fn as_io_error(&self) -> Option<&std::io::Error> { + self.as_io_error() + } +} + +/// Helper trait for converting values to exit codes. +pub trait ConvertibleToExitCode { + /// Converts to an exit code. + fn as_exit_code(&self) -> results::ExecutionExitCode; +} + +impl ConvertibleToExitCode for T +where + results::ExecutionExitCode: for<'a> From<&'a T>, +{ + fn as_exit_code(&self) -> results::ExecutionExitCode { + self.into() + } +} + +impl From<&ErrorKind> for results::ExecutionExitCode { + fn from(value: &ErrorKind) -> Self { + match value { + ErrorKind::CommandNotFound(..) => Self::NotFound, + ErrorKind::Unimplemented(..) => Self::Unimplemented, + ErrorKind::ParseError(..) => Self::InvalidUsage, + ErrorKind::FunctionParseError(..) => Self::InvalidUsage, + ErrorKind::TestCommandParseError(..) => Self::InvalidUsage, + ErrorKind::IntegerExpressionExpected(..) => Self::InvalidUsage, + ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute, + ErrorKind::FunctionNameShadowsSpecialBuiltin { .. } => Self::InvalidUsage, + ErrorKind::IoError(io_err) => io_err.into(), + ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(), + _ => Self::GeneralError, + } + } +} + +impl From<&std::io::Error> for results::ExecutionExitCode { + fn from(io_err: &std::io::Error) -> Self { + if io_err.kind() == std::io::ErrorKind::BrokenPipe { + Self::BrokenPipe + } else { + Self::GeneralError + } + } +} + +impl From<&Error> for results::ExecutionExitCode { + fn from(error: &Error) -> Self { + Self::from(&*error.kind) + } +} + +impl From for Error +where + ErrorKind: From, +{ + fn from(convertible_to_kind: T) -> Self { + Self { + kind: Box::new(convertible_to_kind.into()), + fatal: false, + } + } +} + +impl Error { + /// Marks this error as fatal. + #[must_use] + pub const fn into_fatal(mut self) -> Self { + self.fatal = true; + self + } + + /// Returns whether or not this error is fatal. + pub const fn is_fatal(&self) -> bool { + self.fatal + } + + /// Returns a reference to the error kind. + pub fn kind(&self) -> &ErrorKind { + &self.kind + } + + /// Try to extract a reference to the underlying `std::io::Error`, if any. + pub fn as_io_error(&self) -> Option<&std::io::Error> { + match &*self.kind { + ErrorKind::IoError(io_err) => Some(io_err), + ErrorKind::BuiltinError(inner, _) => inner.as_io_error(), + _ => None, + } + } + + /// Converts this error into the appropriate control flow based on the shell's current state. + /// This centralizes the logic for determining how fatal errors should affect execution flow. + /// + /// # Arguments + /// + /// * `shell` - The shell instance, used to check interactive mode and script call stack. + pub(crate) fn to_control_flow( + &self, + shell: &Shell, + ) -> results::ExecutionControlFlow { + if self.is_fatal() && !shell.options().interactive { + results::ExecutionControlFlow::ExitShell + } else { + results::ExecutionControlFlow::Normal + } + } + + /// Converts this error into an execution result for the shell. + /// + /// # Arguments + /// + /// * `shell` - The shell instance, used to determine control flow. + pub(crate) fn into_result( + self, + shell: &Shell, + ) -> results::ExecutionResult { + let next_control_flow = self.to_control_flow(shell); + let exit_code = results::ExecutionExitCode::from(&self); + + results::ExecutionResult { + next_control_flow, + exit_code, + } + } +} + +/// Convenience function for returning an error for unimplemented functionality. +/// +/// # Arguments +/// +/// * `msg` - The message to include in the error +pub fn unimp(msg: &'static str) -> Result { + Err(ErrorKind::Unimplemented(msg).into()) +} diff --git a/userland/brush/source/brush-core/src/escape.rs b/userland/brush/source/brush-core/src/escape.rs new file mode 100644 index 0000000..01ff08d --- /dev/null +++ b/userland/brush/source/brush-core/src/escape.rs @@ -0,0 +1,630 @@ +//! String escaping utilities + +use std::borrow::Cow; + +use itertools::Itertools; + +use crate::{error, int_utils}; + +/// Escape expansion mode. +#[derive(Clone, Copy)] +pub enum EscapeExpansionMode { + /// echo builtin mode. + EchoBuiltin, + /// ANSI-C quotes. + AnsiCQuotes, +} + +/// Expands backslash escapes in the provided string. +/// +/// # Arguments +/// +/// * `s` - The string to expand. +/// * `mode` - The mode to use for expansion. +#[expect(clippy::too_many_lines)] +pub fn expand_backslash_escapes( + s: &str, + mode: EscapeExpansionMode, +) -> Result<(Vec, bool), error::Error> { + let mut result: Vec = Vec::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '\\' { + // Not a backslash, add and move on. + result.append(c.to_string().into_bytes().as_mut()); + continue; + } + + let Some(escape_cmd) = it.next() else { + // Trailing backslash. + result.push(b'\\'); + continue; + }; + + match escape_cmd { + 'a' => result.push(b'\x07'), + 'b' => result.push(b'\x08'), + 'c' => { + match mode { + EscapeExpansionMode::EchoBuiltin => { + // Stop all additional output! + return Ok((result, false)); + } + EscapeExpansionMode::AnsiCQuotes => { + if let Some(char_value) = it.next() { + // Special case backslash. If it's immediately followed by another + // backslash, then we consume both; if not, we still will use the + // backslash character as the one to apply the control transformation + // to. + if char_value == '\\' { + let orig_it = it.clone(); + if !matches!(it.next(), Some('\\')) { + // Didn't find another backslash; restore iterator. + it = orig_it; + } + } + + let mut bytes: Vec = if char_value.is_ascii_lowercase() { + char_value + .to_ascii_uppercase() + .to_string() + .bytes() + .collect() + } else { + char_value.to_string().bytes().collect() + }; + + if !bytes.is_empty() { + if bytes[0] == b'?' { + // We can't explain why this is the case, but it is. + bytes[0] = 0x7f; + } else { + bytes[0] &= 0x1f; + } + } + + result.append(bytes.as_mut()); + } else { + result.push(b'\\'); + result.push(b'c'); + } + } + } + } + 'e' | 'E' => result.push(b'\x1b'), + 'f' => result.push(b'\x0c'), + 'n' => result.push(b'\n'), + 'r' => result.push(b'\r'), + 't' => result.push(b'\t'), + 'v' => result.push(b'\x0b'), + '\\' => result.push(b'\\'), + '\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''), + '\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'), + '?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'), + '0' => { + // Consume 0-3 valid octal chars + let mut taken_so_far = 0; + let mut octal_chars: String = it + .take_while_ref(|c| { + if taken_so_far < 3 && matches!(*c, '0'..='7') { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if octal_chars.is_empty() { + octal_chars.push('0'); + } + + let value = int_utils::parse::(octal_chars.as_str(), 8)?; + result.push(value); + } + 'x' => { + // Consume 1-2 valid hex chars (or unlimited with braces in ANSI-C mode) + let mut hex_chars = String::new(); + let mut invalid_prefix = false; + let mut hexits_consumed = 0; + let mut start_brace_consumed = false; + + loop { + // Save the original in case we go too far and need to restore. + let orig_it = it.clone(); + + let Some(next_c) = it.next() else { + break; + }; + + if matches!(mode, EscapeExpansionMode::AnsiCQuotes) + && !start_brace_consumed + && next_c == '{' + { + start_brace_consumed = true; + } else if start_brace_consumed && next_c == '}' { + break; + } else if ((start_brace_consumed && !invalid_prefix) + || (!start_brace_consumed && hexits_consumed < 2)) + && next_c.is_ascii_hexdigit() + { + hex_chars.push(next_c); + hexits_consumed += 1; + } else if start_brace_consumed && hexits_consumed == 0 { + invalid_prefix = true; + } else { + // Went too far; restore iterator and break. + it = orig_it; + break; + } + } + + if hex_chars.is_empty() { + if start_brace_consumed { + result.push(0); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } else { + let value32 = int_utils::parse::(hex_chars.as_str(), 16)?; + let value8: u8 = (value32 & 0xFF) as u8; + result.push(value8); + } + } + 'u' => { + // Consume 1-4 hex digits + let mut taken_so_far = 0; + let hex_chars: String = it + .take_while_ref(|next_c| { + if taken_so_far < 4 && next_c.is_ascii_hexdigit() { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if hex_chars.is_empty() { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } else { + let value = int_utils::parse::(hex_chars.as_str(), 16)?; + if let Some(decoded) = char::from_u32(u32::from(value)) { + result.append(decoded.to_string().into_bytes().as_mut()); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } + } + 'U' => { + // Consume 1-8 hex digits + let mut taken_so_far = 0; + let hex_chars: String = it + .take_while_ref(|next_c| { + if taken_so_far < 8 && next_c.is_ascii_hexdigit() { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if hex_chars.is_empty() { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } else { + let value = int_utils::parse::(hex_chars.as_str(), 16)?; + if let Some(decoded) = char::from_u32(value) { + result.append(decoded.to_string().into_bytes().as_mut()); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } + } + first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => { + // We've already consumed the first octal digit. + let mut octal_chars = String::new(); + octal_chars.push(first_octal); + + // Consume up to 2 more valid octal chars + let mut taken_so_far = 1; + for next_c in it.take_while_ref(|next_c| { + if taken_so_far < 3 && matches!(next_c, '0'..='7') { + taken_so_far += 1; + true + } else { + false + } + }) { + octal_chars.push(next_c); + } + + let value = int_utils::parse::(octal_chars.as_str(), 8)?; + result.push(value); + } + unknown => { + // Not a valid escape sequence. + result.push(b'\\'); + result.append(unknown.to_string().into_bytes().as_mut()); + } + } + } + + // In ANSI-C quotes, we crop the result at the first NUL. + if matches!(mode, EscapeExpansionMode::AnsiCQuotes) { + if let Some(nul_index) = result.iter().position(|&b| b == 0) { + result.truncate(nul_index); + } + } + + Ok((result, true)) +} + +/// Quoting mode to use for escaping. +#[derive(Clone, Copy, Debug, Default)] +pub enum QuoteMode { + /// Single-quote. + #[default] + SingleQuote, + /// Double-quote. + DoubleQuote, + /// Backslash-escape. + BackslashEscape, +} + +/// Options for [`quote`]: how to quote a string so that, read back as a shell word, it +/// yields just that string, and which chars that are special only in some places (or to +/// some readers) to treat as special. +/// +/// Build one with [`QuoteOptions::builder`]; every option defaults to off (and the mode to +/// [`QuoteMode::SingleQuote`]), which is what [`quote_if_needed`] uses. +#[derive(Clone, Debug, Default, bon::Builder)] +pub struct QuoteOptions { + /// Whether to quote the input even if nothing in it needs quoting. + #[builder(default)] + pub(crate) always_quote: bool, + /// Preferred mode for quoting. It's upgraded to ANSI-C quoting (`$'...'`) if the input + /// has chars only that can express. + #[builder(default)] + pub(crate) preferred_mode: QuoteMode, + /// Whether to avoid ANSI-C quoting just for newlines, which otherwise require it. + #[builder(default)] + pub(crate) avoid_ansi_c_quoting_newline: bool, + /// Whether to leave a `#` starting the input unquoted. Bash does in some output that + /// isn't read back as shell input, e.g. tracing a `[[ -n ... ]]` test. + #[builder(default)] + pub(crate) leave_leading_hash: bool, + /// Whether to leave a `~` unquoted where it would be tilde-expanded. Bash does when + /// quoting a file name it completes, so that a `~user` completion still expands. + #[builder(default)] + pub(crate) leave_tilde: bool, + /// Whether to quote commas, which aren't special on their own. Bash's `printf %q` + /// does, so its output is safe even inside a brace expansion. + #[builder(default)] + pub(crate) quote_commas: bool, +} + +/// Quotes `s` as `options` say, so that, read back as a shell word, it yields just `s`. +/// +/// # Arguments +/// +/// * `s` - The string to quote. +/// * `options` - How to quote it. +pub fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> { + let use_ansi_c_quotes = s.contains(|c| { + needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n') + }); + + if use_ansi_c_quotes { + return ansi_c_quote(s).into(); + } + + let use_default_quotes = + !use_ansi_c_quotes && (options.always_quote || s.is_empty() || needs_quoting(s, options)); + + if !use_default_quotes { + return s.into(); + } + + match options.preferred_mode { + QuoteMode::BackslashEscape => backslash_escape(s, options), + QuoteMode::SingleQuote => single_quote(s), + QuoteMode::DoubleQuote => double_quote(s).into(), + } +} + +/// Escape the given string, forcing quoting. +/// +/// # Arguments +/// +/// * `s` - The string to escape. +/// * `mode` - The quoting mode to use. +pub fn force_quote(s: &str, mode: QuoteMode) -> String { + let options = QuoteOptions { + always_quote: true, + preferred_mode: mode, + ..Default::default() + }; + + quote(s, &options).to_string() +} + +/// Applies the given quoting mode to the provided string, only changing it if required. +/// +/// # Arguments +/// +/// * `s` - The string to escape. +/// * `mode` - The quoting mode to use. +pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> { + let options = QuoteOptions { + always_quote: false, + preferred_mode: mode, + ..Default::default() + }; + + quote(s, &options) +} + +fn backslash_escape<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> { + if s.is_empty() { + // An empty string must be represented as '' to be a valid shell word. + Cow::Owned("''".to_string()) + } else if !needs_quoting(s, options) { + Cow::Borrowed(s) + } else { + let mut output = String::with_capacity(s.len()); + for (i, c) in s.char_indices() { + if needs_escaping_at(s, i, c, options) { + output.push('\\'); + } + output.push(c); + } + Cow::Owned(output) + } +} + +/// Plain single-quoting, as bash's `sh_single_quote` does it. +/// +/// Unlike [`force_quote`] and [`quote_if_needed`], this never upgrades to ANSI-C quoting; +/// control characters are emitted literally. Use it where bash prints values verbatim, +/// such as `alias` output. +/// +/// # Arguments +/// +/// * `s` - The string to quote. +pub fn single_quote(s: &str) -> Cow<'_, str> { + // Special-case the empty string and a lone single quote; both are rendered + // without any empty quoted runs. + match s { + "" => return Cow::Borrowed("''"), + "'" => return Cow::Borrowed(r"\'"), + _ => (), + } + + let mut result = String::with_capacity(s.len() + 2); + + // Go through the string; put everything in single quotes except for + // the single quote character itself. It will get escaped outside + // all quoting, with the quoted runs on either side of it kept even + // when they're empty. + for (i, part) in s.split('\'').enumerate() { + if i > 0 { + result.push_str(r"\'"); + } + + result.push('\''); + result.push_str(part); + result.push('\''); + } + + Cow::Owned(result) +} + +fn double_quote(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + + result.push('"'); + + for c in s.chars() { + if matches!(c, '$' | '`' | '"' | '\\') { + result.push('\\'); + } + + result.push(c); + } + + result.push('"'); + + result +} + +fn ansi_c_quote(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + result.push_str("$'"); + + for c in s.chars() { + match c { + '\x07' => result.push_str("\\a"), + '\x08' => result.push_str("\\b"), + '\x1b' => result.push_str("\\E"), + '\x0c' => result.push_str("\\f"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\x0b' => result.push_str("\\v"), + '\\' => result.push_str("\\\\"), + '\'' => result.push_str("\\'"), + c if needs_ansi_c_quoting(c) => { + result.push_str(std::format!("\\{:03o}", c as u8).as_str()); + } + _ => result.push(c), + } + } + + result.push('\''); + + result +} + +/// Returns whether `s` has a char that needs quoting for `s` to be a shell word that +/// expands to just `s`, as `options` say. +fn needs_quoting(s: &str, options: &QuoteOptions) -> bool { + s.char_indices() + .any(|(i, c)| needs_escaping_at(s, i, c, options)) +} + +/// Returns whether `c`, at byte offset `i` in `s`, needs quoting: if it's always special, +/// or, like bash, it's a `#` starting the word (which would start a comment), or a `~` +/// starting it or following a `=` or `:` (where it would be tilde-expanded) -- unless +/// `options` say to leave those. +fn needs_escaping_at(s: &str, i: usize, c: char, options: &QuoteOptions) -> bool { + match c { + '#' => i == 0 && !options.leave_leading_hash, + '~' if options.leave_tilde => false, + ',' => options.quote_commas, + '~' => { + let prev = s.get(..i).and_then(|before| before.chars().next_back()); + matches!(prev, None | Some('=' | ':')) + } + _ => needs_escaping(c), + } +} + +// Returns whether or not the given character needs to be escaped (or quoted) if outside +// quotes. +const fn needs_escaping(c: char) -> bool { + matches!( + c, + '(' | ')' + | '[' + | ']' + | '{' + | '}' + | '$' + | '*' + | '?' + | '|' + | '&' + | ';' + | '<' + | '>' + | '`' + | '\\' + | '"' + | '!' + | '^' + | ' ' + | '\'' + ) +} + +const fn needs_ansi_c_quoting(c: char) -> bool { + c.is_ascii_control() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backslash_escape() { + assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a"); + assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b"); + assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "''"); + } + + #[test] + fn test_leading_hash_and_tilde_need_quoting() { + // Like bash: a `#` starting a word, and a `~` starting it or following `=` or `:`. + assert_eq!(quote_if_needed("#a", QuoteMode::BackslashEscape), r"\#a"); + assert_eq!(quote_if_needed("~/x", QuoteMode::BackslashEscape), r"\~/x"); + assert_eq!(quote_if_needed("a=~", QuoteMode::BackslashEscape), r"a=\~"); + assert_eq!(quote_if_needed("a:~", QuoteMode::BackslashEscape), r"a:\~"); + assert_eq!(quote_if_needed("#a", QuoteMode::SingleQuote), "'#a'"); + assert_eq!(quote_if_needed("~", QuoteMode::SingleQuote), "'~'"); + // Elsewhere, they're ordinary chars. + assert_eq!(quote_if_needed("a#", QuoteMode::SingleQuote), "a#"); + assert_eq!(quote_if_needed("a~", QuoteMode::SingleQuote), "a~"); + // A multibyte char before them doesn't change that. + assert_eq!(quote_if_needed("é=~", QuoteMode::BackslashEscape), r"é=\~"); + assert_eq!(quote_if_needed("é~", QuoteMode::BackslashEscape), "é~"); + assert_eq!(quote_if_needed("é#", QuoteMode::BackslashEscape), "é#"); + } + + #[test] + fn test_single_quote_escape() { + assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a"); + assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'"); + assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''"); + assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), r"\'"); + + // The quoted run on either side of an escaped quote is kept even when it's empty; + // only a string that is *nothing but* a single quote loses its surrounding quotes. + assert_eq!(quote_if_needed("a'b", QuoteMode::SingleQuote), r"'a'\''b'"); + assert_eq!( + quote_if_needed("a''b", QuoteMode::SingleQuote), + r"'a'\'''\''b'" + ); + assert_eq!(quote_if_needed("'a", QuoteMode::SingleQuote), r"''\''a'"); + assert_eq!(quote_if_needed("a'", QuoteMode::SingleQuote), r"'a'\'''"); + assert_eq!(quote_if_needed("''", QuoteMode::SingleQuote), r"''\'''\'''"); + } + + #[test] + fn test_single_quote_never_upgrades() { + // Unlike `quote`, this never reaches for ANSI-C quoting; control characters are + // emitted literally. + assert_eq!(single_quote("a\tb"), "'a\tb'"); + assert_eq!(single_quote("a\nb"), "'a\nb'"); + assert_eq!(single_quote("a"), "'a'"); + + // ...whereas `quote` does upgrade for the same input. + assert_eq!(quote_if_needed("a\tb", QuoteMode::SingleQuote), r"$'a\tb'"); + } + + fn assert_echo_expands_to(unexpanded: &str, expected: &str) { + assert_eq!( + String::from_utf8( + expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin) + .unwrap() + .0 + ) + .unwrap(), + expected + ); + } + + #[test] + fn test_echo_expansion() { + assert_echo_expands_to("a", "a"); + assert_echo_expands_to(r"\M", "\\M"); + assert_echo_expands_to(r"a\nb", "a\nb"); + assert_echo_expands_to(r"\a", "\x07"); + assert_echo_expands_to(r"\b", "\x08"); + assert_echo_expands_to(r"\e", "\x1b"); + assert_echo_expands_to(r"\f", "\x0c"); + assert_echo_expands_to(r"\n", "\n"); + assert_echo_expands_to(r"\r", "\r"); + assert_echo_expands_to(r"\t", "\t"); + assert_echo_expands_to(r"\v", "\x0b"); + assert_echo_expands_to(r"\\", "\\"); + assert_echo_expands_to(r"\'", "\\'"); + assert_echo_expands_to(r#"\""#, r#"\""#); + assert_echo_expands_to(r"\?", "\\?"); + assert_echo_expands_to(r"\0", "\0"); + assert_echo_expands_to(r"\00", "\0"); + assert_echo_expands_to(r"\000", "\0"); + assert_echo_expands_to(r"\081", "\081"); + assert_echo_expands_to(r"\0101", "A"); + assert_echo_expands_to(r"abc\", "abc\\"); + assert_echo_expands_to(r"\x41", "A"); + assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍"); + assert_echo_expands_to(r"\u2620", "☠"); + assert_echo_expands_to(r"\U0001f602", "😂"); + } +} diff --git a/userland/brush/source/brush-core/src/expansion.rs b/userland/brush/source/brush-core/src/expansion.rs new file mode 100644 index 0000000..0679489 --- /dev/null +++ b/userland/brush/source/brush-core/src/expansion.rs @@ -0,0 +1,2457 @@ +//! Word expansion utilities. + +use std::borrow::Cow; +use std::cmp::min; +use std::io::Write as _; + +use brush_parser::word::{ParameterTransformOp, SubstringMatchKind}; +use itertools::Itertools; + +use crate::ExecutionParameters; +use crate::arithmetic; +use crate::arithmetic::ExpandAndEvaluate; +use crate::braceexpansion; +use crate::commands; +use crate::env; +use crate::error; +use crate::escape; +use crate::extensions; +use crate::patterns; +use crate::prompt; +use crate::shell::Shell; +use crate::sys; +use crate::trace_categories; +use crate::variables::ShellValueUnsetType; +use crate::variables::ShellVariable; +use crate::variables::{self, ShellValue}; + +mod fieldsplit; + +/// Controls how the expander handles a backslash-escape sequence (`\X`) +/// when it appears outside any explicit quoting (single, double, ANSI-C). +/// Inside actual double-quoted text, the parser's own escape rules apply +/// and this setting is ignored. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum UnquotedBackslashHandling { + /// Default unquoted shell semantics: the backslash is consumed and `X` + /// is preserved literally. + #[default] + Strip, + /// The backslash and `X` are both preserved verbatim. Useful in pattern + /// contexts where the matcher will interpret the backslash itself. + Preserve, + /// Treat `\X` as if it were inside a double-quoted string: the backslash + /// is consumed only when `X` is one of the characters that double-quote + /// syntax treats as escapable; otherwise both characters are preserved. + /// A backslash followed by a literal newline is a line continuation + /// (both characters are consumed). Used by prompt-string expansion. + DoubleQuoted, +} + +/// Options to customize the behavior of the word expander. +pub(crate) struct ExpanderOptions { + /// Whether to perform tilde-expansion. + pub tilde_expand: bool, + /// Whether to perform brace-expansion. + pub brace_expand: bool, + /// Whether to perform command substitutions. If disabled, command substitutions + /// are replaced with an empty string. + pub execute_command_substitutions: bool, + /// Whether to perform pathname expansion (globbing). If disabled, glob patterns + /// are returned as literal strings. + pub pathname_expand: bool, + /// How to handle backslash-escape sequences encountered outside of any + /// explicit quoting. See [`UnquotedBackslashHandling`] for details. + pub unquoted_backslash_handling: UnquotedBackslashHandling, +} + +impl Default for ExpanderOptions { + fn default() -> Self { + Self { + tilde_expand: true, + brace_expand: true, + execute_command_substitutions: true, + pathname_expand: true, + unquoted_backslash_handling: UnquotedBackslashHandling::default(), + } + } +} + +/// The set of characters that, when preceded by a backslash inside a +/// double-quoted string, form a valid escape sequence (the backslash is +/// consumed). Any other character's backslash is preserved literally. +/// Newline is a special case: `\` is a line continuation and +/// consumes both characters, leaving nothing. +pub(crate) const DOUBLE_QUOTED_ESCAPE_CHARS: &[char] = &['\\', '$', '`', '"', '\n']; + +/// Describes what a parameter's expansion is a list of. Operators whose behavior depends +/// on that shape -- `${#x}` and `${x:offset:length}` today -- consult this to decide +/// whether they address characters or elements, via the methods below. +#[derive(Clone, Debug)] +enum ExpansionKind { + /// A single string; addressed by character. + String, + /// The elements of an indexed array, along with their subscripts in ascending order. + /// The subscripts matter because bash addresses these by subscript rather than by + /// position, which only makes a difference when the array is sparse. + IndexedArray(Vec), + /// Elements with no indices to address them by: an associative array, the positional + /// parameters, or a generated list of names. + ElementList, + /// A scalar that was written `${x[@]}` or `${x[*]}`. bash counts it as a single + /// element but slices it as a string. + ScalarAsArray, +} + +impl ExpansionKind { + /// Returns whether `${x:offset:length}` rejects a negative length, which bash does for + /// every real array form. + const fn is_array(&self) -> bool { + matches!(self, Self::IndexedArray(_) | Self::ElementList) + } + + /// Returns whether `${#x}` counts characters rather than elements. + const fn counts_chars(&self) -> bool { + matches!(self, Self::String) + } + + /// Returns whether `${x:offset:length}` addresses characters rather than elements. + /// bash slices a scalar as a string even when it was written `${x[@]}`, so this is not + /// the same question as [`Self::counts_chars`]. + const fn slices_chars(&self) -> bool { + matches!(self, Self::String | Self::ScalarAsArray) + } + + /// Returns the size of the space that `${x:offset:length}` offsets live in, which a + /// negative offset counts back from. For an indexed array that is subscript space, one + /// past the highest subscript; a sparse array's is wider than its element count. + fn offset_space(&self, len: i64) -> i64 { + match self { + Self::IndexedArray(subscripts) => subscripts.last().map_or(0, |&last| { + i64::try_from(last).unwrap_or(i64::MAX).saturating_add(1) + }), + _ => len, + } + } + + /// Returns the lowest offset that is out of range. bash validates the offset before it + /// ever looks at the length, and yields an empty slice for anything at or past this + /// point. + fn offset_limit(&self, len: i64) -> i64 { + match self { + // An offset of exactly the length is in range for a string, and for elements + // addressed by position; it just selects an empty slice. + Self::String | Self::ScalarAsArray | Self::ElementList => len + 1, + // An indexed array runs out just past its highest subscript. + Self::IndexedArray(_) => self.offset_space(len), + } + } + + /// Translates an in-range offset into a position in the element list. For an indexed + /// array bash selects the first element whose subscript is at least the offset; the + /// length then counts elements positionally from there. + fn offset_to_position(&self, offset: i64) -> i64 { + match self { + Self::IndexedArray(subscripts) => { + let position = + subscripts.partition_point(|&s| i64::try_from(s).is_ok_and(|s| s < offset)); + i64::try_from(position).unwrap_or(i64::MAX) + } + _ => offset, + } + } +} + +#[derive(Debug)] +struct Expansion { + fields: Vec, + concatenate: bool, + kind: ExpansionKind, + undefined: bool, +} + +impl Default for Expansion { + fn default() -> Self { + Self { + fields: vec![], + concatenate: true, + kind: ExpansionKind::String, + undefined: false, + } + } +} + +impl From for Expansion { + fn from(value: String) -> Self { + Self { + fields: vec![WordField::from(value)], + ..Self::default() + } + } +} + +impl From for Expansion { + fn from(piece: ExpansionPiece) -> Self { + Self { + fields: vec![WordField::from(piece)], + ..Self::default() + } + } +} + +impl Expansion { + fn classify(&self) -> ParameterState { + let non_empty = self + .fields + .iter() + .any(|field| field.0.iter().any(|piece| !piece.as_str().is_empty())); + + if self.undefined { + ParameterState::Undefined + } else if non_empty { + ParameterState::NonZeroLength + } else if self.fields.is_empty() { + // An array referenced via [@]/[*] that yields no fields (i.e. an empty + // array) is treated as unset by bash for the +/- conditional operators. + // This is distinct from a scalar holding an empty string, which has a + // single (empty) field and remains a defined empty string. + ParameterState::Undefined + } else { + ParameterState::DefinedEmptyString + } + } + + fn undefined() -> Self { + Self { + fields: vec![WordField::from(String::new())], + concatenate: true, + undefined: true, + kind: ExpansionKind::String, + } + } + + /// Returns the length that `${#x}` reports. + fn polymorphic_len(&self) -> usize { + self.len_in(self.kind.counts_chars()) + } + + /// Returns the length that `${x:offset:length}` addresses. + fn slice_len(&self) -> usize { + self.len_in(self.kind.slices_chars()) + } + + fn len_in(&self, chars: bool) -> usize { + if chars { + self.fields.iter().fold(0, |acc, field| acc + field.len()) + } else { + self.fields.len() + } + } + + fn polymorphic_subslice(&self, index: usize, end: usize) -> Self { + let len = end - index; + + // If we're addressing elements, then interpret `index` and `end` as indices + // into the elements. + if !self.kind.slices_chars() { + let actual_len = min(len, self.fields.len() - index); + let fields = self.fields[index..(index + actual_len)].to_vec(); + + Self { + fields, + concatenate: self.concatenate, + undefined: self.undefined, + kind: self.kind.clone(), + } + } else { + // Otherwise, interpret `index` and `end` as indices into the string contents. + let mut fields = vec![]; + + // Keep track of how far away the interesting data is from the current read offset. + let mut dist_to_slice = index; + // Keep track of how many characters are left to be copied. + let mut left = len; + + // Go through fields, copying the interesting parts. + for field in &self.fields { + let mut pieces = vec![]; + + for piece in &field.0 { + // Stop once we've extracted enough characters. + if left == 0 { + break; + } + + // Get the inner string of the piece, and figure out how many + // characters are in it; make sure to get the *character count* + // and not just call `.len()` to get the byte count. + let piece_str = piece.as_str(); + let piece_char_count = piece_str.chars().count(); + + // If the interesting data isn't even in this piece yet, then + // continue until we find it. + if dist_to_slice >= piece_char_count { + dist_to_slice -= piece_char_count; + continue; + } + + // Figure out how far into this piece we're interested in copying. + let desired_offset_into_this_piece = dist_to_slice; + // Figure out how many characters we're going to use from *this* piece. + let len_from_this_piece = + min(left, piece_char_count - desired_offset_into_this_piece); + + let slice = |s: &str| -> String { + s.chars() + .skip(desired_offset_into_this_piece) + .take(len_from_this_piece) + .collect() + }; + let new_piece = match piece { + ExpansionPiece::Unsplittable(s) => ExpansionPiece::Unsplittable(slice(s)), + ExpansionPiece::Splittable(s) => ExpansionPiece::Splittable(slice(s)), + ExpansionPiece::UnquotedLiteral(s) => { + ExpansionPiece::UnquotedLiteral(slice(s)) + } + ExpansionPiece::EmptyListElement { has_following } => { + ExpansionPiece::EmptyListElement { + has_following: *has_following, + } + } + }; + + pieces.push(new_piece); + + left -= len_from_this_piece; + dist_to_slice = 0; + } + + if !pieces.is_empty() { + fields.push(WordField(pieces)); + } + } + + Self { + fields, + concatenate: self.concatenate, + undefined: self.undefined, + kind: self.kind.clone(), + } + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct WordField(Vec); + +impl WordField { + pub const fn new() -> Self { + Self(vec![]) + } + + pub fn len(&self) -> usize { + self.0.iter().fold(0, |acc, piece| acc + piece.len()) + } +} + +impl From for String { + fn from(field: WordField) -> Self { + field.0.into_iter().map(Self::from).collect() + } +} + +impl From for patterns::Pattern { + fn from(value: WordField) -> Self { + let pieces: Vec<_> = value + .0 + .into_iter() + .map(patterns::PatternPiece::from) + .collect(); + + Self::from(pieces) + } +} + +impl From for WordField { + fn from(piece: ExpansionPiece) -> Self { + Self(vec![piece]) + } +} + +impl From for WordField { + fn from(value: String) -> Self { + Self(vec![ExpansionPiece::Splittable(value)]) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ExpansionPiece { + /// Quoted text: never field-split, never treated as a pattern. + Unsplittable(String), + /// The unquoted result of a parameter expansion, command substitution or arithmetic + /// expansion: field-split on IFS, then treated as a pattern. + Splittable(String), + /// Unquoted literal text of the word itself: never field-split (bash only splits the + /// results of expansions), but still treated as a pattern. + UnquotedLiteral(String), + /// An empty element of an unquoted array or positional-parameter expansion. + EmptyListElement { + /// Whether another element follows in the same list expansion. + has_following: bool, + }, +} + +impl From for String { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) + | ExpansionPiece::Splittable(s) + | ExpansionPiece::UnquotedLiteral(s) => s, + ExpansionPiece::EmptyListElement { .. } => Self::new(), + } + } +} + +impl From for patterns::PatternPiece { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) => Self::Literal(s), + ExpansionPiece::Splittable(s) | ExpansionPiece::UnquotedLiteral(s) => Self::Pattern(s), + ExpansionPiece::EmptyListElement { .. } => Self::Literal(String::new()), + } + } +} + +impl From for crate::regex::RegexPiece { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) => Self::Literal(s), + ExpansionPiece::Splittable(s) | ExpansionPiece::UnquotedLiteral(s) => Self::Pattern(s), + ExpansionPiece::EmptyListElement { .. } => Self::Literal(String::new()), + } + } +} + +impl ExpansionPiece { + const fn as_str(&self) -> &str { + match self { + Self::Unsplittable(s) | Self::Splittable(s) | Self::UnquotedLiteral(s) => s.as_str(), + Self::EmptyListElement { .. } => "", + } + } + + const fn len(&self) -> usize { + self.as_str().len() + } + + fn make_unsplittable(self) -> Self { + match self { + Self::Unsplittable(_) => self, + Self::Splittable(s) | Self::UnquotedLiteral(s) => Self::Unsplittable(s), + Self::EmptyListElement { .. } => Self::Unsplittable(String::new()), + } + } + + fn list_element(value: String, has_following: bool) -> Self { + if value.is_empty() { + Self::EmptyListElement { has_following } + } else { + Self::Splittable(value) + } + } +} + +enum ParameterState { + Undefined, + DefinedEmptyString, + NonZeroLength, +} + +/// Applies all basic expansion to the given word, yielding a pattern. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_pattern( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + // When expanding patterns, we do not want backslash removal to occur in unquoted + // contexts, as that would interfere with pattern syntax. + let options = ExpanderOptions { + // Bash performs no brace expansion in this context. + brace_expand: false, + unquoted_backslash_handling: UnquotedBackslashHandling::Preserve, + ..Default::default() + }; + let mut expander = WordExpander::new_from_options(shell, params, &options); + + expander.basic_expand_pattern(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word, yielding a regex. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_regex( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + + // Brace expansion does not appear to be used in regexes. + expander.disable_brace_expansion = true; + + expander.basic_expand_regex(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word (represented as a string). +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + // Bash performs no brace expansion in this context. + expander.disable_brace_expansion = true; + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Expands a heredoc body. +/// +/// Performs parameter expansion, command substitution, and arithmetic expansion +/// on the heredoc content while preserving literal quote characters. Unlike +/// [`basic_expand_word`], this treats `"` and `'` as literal characters rather +/// than quote delimiters. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The heredoc body to expand, as a string. +pub(crate) async fn basic_expand_heredoc_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + expander.heredoc_mode = true; + expander.disable_brace_expansion = true; + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word (represented as a string), +/// with custom expander options. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_word_with_options( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, + options: &ExpanderOptions, +) -> Result { + let mut expander = WordExpander::new_from_options(shell, params, options); + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; +/// then perform field splitting and pathname expansion on the result. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn full_expand_and_split_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result, error::Error> { + let mut expander = WordExpander::new(shell, params); + expander.full_expand_with_splitting(word_str.as_ref()).await +} + +/// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; +/// then perform field splitting and pathname expansion on the result. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +/// * `options` - Options to customize the behavior of the expander. +pub(crate) async fn full_expand_and_split_word_with_options( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, + options: &ExpanderOptions, +) -> Result, error::Error> { + let mut expander = WordExpander::new_from_options(shell, params, options); + expander.full_expand_with_splitting(word_str.as_ref()).await +} + +/// Expands a word in assignment context (enables tilde-after-colon expansion). +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_assignment_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + // Bash performs no brace expansion in this context. + expander.disable_brace_expansion = true; + expander.parser_options.tilde_expansion_after_colon = true; + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Assigns a value to a named parameter. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform the assignment. +/// * `params` - The execution parameters to use during the assignment. +/// * `name` - The name of the parameter to assign to. May be a variable name, or a more complex, +/// assignable parameter expression (e.g., an array element). +/// * `value` - The value to assign to the parameter. +pub async fn assign_to_named_parameter( + shell: &mut Shell, + params: &ExecutionParameters, + name: &str, + value: String, +) -> Result<(), error::Error> { + let parser_options = shell.parser_options(); + let mut expander = WordExpander::new(shell, params); + let parameter = brush_parser::word::parse_parameter(name, &parser_options)?; + expander.assign_to_parameter(¶meter, value).await +} + +struct WordExpander<'a, SE: extensions::ShellExtensions> { + /// The shell in which to perform expansion. + shell: &'a mut Shell, + /// The execution parameters to use during expansion. + params: &'a ExecutionParameters, + /// The parser options to use during expansion. + parser_options: brush_parser::ParserOptions, + /// Whether to disable brace expansion. + disable_brace_expansion: bool, + /// Whether to disable command substitutions. + disable_command_substitutions: bool, + /// Whether to disable pathname expansion (globbing). + disable_pathname_expansion: bool, + /// How to handle backslash-escape sequences encountered outside of any + /// explicit quoting. See [`UnquotedBackslashHandling`] for details. + unquoted_backslash_handling: UnquotedBackslashHandling, + /// Whether we are currently expanding inside a double-quoted context. + in_double_quotes: bool, + /// Whether to use heredoc expansion semantics (literal quotes, no brace expansion). + heredoc_mode: bool, +} + +impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { + pub const fn new(shell: &'a mut Shell, params: &'a ExecutionParameters) -> Self { + let parser_options = shell.parser_options(); + Self { + shell, + params, + parser_options, + disable_brace_expansion: false, + disable_command_substitutions: false, + disable_pathname_expansion: false, + unquoted_backslash_handling: UnquotedBackslashHandling::Strip, + in_double_quotes: false, + heredoc_mode: false, + } + } + + pub const fn new_from_options( + shell: &'a mut Shell, + params: &'a ExecutionParameters, + options: &ExpanderOptions, + ) -> Self { + let mut parser_options = shell.parser_options(); + + if !options.tilde_expand { + parser_options.tilde_expansion_at_word_start = false; + parser_options.tilde_expansion_after_colon = false; + } + + Self { + shell, + params, + parser_options, + disable_brace_expansion: !options.brace_expand, + disable_command_substitutions: !options.execute_command_substitutions, + disable_pathname_expansion: !options.pathname_expand, + unquoted_backslash_handling: options.unquoted_backslash_handling, + in_double_quotes: false, + heredoc_mode: false, + } + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion. + pub async fn basic_expand_to_str(&mut self, word: &str) -> Result { + let expansion = self.basic_expand(word).await?; + Ok(self.fields_to_string(expansion)) + } + + /// Join an [`Expansion`]'s fields into a single string for scalar + /// (non-field-splitting) contexts, following bash rules: `$*`/`${a[*]}` + /// (`concatenate=true`) join with the first character of IFS, while + /// `$@`/`${a[@]}` join with a space. A single field (the common case) is + /// unaffected. Without this, e.g. `x=${arr[*]}` under `IFS=:` would yield + /// `a b c` instead of bash's `a:b:c`. + fn fields_to_string(&self, expansion: Expansion) -> String { + let joiner = if expansion.concatenate { + self.shell.ifs_joiner() + } else { + String::from(' ') + }; + expansion + .fields + .into_iter() + .map(String::from) + .join(joiner.as_str()) + } + + async fn basic_expand_opt_pattern( + &mut self, + word: Option<&str>, + ) -> Result, error::Error> { + if let Some(word) = word { + let pattern = self + .basic_expand_pattern(word) + .await? + .set_extended_globbing(self.parser_options.enable_extended_globbing); + + Ok(Some(pattern)) + } else { + Ok(None) + } + } + + async fn basic_expand_pattern( + &mut self, + word: &str, + ) -> Result { + let expansion = self.basic_expand(word).await?; + + // TODO(IFS): Use IFS instead for separator? + #[expect(unstable_name_collisions)] + let pattern_pieces: Vec<_> = expansion + .fields + .into_iter() + .map(|field| { + field + .0 + .into_iter() + .map(patterns::PatternPiece::from) + .collect::>() + }) + .intersperse(vec![patterns::PatternPiece::Literal(String::from(" "))]) + .flatten() + .collect(); + + let pattern = patterns::Pattern::from(pattern_pieces); + + Ok(pattern) + } + + async fn basic_expand_regex( + &mut self, + word: &str, + ) -> Result { + let expansion = self.basic_expand(word).await?; + + // TODO(IFS): Use IFS instead for separator? + #[expect(unstable_name_collisions)] + let regex_pieces: Vec<_> = expansion + .fields + .into_iter() + .map(|field| { + field + .0 + .into_iter() + .map(crate::regex::RegexPiece::from) + .collect::>() + }) + .intersperse(vec![crate::regex::RegexPiece::Literal(String::from(" "))]) + .flatten() + .collect(); + + Ok(crate::regex::Regex::from(regex_pieces) + .set_case_insensitive(self.shell.options().case_insensitive_conditionals)) + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; + /// yield pieces that could be further processed. + async fn basic_expand(&mut self, word: &str) -> Result { + tracing::debug!(target: trace_categories::EXPANSION, "Basic expanding: '{word}'"); + + // Quick short circuit to avoid more expensive parsing. The characters below are + // understood to be the *only* ones indicative of *possible* expansion. There's + // still a possibility no expansion needs to be done, but that's okay; we'll still + // yield a correct result. + let expansion_chars: &[char] = if self.heredoc_mode { + // Heredoc bodies treat quotes as literal; only $, `, and \ trigger expansion. + &['$', '`', '\\'] + } else { + &['$', '`', '\\', '\'', '\"', '~', '{'] + }; + if !word.contains(expansion_chars) { + return Ok(Expansion::from(ExpansionPiece::UnquotedLiteral( + word.to_owned(), + ))); + } + + // Apply brace expansion first, before anything else (not applicable to heredoc bodies). + // Bash performs it before every other expansion and each result is a word of its + // own; that is what keeps the results separate when IFS lacks a space. + let Some(brace_words) = self.brace_expand_if_needed(word) else { + return self.expand_unbraced_word(word).await; + }; + + tracing::debug!(target: trace_categories::EXPANSION, " => brace expanded to {brace_words:?}"); + + // A single result (e.g. `{2..2}`) keeps the inner expansion's properties, such as + // the `concatenate` flag of "${arr[@]}". With several results each one contributes + // its fields and array semantics don't propagate. + if let [only] = brace_words.as_slice() { + return self.expand_unbraced_word(only).await; + } + + let mut fields = vec![]; + for brace_word in &brace_words { + fields.extend(self.expand_unbraced_word(brace_word).await?.fields); + } + + Ok(Expansion { + fields, + ..Expansion::default() + }) + } + + /// Apply tilde-expansion, parameter expansion, command substitution and arithmetic + /// expansion to a word that contains no brace expression (or is one result of one). + async fn expand_unbraced_word(&mut self, word: &str) -> Result { + // Heredoc mode only affects top-level parsing (literal quotes); recursive + // expansion of parameter words (e.g., ${var:-"default"}) uses normal semantics. + let pieces = if self.heredoc_mode { + self.heredoc_mode = false; + brush_parser::word::parse_heredoc(word, &self.parser_options)? + } else { + brush_parser::word::parse(word, &self.parser_options)? + }; + + let mut expansions = Vec::with_capacity(pieces.len()); + for piece in pieces { + expansions.push(self.expand_word_piece(piece.piece).await?); + } + + Ok(coalesce_expansions(expansions)) + } + + /// Expand a word used inside a parameter expansion (like the word in ${param:+word}). + /// When we're already inside double-quotes, we preserve literal backslashes and quotes + /// (except those escaped in ways valid in double-quotes) but still expand parameters, + /// command substitutions, and arithmetic. + async fn expand_parameter_word(&mut self, word: &str) -> Result { + let mut expansion = if self.in_double_quotes { + // When inside double-quotes, we need to parse the word with double-quote semantics. + // If the word already starts with a double-quote, we need to remove those quotes + // and expand what's inside with normal (non-double-quote) semantics. + if let Some(stripped) = word.strip_prefix('"') + && let Some(inner) = stripped.strip_suffix('"') + { + // Remove the surrounding double-quotes and expand the content normally + // This requires us to temporarily clear in_double_quotes so the inner + // content gets normal processing. + let previously_in_double_quotes = self.in_double_quotes; + self.in_double_quotes = false; + + // Now perform the expansion and make sure to restore the previous state, + // even if the expansion fails. + let result = self.basic_expand(inner).await; + self.in_double_quotes = previously_in_double_quotes; + + result? + } else { + // Not double-quoted - wrap in double-quotes to get double-quote parsing semantics + let wrapped = std::format!("\"{word}\""); + self.basic_expand(&wrapped).await? + } + } else { + // When not inside double-quotes, perform normal expansion with quote removal + self.basic_expand(word).await? + }; + + // The word becomes part of the parameter expansion's result, which bash + // field-splits as a whole: `${x:-a:b}` under `IFS=:` is two fields. + for field in &mut expansion.fields { + for piece in &mut field.0 { + if let ExpansionPiece::UnquotedLiteral(s) = piece { + *piece = ExpansionPiece::Splittable(std::mem::take(s)); + } + } + } + + Ok(expansion) + } + + /// Performs brace expansion on the word, yielding the resulting words, or `None` if + /// the word contains no brace expression. + fn brace_expand_if_needed(&self, word: &str) -> Option> { + // We perform a non-authoritative check to see if the string *may* contain braces + // to expand. There may be false positives, but must be no false negatives. + if self.disable_brace_expansion + || !self.shell.options().perform_brace_expansion + || !may_contain_braces_to_expand(word) + { + return None; + } + + let brace_expansion_pieces = + match brush_parser::word::parse_brace_expansions(word, &self.parser_options) { + Ok(Some(pieces)) => pieces, + Ok(None) => return None, + Err(err) => { + tracing::error!("failed to parse for brace expansion: {err:?}"); + return None; + } + }; + + tracing::debug!(target: trace_categories::EXPANSION, "Brace expansion pieces: {brace_expansion_pieces:?}"); + + // An alternative that expands to nothing yields no word at all (like any other + // unquoted empty expansion); `{a,}` is just `a`. + let words = braceexpansion::generate_and_combine_brace_expansions(brace_expansion_pieces) + .into_iter() + .filter(|s| !s.is_empty()) + .collect(); + + Some(words) + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; + /// then perform field splitting and pathname expansion. + pub async fn full_expand_with_splitting( + &mut self, + word: &str, + ) -> Result, error::Error> { + // Perform basic expansion first. + let basic_expansion = self.basic_expand(word).await?; + + // Then split. + let fields: Vec = fieldsplit::split_fields(&self.shell.ifs(), basic_expansion); + + // Now expand pathnames if necessary. This also unquotes as a side effect. + // We also know a length that the vector may be at minimally. + let mut result = Vec::with_capacity(fields.len()); + for field in fields { + if self.disable_pathname_expansion || self.shell.options().disable_filename_globbing { + result.push(String::from(field)); + } else { + result.extend(self.expand_pathnames_in_field(field)?); + } + } + + Ok(result) + } + + fn expand_pathnames_in_field(&self, field: WordField) -> Result, error::Error> { + let pattern = patterns::Pattern::from(field.clone()) + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options().case_insensitive_pathname_expansion); + + let options = patterns::FilenameExpansionOptions { + require_dot_in_pattern_to_match_dot_files: !self.shell.options().glob_matches_dotfiles, + }; + + // On error (e.g. malformed pattern), default to NoGlob so the field + // passes through as a literal rather than triggering failglob. + let expansion = pattern + .expand( + self.shell.working_dir(), + Some(&patterns::Pattern::accept_all_expand_filter), + &options, + ) + .unwrap_or_default(); + + if expansion.is_unmatched_glob() + && self.shell.options().fail_expansion_on_globs_without_match + { + let field_str = String::from(field); + return Err(error::ErrorKind::NoMatch(field_str).into()); + } + + let paths = expansion.into_paths(); + if paths.is_empty() { + if self.shell.options().expand_non_matching_patterns_to_null { + Ok(vec![]) + } else { + Ok(vec![String::from(field)]) + } + } else { + Ok(paths) + } + } + + #[allow(clippy::double_must_use)] + #[async_recursion::async_recursion] + async fn expand_word_piece( + &mut self, + word_piece: brush_parser::word::WordPiece, + ) -> Result { + let expansion: Expansion = match word_piece { + brush_parser::word::WordPiece::Text(s) => { + Expansion::from(ExpansionPiece::UnquotedLiteral(s)) + } + brush_parser::word::WordPiece::SingleQuotedText(s) => { + Expansion::from(ExpansionPiece::Unsplittable(s)) + } + brush_parser::word::WordPiece::AnsiCQuotedText(s) => { + let (expanded, _) = escape::expand_backslash_escapes( + s.as_str(), + escape::EscapeExpansionMode::AnsiCQuotes, + )?; + Expansion::from(ExpansionPiece::Unsplittable( + String::from_utf8_lossy(expanded.as_slice()).into_owned(), + )) + } + brush_parser::word::WordPiece::DoubleQuotedSequence(pieces) + | brush_parser::word::WordPiece::GettextDoubleQuotedSequence(pieces) => { + let pieces_is_empty = pieces.is_empty(); + + // Save the previous state and set the flag + let previously_in_double_quotes = self.in_double_quotes; + self.in_double_quotes = true; + + // Process pieces; don't inspect the result yet, so we can make + // sure we restore the previous value of the 'in_double_quotes' flag. + let result = self.process_double_quoted_pieces(pieces).await; + + // Restore the previous state + self.in_double_quotes = previously_in_double_quotes; + + // Now we can inspect the result. + let mut fields = result?; + + // If there were no pieces, then make sure we yield a single field containing an + // empty, unsplittable string. + if pieces_is_empty { + fields.push(WordField::from(ExpansionPiece::Unsplittable(String::new()))); + } + + Expansion { + fields, + concatenate: false, + undefined: false, + kind: ExpansionKind::String, + } + } + brush_parser::word::WordPiece::TildeExpansion(tilde_expr) => { + Expansion::from(ExpansionPiece::Unsplittable( + self.expand_tilde_expression(&tilde_expr)?.to_string(), + )) + } + brush_parser::word::WordPiece::ParameterExpansion(p) => { + self.expand_parameter_expr(p).await? + } + brush_parser::word::WordPiece::BackquotedCommandSubstitution(s) + | brush_parser::word::WordPiece::CommandSubstitution(s) => { + let mut cmd_output = if !self.disable_command_substitutions { + commands::invoke_command_in_subshell_and_get_output(self.shell, self.params, s) + .await? + } else { + String::new() + }; + + // Strips null bytes from command substitution output for compatibility. + if cmd_output.contains('\0') { + writeln!( + self.params.stderr(self.shell), + "warning: command substitution: ignored null byte in input", + )?; + cmd_output.retain(|c| c != '\0'); + } + + // We trim trailing newlines, per spec. + let trimmed_len = cmd_output.trim_end_matches('\n').len(); + cmd_output.truncate(trimmed_len); + + Expansion::from(ExpansionPiece::Splittable(cmd_output)) + } + brush_parser::word::WordPiece::EscapeSequence(s) => { + let Some(escaped) = s.strip_prefix('\\') else { + // We don't ever expect this case, as it breaks our invariant--but + // we handle it to avoid panicking. + return Ok(Expansion::from(ExpansionPiece::Unsplittable(s))); + }; + + // Inside actual double-quoted text, the parser only emits an + // EscapeSequence for `\X` where X is double-quote-escapable; + // we always strip the backslash there. + if self.in_double_quotes { + return Ok(Expansion::from(ExpansionPiece::Unsplittable( + escaped.to_owned(), + ))); + } + + match self.unquoted_backslash_handling { + UnquotedBackslashHandling::Strip => { + // Default unquoted semantics: consume the backslash. + Expansion::from(ExpansionPiece::Unsplittable(escaped.to_owned())) + } + UnquotedBackslashHandling::Preserve => { + // Preserve `\X` verbatim (e.g., for pattern syntax). + Expansion::from(ExpansionPiece::Splittable(s)) + } + UnquotedBackslashHandling::DoubleQuoted => { + // `\` is a line continuation: both characters + // are consumed. + if escaped.starts_with('\n') { + Expansion::from(ExpansionPiece::Unsplittable(String::new())) + } else if escaped.starts_with(DOUBLE_QUOTED_ESCAPE_CHARS) { + // Consume the backslash; preserve the next char. + Expansion::from(ExpansionPiece::Unsplittable(escaped.to_owned())) + } else { + // Preserve both characters. + Expansion::from(ExpansionPiece::Unsplittable(s)) + } + } + } + } + brush_parser::word::WordPiece::ArithmeticExpression(e) => Expansion::from( + ExpansionPiece::Splittable(self.expand_arithmetic_expr(e).await?), + ), + }; + + Ok(expansion) + } + + fn expand_tilde_expression( + &self, + tilde_expr: &brush_parser::word::TildeExpr, + ) -> Result, error::Error> { + match tilde_expr { + brush_parser::word::TildeExpr::Home => { + if let Some(home_dir) = self.shell.home_dir() { + Ok(Cow::Owned(home_dir.to_string_lossy().to_string())) + } else { + Err(error::ErrorKind::TildeWithoutValidHome.into()) + } + } + brush_parser::word::TildeExpr::UserHome(username) => { + Ok(sys::users::get_user_home_dir(username).map_or_else( + || Cow::Owned(std::format!("~{username}")), + |p| Cow::Owned(p.to_string_lossy().to_string()), + )) + } + brush_parser::word::TildeExpr::WorkingDir => { + Ok(self.shell.working_dir().to_string_lossy()) + } + brush_parser::word::TildeExpr::OldWorkingDir => { + // OLDPWD may be declared without a value (e.g. at startup); only + // expand `~-` when it actually has one. + Ok(self + .shell + .env_var("OLDPWD") + .filter(|var| var.value().is_set()) + .map_or(Cow::Borrowed("~-"), |var| { + var.value().to_cow_str(self.shell) + })) + } + brush_parser::word::TildeExpr::NthDirFromBottomOfDirStack { n } => { + let dir_stack_count = self.shell.directory_stack().len(); + + if let Some(dir) = self.shell.directory_stack().get(*n) { + Ok(dir.to_string_lossy()) + } else if *n == dir_stack_count { + Ok(self.shell.working_dir().to_string_lossy()) + } else { + Ok(Cow::Owned(std::format!("~-{n}"))) + } + } + brush_parser::word::TildeExpr::NthDirFromTopOfDirStack { n, plus_used } => { + if *n == 0 { + return Ok(self.shell.working_dir().to_string_lossy()); + } + + let dir_stack_count = self.shell.directory_stack().len(); + if dir_stack_count >= *n + && let Some(dir) = self.shell.directory_stack().get(dir_stack_count - *n) + { + return Ok(dir.to_string_lossy()); + } + + let plus_or_nothing = if *plus_used { "+" } else { "" }; + Ok(Cow::Owned(std::format!("~{plus_or_nothing}{n}"))) + } + } + } + + /// Helper function to process pieces within a double-quoted sequence. + /// This ensures proper handling of concatenation and field building. + async fn process_double_quoted_pieces( + &mut self, + pieces: Vec, + ) -> Result, error::Error> { + let mut fields: Vec = vec![]; + let concatenation_joiner = self.shell.ifs_joiner(); + + for piece in pieces { + let Expansion { + fields: this_fields, + concatenate, + .. + } = self.expand_word_piece(piece.piece).await?; + + let fields_to_append = if concatenate { + #[expect(unstable_name_collisions)] + let mut concatenated: Vec = this_fields + .into_iter() + .map(|WordField(pieces)| { + pieces + .into_iter() + .map(|piece| piece.make_unsplittable()) + .collect() + }) + .intersperse(vec![ExpansionPiece::Unsplittable( + concatenation_joiner.clone(), + )]) + .flatten() + .collect(); + + // If there were no pieces, make sure there's an empty string after + // concatenation. + if concatenated.is_empty() { + concatenated.push(ExpansionPiece::Splittable(String::new())); + } + + vec![WordField(concatenated)] + } else { + this_fields + }; + + for (i, WordField(next_pieces)) in fields_to_append.into_iter().enumerate() { + // Flip to unsplittable. + let mut next_pieces: Vec<_> = next_pieces + .into_iter() + .map(|piece| piece.make_unsplittable()) + .collect(); + + if i == 0 + && let Some(WordField(last_pieces)) = fields.last_mut() + { + last_pieces.append(&mut next_pieces); + continue; + } + + fields.push(WordField(next_pieces)); + } + } + + Ok(fields) + } + + #[expect(clippy::too_many_lines)] + async fn expand_parameter_expr( + &mut self, + expr: brush_parser::word::ParameterExpr, + ) -> Result { + #[expect(clippy::cast_possible_truncation)] + match expr { + brush_parser::word::ParameterExpr::Parameter { + parameter, + indirect, + } => self.expand_parameter(¶meter, indirect).await, + brush_parser::word::ParameterExpr::UseDefaultValues { + parameter, + indirect, + test_type, + default_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let default_value = default_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => Ok(self.expand_parameter_word(default_value).await?), + } + } + brush_parser::word::ParameterExpr::AssignDefaultValues { + parameter, + indirect, + test_type, + default_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let default_value = default_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => { + let expanded_default = self.expand_parameter_word(default_value).await?; + let expanded_default_value = self.fields_to_string(expanded_default); + self.assign_to_parameter(¶meter, expanded_default_value.clone()) + .await?; + Ok(Expansion::from(expanded_default_value)) + } + } + } + brush_parser::word::ParameterExpr::IndicateErrorIfNullOrUnset { + parameter, + indirect, + test_type, + error_message, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let error_message = error_message.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => { + let result = self.basic_expand_to_str(error_message).await?; + let err: error::Error = + error::ErrorKind::CheckedExpansionError(result).into(); + + // Expansion errors are fatal per POSIX spec + Err(err.into_fatal()) + } + } + } + brush_parser::word::ParameterExpr::UseAlternativeValue { + parameter, + indirect, + test_type, + alternative_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let alternative_value = alternative_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(self.expand_parameter_word(alternative_value).await?), + // "${arr[@]+word}" or "${@+word}" with no elements expands to nothing + // (zero words), just as "${arr[@]}" and "$@" do. + _ if !expanded_parameter.concatenate + && expanded_parameter.fields.is_empty() => + { + Ok(expanded_parameter) + } + _ => Ok(Expansion::from(String::new())), + } + } + brush_parser::word::ParameterExpr::ParameterLength { + parameter, + indirect, + } => { + // In bash, ${#arr[i]} returns 0 for unset elements of a + // declared array even with `set -u`. But ${#unset_var} still + // errors. Allow unset only for array element/all-indices + // access on variables that exist. + let allow_unset = match ¶meter { + brush_parser::word::Parameter::NamedWithIndex { name, .. } + | brush_parser::word::Parameter::NamedWithAllIndices { name, .. } => { + self.shell.env().get(name).is_some() + } + _ => false, + }; + let expansion = if allow_unset { + self.expand_parameter_allowing_unset(¶meter, indirect) + .await? + } else { + self.expand_parameter(¶meter, indirect).await? + }; + Ok(Expansion::from(expansion.polymorphic_len().to_string())) + } + brush_parser::word::ParameterExpr::RemoveSmallestSuffixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + transform_expansion(expanded_parameter, async |s| { + patterns::remove_smallest_matching_suffix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveLargestSuffixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + transform_expansion(expanded_parameter, async |s| { + patterns::remove_largest_matching_suffix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveSmallestPrefixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + patterns::remove_smallest_matching_prefix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveLargestPrefixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + patterns::remove_largest_matching_prefix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::Substring { + parameter, + indirect, + offset, + length, + } => { + let mut expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + + // If this is ${@:...} then make sure $0 is in the array being sliced. + if matches!( + parameter, + brush_parser::word::Parameter::Special( + brush_parser::word::SpecialParameter::AllPositionalParameters { + concatenate: _ + }, + ) + ) { + let shell_name = self.shell.current_shell_name().unwrap_or_else(|| "".into()); + + expanded_parameter.fields.insert( + 0, + WordField::from(ExpansionPiece::Splittable(shell_name.to_string())), + ); + } + + #[expect(clippy::cast_possible_wrap)] + let expanded_parameter_len = expanded_parameter.slice_len() as i64; + + // bash has nothing to slice when the parameter is unset, or when it + // expanded to no fields at all -- an empty array, say -- and yields an + // empty slice without validating the offset or the length. + if matches!(expanded_parameter.classify(), ParameterState::Undefined) { + return Ok(expanded_parameter.polymorphic_subslice(0, 0)); + } + + // A negative offset counts back from the end of the offset space: -1 + // addresses the last character or position, or -- for an indexed array -- + // the highest subscript. + let mut expanded_offset = offset.eval(self.shell, self.params, false).await?; + if expanded_offset < 0 { + expanded_offset += expanded_parameter.kind.offset_space(expanded_parameter_len); + } + + // bash validates the offset before it looks at the length: an offset that is + // out of range -- still negative after counting back from the end, or at or + // past the end -- yields an empty slice, whatever the length says. + if expanded_offset < 0 + || expanded_offset + >= expanded_parameter.kind.offset_limit(expanded_parameter_len) + { + return Ok(expanded_parameter.polymorphic_subslice(0, 0)); + } + + // Offsets are validated in the space they were written in; from here on we + // need a position in the element list. + let expanded_offset = expanded_parameter.kind.offset_to_position(expanded_offset); + + let end_offset = if let Some(length) = length { + let expanded_length = length.eval(self.shell, self.params, false).await?; + + if expanded_length < 0 { + // For a string, a negative length says where the slice *ends*, + // counting back from the end of the value, instead of how long it + // is: `${s:2:-2}` on `abcdefgh` ends at index 6, giving `cdef`. bash + // has no such reading for an array, and rejects a negative length + // there outright. + let end_offset = expanded_parameter_len + expanded_length; + if expanded_parameter.kind.is_array() || end_offset < expanded_offset { + return Err(error::ErrorKind::CheckedExpansionError(format!( + "{expanded_length}: substring expression < 0" + )) + .into()); + } + end_offset + } else { + min( + expanded_offset.saturating_add(expanded_length), + expanded_parameter_len, + ) + } + } else { + expanded_parameter_len + }; + + #[expect(clippy::cast_sign_loss)] + Ok(expanded_parameter + .polymorphic_subslice(expanded_offset as usize, end_offset as usize)) + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op: ParameterTransformOp::ToAttributeFlags, + } => { + if let (_, _, Some(var)) = self + .try_resolve_parameter_to_variable(¶meter, indirect) + .await? + { + Ok(var.attribute_flags(self.shell).into()) + } else { + Ok(String::new().into()) + } + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op: ParameterTransformOp::ToAssignmentLogic, + } => { + if let (Some(name), index, Some(var)) = self + .try_resolve_parameter_to_variable(¶meter, indirect) + .await? + { + let assignable_value_str = var + .value() + .to_assignable_str(index.as_deref(), self.shell)?; + + let mut attr_str = var.attribute_flags(self.shell); + if attr_str.is_empty() { + attr_str.push('-'); + } + + match var.value() { + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + // TODO(dynamic): confirm this + | ShellValue::Dynamic { .. } => { + let equals_or_nothing = if assignable_value_str.is_empty() { + "" + } else { + "=" + }; + + Ok(std::format!( + "declare -{attr_str} {name}{equals_or_nothing}{assignable_value_str}" + ) + .into()) + } + ShellValue::String(_) => { + Ok(std::format!("{name}={assignable_value_str}").into()) + } + ShellValue::Unset(_) => { + Ok(std::format!("declare -{attr_str} {name}").into()) + } + } + } else { + Ok(String::new().into()) + } + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let came_from_undefined = expanded_parameter.undefined; + + // + // For typing reasons (issues with FnMut and our mut use of self), we can't use + // transform_expansion. Instead, we inline its logic here. + // + + let mut transformed_fields = vec![]; + for field in expanded_parameter.fields { + let s = String::from(field); + let transformed = self + .apply_transform_to(&op, &s, came_from_undefined) + .await?; + transformed_fields.push(WordField::from(transformed)); + } + + Ok(Expansion { + fields: transformed_fields, + concatenate: expanded_parameter.concatenate, + kind: expanded_parameter.kind, + undefined: expanded_parameter.undefined, + }) + } + brush_parser::word::ParameterExpr::UppercaseFirstChar { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_first_char(s, expanded_pattern.as_ref(), |c| c.to_uppercase()) + }) + .await + } + brush_parser::word::ParameterExpr::UppercasePattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_string(s.as_str(), expanded_pattern.as_ref(), |str| { + str.to_uppercase() + }) + }) + .await + } + brush_parser::word::ParameterExpr::LowercaseFirstChar { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_first_char(s, expanded_pattern.as_ref(), |c| c.to_lowercase()) + }) + .await + } + brush_parser::word::ParameterExpr::LowercasePattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_string(s.as_str(), expanded_pattern.as_ref(), |str| { + str.to_lowercase() + }) + }) + .await + } + brush_parser::word::ParameterExpr::ReplaceSubstring { + parameter, + indirect, + pattern, + replacement, + match_kind, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self + .basic_expand_pattern(pattern.as_str()) + .await? + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options().case_insensitive_conditionals); + + // If no replacement was provided, then we replace with an empty string. + let replacement = replacement.unwrap_or(String::new()); + let expanded_replacement = self.basic_expand_to_str(&replacement).await?; + + let regex = expanded_pattern.to_regex( + matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix), + matches!(match_kind, brush_parser::word::SubstringMatchKind::Suffix), + )?; + + transform_expansion(expanded_parameter, async |s| { + Ok(Self::replace_substring( + s.as_str(), + ®ex, + expanded_replacement.as_str(), + &match_kind, + )) + }) + .await + } + brush_parser::word::ParameterExpr::VariableNames { + prefix, + concatenate, + } => { + if prefix.is_empty() { + Ok(Expansion::from(String::new())) + } else { + let matching_names = self + .shell + .env() + .iter() + .filter_map(|(name, _)| { + if name.starts_with(prefix.as_str()) { + Some(name.to_owned()) + } else { + None + } + }) + .sorted(); + + Ok(Expansion { + fields: matching_names + .into_iter() + .map(|name| WordField(vec![ExpansionPiece::Splittable(name)])) + .collect(), + concatenate, + kind: ExpansionKind::ElementList, + undefined: false, + }) + } + } + brush_parser::word::ParameterExpr::MemberKeys { + variable_name, + concatenate, + } => { + let keys = if let Some((_, var)) = self.shell.env().get(variable_name) { + var.value().element_keys(self.shell) + } else { + vec![] + }; + + Ok(Expansion { + fields: keys + .into_iter() + .map(|key| WordField(vec![ExpansionPiece::Splittable(key)])) + .collect(), + concatenate, + kind: ExpansionKind::ElementList, + undefined: false, + }) + } + } + } + + async fn assign_to_parameter>( + &mut self, + parameter: &brush_parser::word::Parameter, + value: T, + ) -> Result<(), error::Error> { + let (variable_name, index) = match parameter { + brush_parser::word::Parameter::Named(name) => (name, None), + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + let is_set_assoc_array = if let Some((_, var)) = self.shell.env().get(name) { + matches!( + var.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + false + }; + + let index_to_use = self + .expand_array_index(index.as_str(), is_set_assoc_array) + .await?; + (name, Some(index_to_use)) + } + brush_parser::word::Parameter::Positional(_) + | brush_parser::word::Parameter::NamedWithAllIndices { + name: _, + concatenate: _, + } + | brush_parser::word::Parameter::Special(_) => { + return Err(error::ErrorKind::CannotAssignToSpecialParameter.into()); + } + }; + + let value = value.into(); + + if let Some(index) = index { + self.shell.env_mut().update_or_add_array_element( + variable_name, + index, + value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + } else { + self.shell.env_mut().update_or_add( + variable_name, + variables::ShellValueLiteral::Scalar(value), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + } + } + + async fn try_resolve_parameter_to_variable( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result<(Option, Option, Option), error::Error> { + if !indirect { + Ok(self.try_resolve_parameter_to_variable_without_indirect(parameter)) + } else { + let expansion = self.expand_parameter(parameter, false).await?; + let parameter_str: String = self.fields_to_string(expansion); + let inner_parameter = + brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?; + Ok(self.try_resolve_parameter_to_variable_without_indirect(&inner_parameter)) + } + } + + fn try_resolve_parameter_to_variable_without_indirect( + &self, + parameter: &brush_parser::word::Parameter, + ) -> (Option, Option, Option) { + let (name, index) = match parameter { + brush_parser::word::Parameter::Positional(_) + | brush_parser::word::Parameter::Special(_) => (None, None), + brush_parser::word::Parameter::Named(name) => (Some(name.to_owned()), Some("0".into())), + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + (Some(name.to_owned()), Some(index.to_owned())) + } + brush_parser::word::Parameter::NamedWithAllIndices { + name, + concatenate: _concatenate, + } => (Some(name.to_owned()), None), + }; + + let var = name + .as_ref() + .and_then(|name| self.shell.env().get(name).map(|(_, var)| var.clone())); + + (name, index, var) + } + + fn undefined_expansion( + &self, + parameter: &brush_parser::word::Parameter, + allow_unset_vars: bool, + ) -> Result { + if allow_unset_vars || !self.shell.options().treat_unset_variables_as_error { + Ok(Expansion::undefined()) + } else { + let err: error::Error = + error::ErrorKind::ExpandingUnsetVariable(parameter.to_string()).into(); + Err(err.into_fatal()) + } + } + + async fn expand_parameter( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result { + self.expand_parameter_internal(parameter, indirect, false) + .await + } + + async fn expand_parameter_allowing_unset( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result { + self.expand_parameter_internal(parameter, indirect, true) + .await + } + + async fn expand_parameter_internal( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + allow_unset_vars: bool, + ) -> Result { + let expansion = self + .expand_parameter_without_indirect(parameter, allow_unset_vars) + .await?; + if !indirect { + Ok(expansion) + } else { + let parameter_str: String = self.fields_to_string(expansion); + let inner_parameter = + brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?; + + self.expand_parameter_without_indirect(&inner_parameter, allow_unset_vars) + .await + } + } + + async fn expand_parameter_without_indirect( + &mut self, + parameter: &brush_parser::word::Parameter, + allow_unset_vars: bool, + ) -> Result { + match parameter { + brush_parser::word::Parameter::Positional(p) => { + if *p == 0 { + Ok(self + .expand_special_parameter(&brush_parser::word::SpecialParameter::ShellName)) + } else if let Some(parameter) = + self.shell.current_shell_args().get((p - 1) as usize) + { + Ok(Expansion::from(parameter.to_owned())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::Special(s) => Ok(self.expand_special_parameter(s)), + brush_parser::word::Parameter::Named(n) => { + if !env::valid_variable_name(n.as_str()) { + Err(error::ErrorKind::BadSubstitution(n.clone()).into()) + } else if let Some((_, var)) = self.shell.env().get(n) { + if matches!(var.value(), ShellValue::Unset(_)) { + self.undefined_expansion(parameter, allow_unset_vars) + } else { + let value = var.value().try_get_cow_str(self.shell); + if let Some(value) = value { + Ok(Expansion::from(value.to_string())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + // First check to see if it's an associative array. + let is_set_assoc_array = if let Some((_, var)) = self.shell.env().get(name) { + matches!( + var.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + false + }; + + // Figure out which index to use. + let index_to_use = self + .expand_array_index(index.as_str(), is_set_assoc_array) + .await?; + + // Index into the array. + if let Some((_, var)) = self.shell.env().get(name) + && let Ok(Some(value)) = var.value().get_at(index_to_use.as_str(), self.shell) + { + Ok(Expansion::from(value.to_string())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::NamedWithAllIndices { name, concatenate } => { + if let Some((_, var)) = self.shell.env().get(name) { + // Resolve a dynamic value once, so the kind and the element values + // can't disagree: a getter like RANDOM's changes on every read. + let resolved; + let value = match var.value() { + ShellValue::Dynamic { getter, .. } => { + resolved = getter(self.shell); + &resolved + } + other => other, + }; + let fields = + list_element_fields(value.element_values(self.shell), value.is_array()); + + Ok(Expansion { + fields, + concatenate: *concatenate, + kind: match value { + // Slicing addresses an indexed array by subscript, so it needs + // the subscripts themselves and not just the element count. + ShellValue::IndexedArray(array) => { + ExpansionKind::IndexedArray(array.keys().copied().collect()) + } + // A scalar written `${x[@]}`: bash slices it as a string. + ShellValue::String(_) => ExpansionKind::ScalarAsArray, + // Associative arrays land here. bash documents substring + // expansion of one as producing undefined results -- it + // selects from an offset of 1 rather than 0 -- so we + // deliberately address them like any other element list. + _ => ExpansionKind::ElementList, + }, + undefined: false, + }) + } else { + Ok(Expansion { + fields: vec![], + concatenate: *concatenate, + kind: ExpansionKind::ElementList, + undefined: false, + }) + } + } + } + } + + async fn expand_array_index( + &mut self, + index: &str, + for_set_associative_array: bool, + ) -> Result { + let index_to_use = if for_set_associative_array { + self.basic_expand_to_str(index).await? + } else { + arithmetic::expand_and_eval(self.shell, self.params, index, false) + .await? + .to_string() + }; + + Ok(index_to_use) + } + + fn expand_special_parameter( + &self, + parameter: &brush_parser::word::SpecialParameter, + ) -> Expansion { + match parameter { + brush_parser::word::SpecialParameter::AllPositionalParameters { concatenate } => { + Expansion { + fields: list_element_fields(self.shell.current_shell_args().to_vec(), true), + concatenate: *concatenate, + kind: ExpansionKind::ElementList, + undefined: false, + } + } + brush_parser::word::SpecialParameter::PositionalParameterCount => { + Expansion::from(self.shell.current_shell_args().len().to_string()) + } + brush_parser::word::SpecialParameter::LastExitStatus => { + Expansion::from(self.shell.last_exit_status().to_string()) + } + brush_parser::word::SpecialParameter::CurrentOptionFlags => { + Expansion::from(self.shell.options().option_flags()) + } + brush_parser::word::SpecialParameter::ProcessId => { + Expansion::from(std::process::id().to_string()) + } + brush_parser::word::SpecialParameter::LastBackgroundProcessId => { + if let Some(job) = self.shell.jobs().current_job() + && let Some(pid) = job.representative_pid() + { + return Expansion::from(pid.to_string()); + } + Expansion::from(String::new()) + } + brush_parser::word::SpecialParameter::ShellName => Expansion::from( + self.shell + .current_shell_name() + .map_or_else(String::new, |name| name.to_string()), + ), + } + } + + async fn expand_arithmetic_expr( + &mut self, + expr: brush_parser::ast::UnexpandedArithmeticExpr, + ) -> Result { + let value = expr.eval(self.shell, self.params, false).await?; + Ok(value.to_string()) + } + + fn pattern_to_first_char( + s: String, + pattern: Option<&patterns::Pattern>, + transform: F, + ) -> Result + where + F: Fn(char) -> I, + I: Iterator, + { + if let Some(first_char) = s.chars().next() { + let applicable = if let Some(pattern) = pattern { + pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())? + } else { + true + }; + + if applicable { + if let Some(upper_char) = transform(first_char).next() { + let mut result = upper_char.to_string(); + result.extend(s.chars().skip(1)); + return Ok(result); + } + } + } + + Ok(s) + } + + fn pattern_to_string( + s: &str, + pattern: Option<&patterns::Pattern>, + transform: F, + ) -> Result + where + F: Fn(&str) -> String, + { + if let Some(pattern) = pattern { + if !pattern.is_empty() { + let regex = pattern.to_regex(false, false)?; + let result = regex + .replace_all(s.as_ref(), |caps: &fancy_regex::Captures<'_, str>| { + transform(&caps[0]) + }); + Ok(result.into_owned()) + } else { + Ok(transform(s)) + } + } else { + Ok(transform(s)) + } + } + + fn replace_substring( + s: &str, + regex: &fancy_regex::Regex, + replacement: &str, + match_kind: &SubstringMatchKind, + ) -> String { + // The replacement is already fully expanded; it must be inserted verbatim. A bare + // `&str` replacer would instead read `$1`, `$0` and `${name}` as references to the + // regex's capture groups. + let replacement = fancy_regex::NoExpand(replacement); + + match match_kind { + brush_parser::word::SubstringMatchKind::Prefix + | brush_parser::word::SubstringMatchKind::Suffix + | brush_parser::word::SubstringMatchKind::FirstOccurrence => { + regex.replace(s, replacement).into_owned() + } + + brush_parser::word::SubstringMatchKind::Anywhere => { + regex.replace_all(s, replacement).into_owned() + } + } + } + + async fn apply_transform_to( + &mut self, + op: &ParameterTransformOp, + s: &str, + came_from_undefined: bool, + ) -> Result { + match op { + brush_parser::word::ParameterTransformOp::PromptExpand => { + prompt::expand_prompt(self.shell, self.params, s).await + } + brush_parser::word::ParameterTransformOp::CapitalizeInitial => { + Ok(to_initial_capitals(s)) + } + brush_parser::word::ParameterTransformOp::ExpandEscapeSequences => { + let (result, _) = + escape::expand_backslash_escapes(s, escape::EscapeExpansionMode::AnsiCQuotes)?; + Ok(String::from_utf8_lossy(result.as_slice()).into_owned()) + } + brush_parser::word::ParameterTransformOp::PossiblyQuoteWithArraysExpanded { + separate_words: _separate_words, + } => { + if came_from_undefined { + Ok(String::new()) + } else { + // TODO(expansion): This isn't right for arrays. + // TODO(expansion): This doesn't honor 'separate_words' + Ok(escape::force_quote(s, escape::QuoteMode::SingleQuote)) + } + } + brush_parser::word::ParameterTransformOp::Quoted => { + if came_from_undefined { + Ok(String::new()) + } else { + Ok(escape::force_quote(s, escape::QuoteMode::SingleQuote)) + } + } + brush_parser::word::ParameterTransformOp::ToLowerCase => Ok(s.to_lowercase()), + brush_parser::word::ParameterTransformOp::ToUpperCase => Ok(s.to_uppercase()), + brush_parser::word::ParameterTransformOp::ToAssignmentLogic + | brush_parser::word::ParameterTransformOp::ToAttributeFlags => { + unreachable!("covered in caller") + } + } + } +} + +fn list_element_fields(values: Vec, preserve_empty_elements: bool) -> Vec { + let value_count = values.len(); + values + .into_iter() + .enumerate() + .map(|(i, value)| { + let piece = if preserve_empty_elements { + ExpansionPiece::list_element(value, i + 1 < value_count) + } else { + ExpansionPiece::Splittable(value) + }; + WordField(vec![piece]) + }) + .collect() +} + +fn coalesce_expansions(expansions: Vec) -> Expansion { + expansions + .into_iter() + .fold(Expansion::default(), |mut acc, expansion| { + for (i, mut field) in expansion.fields.into_iter().enumerate() { + match acc.fields.last_mut() { + Some(last) if i == 0 => { + last.0.append(&mut field.0); + } + _ => acc.fields.push(field), + } + } + + // TODO(expansion): What if expansions have different concatenation values? + acc.concatenate = expansion.concatenate; + // The last kind wins, like `concatenate` above. An IndexedArray kind's + // subscripts no longer describe the merged fields; nothing slices a coalesced + // expansion today, and `polymorphic_subslice` must not until that's fixed. + acc.kind = expansion.kind; + + acc + }) +} + +fn to_initial_capitals(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in s.chars() { + if c.is_whitespace() { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + result.push_str(c.to_uppercase().to_string().as_str()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +async fn transform_expansion( + expansion: Expansion, + mut f: F, +) -> Result +where + F: FnMut(String) -> FReturn, + FReturn: Future>, +{ + let mut transformed_fields = vec![]; + for field in expansion.fields { + let transformed_field = WordField::from(f(String::from(field)).await?); + transformed_fields.push(transformed_field); + } + + Ok(Expansion { + fields: transformed_fields, + concatenate: expansion.concatenate, + kind: expansion.kind, + undefined: expansion.undefined, + }) +} + +fn may_contain_braces_to_expand(s: &str) -> bool { + // This is a completely inaccurate but quick heuristic used to see if + // it's even worth properly parsing the string to find brace expressions. + // It's mostly used to avoid more expensive parsing just because we've + // encountered a brace used in a parameter expansion. + let mut last_was_unescaped_dollar_sign = false; + let mut last_was_escape = false; + let mut saw_opening_brace = false; + let mut saw_closing_brace = false; + for c in s.chars() { + if !last_was_unescaped_dollar_sign { + if c == '{' { + saw_opening_brace = true; + } else if c == '}' { + saw_closing_brace = true; + if saw_opening_brace { + return true; + } + } + } + + last_was_unescaped_dollar_sign = !last_was_escape && c == '$'; + last_was_escape = c == '\\'; + } + + saw_opening_brace && saw_closing_brace +} + +#[expect(clippy::panic_in_result_fn)] +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + + #[tokio::test] + async fn test_full_expansion() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + let params = shell.default_exec_params(); + + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "\"\"").await?, + vec![""] + ); + // Literal text is never field-split (the parser would not produce such a word). + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "a b").await?, + vec!["a b"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "ab").await?, + vec!["ab"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, r#""a b""#).await?, + vec!["a b"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "").await?, + Vec::::new() + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "$@").await?, + Vec::::new() + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "$*").await?, + Vec::::new() + ); + + Ok(()) + } + + /// Regression test: a quoted empty string `""` must survive word expansion + /// even when `nullglob` is enabled. Previously, `Pattern::expand()` would + /// return `NoGlob` for an all-empty pattern, which with nullglob set caused + /// `expand_pathnames_in_field` to silently discard the empty-string field. + #[tokio::test] + async fn test_quoted_empty_string_with_nullglob() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + shell.options_mut().expand_non_matching_patterns_to_null = true; // shopt -s nullglob + let params = shell.default_exec_params(); + + // Quoted empty string must always produce exactly one empty-string field. + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "\"\"").await?, + vec![""] + ); + // Unquoted empty string (no characters at all) should still produce no fields. + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "").await?, + Vec::::new() + ); + + Ok(()) + } + + #[tokio::test] + async fn test_brace_expansion() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + let params = shell.default_exec_params(); + let expander = WordExpander::new(&mut shell, ¶ms); + + let expand = |w| expander.brace_expand_if_needed(w); + assert_eq!(expand("abc"), None); + assert_eq!(expand("a{,b}d"), Some(vec!["ad".into(), "abd".into()])); + assert_eq!(expand("a{b,c}d"), Some(vec!["abd".into(), "acd".into()])); + assert_eq!( + expand("a{1..3}d"), + Some(vec!["a1d".into(), "a2d".into(), "a3d".into()]) + ); + assert_eq!(expand(r#""{a,b}""#), Some(vec![r#""{a,b}""#.into()])); + assert_eq!(expand("a{}b"), Some(vec!["a{}b".into()])); + assert_eq!(expand("a{ }b"), Some(vec!["a{ }b".into()])); + assert_eq!( + expand("{a,b{1,2}}"), + Some(vec!["a".into(), "b1".into(), "b2".into()]) + ); + + Ok(()) + } + + #[test] + fn test_replace_substring_inserts_replacement_verbatim() -> Result<()> { + // A capture group, as an extglob pattern like `@(b)` produces. + let regex = fancy_regex::Regex::new("(b)")?; + let replace = |r: &str, kind: SubstringMatchKind| { + WordExpander::::replace_substring( + "abcb", ®ex, r, &kind, + ) + }; + + assert_eq!(replace("$1", SubstringMatchKind::FirstOccurrence), "a$1cb"); + assert_eq!(replace("$0", SubstringMatchKind::Anywhere), "a$0c$0"); + assert_eq!( + replace("${y}$$", SubstringMatchKind::Anywhere), + "a${y}$$c${y}$$" + ); + + Ok(()) + } + + #[test] + fn test_to_initial_capitals() { + assert_eq!(to_initial_capitals("ab bc cd"), String::from("Ab Bc Cd")); + assert_eq!(to_initial_capitals(" a "), String::from(" A ")); + assert_eq!(to_initial_capitals(""), String::new()); + } +} diff --git a/userland/brush/source/brush-core/src/expansion/fieldsplit.rs b/userland/brush/source/brush-core/src/expansion/fieldsplit.rs new file mode 100644 index 0000000..719bcef --- /dev/null +++ b/userland/brush/source/brush-core/src/expansion/fieldsplit.rs @@ -0,0 +1,297 @@ +//! Field splitting: breaking the results of expansion into words on `IFS`. +//! +//! Bash treats the characters in `IFS` as two kinds of delimiter: +//! +//! - *IFS whitespace* (space, tab, newline and the rest of the C locale's `isspace` set) +//! is "soft". A run of it, however long, is one separator, and any of it at the start +//! or end of a value is simply dropped. `" a b "` splits to `a` and `b`. +//! - Every other character in `IFS` is "hard". Each occurrence is a separator in its +//! own right, so two in a row enclose an empty field, and one at the very start leaves +//! an empty field in front of it. With `IFS=:`, `a::b` is `a`, an empty field, `b`. +//! +//! The two mix by absorption: whitespace on either side of a hard delimiter belongs to +//! that delimiter, so `a : b` under `IFS=': '` is two fields, not three. A hard delimiter +//! at the very end closes the field before it but does not open an empty one after it. + +use super::{Expansion, ExpansionPiece, WordField}; + +/// Where the splitter is relative to the field boundaries it has emitted. +enum SplitState { + /// A field is open: at least one piece has gone into it. That piece may be an + /// unquoted character or an unsplittable (quoted) piece, which can be empty, as in + /// `""`. The next delimiter closes the field; anything else extends it. + InField, + /// No field is open. The last delimiter was whitespace, so the run can still absorb + /// one hard delimiter without closing an empty field. + AfterWhitespace, + /// No field is open, and the run cannot absorb another hard delimiter. This is the + /// initial state and the state right after a hard delimiter. A hard delimiter here + /// closes an empty field. + AfterDelimiter, +} + +/// Returns whether `c` is IFS whitespace, i.e. a member of the C locale's `isspace` set, +/// which is what bash consults. +/// +/// Neither standard predicate matches that set: [`char::is_ascii_whitespace`] leaves out +/// vertical tab, and [`char::is_whitespace`] takes in the Unicode spaces (U+00A0 and +/// friends), which bash treats as hard delimiters. +const fn is_ifs_whitespace(c: char) -> bool { + matches!( + c, + ' ' | '\t' | '\n' | '\r' + | '\u{0b}' // vertical tab + | '\u{0c}' // form feed + ) +} + +/// Splits an expansion's fields on the characters in `ifs`, following bash's rules +/// (see the module docs). Only [`ExpansionPiece::Splittable`] text is examined; +/// quoted text and the word's own literal text always stay in the field they are in. +pub(super) fn split_fields(ifs: &str, expansion: Expansion) -> Vec { + let mut fields: Vec = vec![]; + let mut current_field = WordField::new(); + let has_hard_delimiter = ifs.chars().any(|c| !is_ifs_whitespace(c)); + + // Each incoming field is split on its own, so a delimiter run never reaches across + // the boundary between two of them (e.g. two elements of `${arr[@]}`). + for existing_field in expansion.fields { + let mut state = SplitState::AfterDelimiter; + let preserve_empty_list_element = matches!( + existing_field.0.as_slice(), + [ExpansionPiece::EmptyListElement { + has_following: true + }] + ); + + for piece in existing_field.0 { + match piece { + ExpansionPiece::EmptyListElement { .. } => {} + // An empty word contributes nothing (unlike a quoted empty string). + ExpansionPiece::UnquotedLiteral(s) if s.is_empty() => {} + // Quoted text and the word's own literal text go into the current field + // untouched, opening one if needed. That is what keeps `""` alive as an + // empty argument. + ExpansionPiece::Unsplittable(_) | ExpansionPiece::UnquotedLiteral(_) => { + current_field.0.push(piece); + state = SplitState::InField; + } + ExpansionPiece::Splittable(s) => { + for c in s.chars() { + // An ordinary character extends the open field, or opens one. + // It joins the trailing splittable piece if there is one, so a + // field's text stays in as few pieces as possible. + if !ifs.contains(c) { + match current_field.0.last_mut() { + Some(ExpansionPiece::Splittable(last)) => last.push(c), + _ => current_field + .0 + .push(ExpansionPiece::Splittable(c.to_string())), + } + state = SplitState::InField; + continue; + } + + // A delimiter. What it does depends on whether it is soft + // (whitespace) or hard, and on where the last field ended. + state = match (state, is_ifs_whitespace(c)) { + // Either kind closes an open field. The kind decides whether + // the run that follows can still absorb a hard delimiter. + (SplitState::InField, whitespace) => { + fields.push(std::mem::take(&mut current_field)); + if whitespace { + SplitState::AfterWhitespace + } else { + SplitState::AfterDelimiter + } + } + // The run's one hard delimiter: absorbed, nothing emitted. + (SplitState::AfterWhitespace, false) => SplitState::AfterDelimiter, + // A second hard delimiter, or one at the very start: it + // closes a field that never got any content. + (SplitState::AfterDelimiter, false) => { + fields.push(WordField::new()); + SplitState::AfterDelimiter + } + // Whitespace between fields is skipped, whatever came + // before it; leading whitespace is dropped the same way. + (state, true) => state, + }; + } + } + } + } + + // Only an open field is left to flush. Ending on a delimiter adds nothing, so a + // trailing `:` never yields a trailing empty field. + if matches!(state, SplitState::InField) { + fields.push(std::mem::take(&mut current_field)); + } else if preserve_empty_list_element && has_hard_delimiter { + fields.push(WordField::new()); + } + } + + fields +} + +#[cfg(test)] +mod tests { + use super::*; + + type Pieces = Vec>; + + fn s(text: &str) -> ExpansionPiece { + ExpansionPiece::Splittable(text.into()) + } + + fn u(text: &str) -> ExpansionPiece { + ExpansionPiece::Unsplittable(text.into()) + } + + fn l(text: &str) -> ExpansionPiece { + ExpansionPiece::UnquotedLiteral(text.into()) + } + + const fn e(has_following: bool) -> ExpansionPiece { + ExpansionPiece::EmptyListElement { has_following } + } + + fn split(ifs: &str, fields: Pieces) -> Pieces { + let expansion = Expansion { + fields: fields.into_iter().map(WordField).collect(), + ..Expansion::default() + }; + split_fields(ifs, expansion) + .into_iter() + .map(|field| field.0) + .collect() + } + + #[test] + fn splits_like_bash() { + // (IFS, input fields, expected fields). Every expectation was checked against bash. + let cases: Vec<(&str, Pieces, Pieces)> = vec![ + // Whitespace collapses and is dropped at either end. + ( + " \t\n", + vec![vec![s(" a \t b\n")]], + vec![vec![s("a")], vec![s("b")]], + ), + // Non-whitespace delimiters keep the empty fields between them. + (":", vec![vec![s("a:b")]], vec![vec![s("a")], vec![s("b")]]), + ( + ":", + vec![vec![s("a::b")]], + vec![vec![s("a")], vec![], vec![s("b")]], + ), + // A leading delimiter leaves an empty field; a trailing one does not. + (":", vec![vec![s(":a")]], vec![vec![], vec![s("a")]]), + (":", vec![vec![s("a:")]], vec![vec![s("a")]]), + (":", vec![vec![s(":")]], vec![vec![]]), + (":", vec![vec![s("::")]], vec![vec![], vec![]]), + // Whitespace next to a delimiter joins its run. + ( + ": ", + vec![vec![s("a : b")]], + vec![vec![s("a")], vec![s("b")]], + ), + ( + ": ", + vec![vec![s("a :: b")]], + vec![vec![s("a")], vec![], vec![s("b")]], + ), + (": ", vec![vec![s(" :a")]], vec![vec![], vec![s("a")]]), + (": ", vec![vec![s(": a")]], vec![vec![], vec![s("a")]]), + ( + ": ", + vec![vec![s(": :a")]], + vec![vec![], vec![], vec![s("a")]], + ), + (": ", vec![vec![s("a: ")]], vec![vec![s("a")]]), + // The word's own literal text is never split; it joins whatever field is + // open, and a delimiter that closed the previous field keeps it out. + (":", vec![vec![l("a:b")]], vec![vec![l("a:b")]]), + ( + ":", + vec![vec![s("1:2"), l(":3")]], + vec![vec![s("1")], vec![s("2"), l(":3")]], + ), + ( + ":", + vec![vec![s("1:"), l(":3")]], + vec![vec![s("1")], vec![l(":3")]], + ), + // Quoted text is never split and keeps its own piece. + ( + ":", + vec![vec![u("a:b"), s(":c")]], + vec![vec![u("a:b")], vec![s("c")]], + ), + ( + ":", + vec![vec![u("a:b"), s("c")]], + vec![vec![u("a:b"), s("c")]], + ), + (":", vec![vec![u("")]], vec![vec![u("")]]), + ( + ":", + vec![vec![u("A")], vec![u("")]], + vec![vec![u("A")], vec![u("")]], + ), + // A delimiter run may span two splittable pieces of one field... + ( + ":", + vec![vec![s("1:"), s(":4")]], + vec![vec![s("1")], vec![], vec![s("4")]], + ), + // ...but never two incoming fields. + ( + ":", + vec![vec![s("a:b")], vec![s("c::d")]], + vec![ + vec![s("a")], + vec![s("b")], + vec![s("c")], + vec![], + vec![s("d")], + ], + ), + // An empty unquoted expansion yields nothing. + (":", vec![vec![s("")]], vec![]), + // Empty IFS disables splitting. + ("", vec![vec![s("a b")]], vec![vec![s("a b")]]), + // The C-locale whitespace set is honored; Unicode spaces are ordinary delimiters. + ( + "\r", + vec![vec![s("a\rb\r\rc")]], + vec![vec![s("a")], vec![s("b")], vec![s("c")]], + ), + ( + "\u{a0}", + vec![vec![s("a\u{a0}\u{a0}b")]], + vec![vec![s("a")], vec![], vec![s("b")]], + ), + ]; + + for (ifs, input, expected) in cases { + let actual = split(ifs, input.clone()); + assert_eq!(actual, expected, "ifs={ifs:?} input={input:?}"); + } + } + + #[test] + fn preserves_non_trailing_empty_list_elements_with_hard_ifs() { + assert_eq!( + split(":", vec![vec![e(true)], vec![s("2")]]), + vec![vec![], vec![s("2")]] + ); + assert_eq!( + split(":", vec![vec![s("2")], vec![e(false)]]), + vec![vec![s("2")]] + ); + assert_eq!( + split(" ", vec![vec![e(true)], vec![s("2")]]), + vec![vec![s("2")]] + ); + } +} diff --git a/userland/brush/source/brush-core/src/extendedtests.rs b/userland/brush/source/brush-core/src/extendedtests.rs new file mode 100644 index 0000000..4ca6d8e --- /dev/null +++ b/userland/brush/source/brush-core/src/extendedtests.rs @@ -0,0 +1,625 @@ +use brush_parser::ast; +use std::path::Path; + +use crate::{ + ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions, + namedoptions, patterns, + sys::{ + fs::{MetadataExt, PathExt}, + users, + }, + variables::{self, ArrayLiteral}, +}; + +#[allow(clippy::double_must_use)] +#[async_recursion::async_recursion] +pub(crate) async fn eval_extended_test_expr( + expr: &ast::ExtendedTestExpr, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match expr { + ast::ExtendedTestExpr::UnaryTest(op, operand) => { + apply_unary_predicate(op, operand, shell, params).await + } + ast::ExtendedTestExpr::BinaryTest(op, left, right) => { + apply_binary_predicate(op, left, right, shell, params).await + } + ast::ExtendedTestExpr::And(left, right) => { + let result = eval_extended_test_expr(left, shell, params).await? + && eval_extended_test_expr(right, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Or(left, right) => { + let result = eval_extended_test_expr(left, shell, params).await? + || eval_extended_test_expr(right, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Not(expr) => { + let result = !eval_extended_test_expr(expr, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Parenthesized(expr) => { + eval_extended_test_expr(expr, shell, params).await + } + } +} + +async fn apply_unary_predicate( + op: &ast::UnaryPredicate, + operand: &ast::Word, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + let expanded_operand = expansion::basic_expand_word(shell, params, operand).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command( + params, + std::format!( + "[[ {op} {} ]]", + // Like bash, leave a leading `#` or `~` as is. + escape::quote( + &expanded_operand, + &escape::QuoteOptions { + preferred_mode: escape::QuoteMode::SingleQuote, + leave_leading_hash: true, + leave_tilde: true, + ..Default::default() + } + ) + ), + ) + .await; + } + + apply_unary_predicate_to_str(op, expanded_operand.as_str(), shell, params) +} + +#[expect(clippy::too_many_lines)] +pub(crate) fn apply_unary_predicate_to_str( + op: &ast::UnaryPredicate, + operand: &str, + shell: &Shell, + params: &ExecutionParameters, +) -> Result { + match op { + ast::UnaryPredicate::StringHasNonZeroLength => Ok(!operand.is_empty()), + ast::UnaryPredicate::StringHasZeroLength => Ok(operand.is_empty()), + ast::UnaryPredicate::FileExists => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists()) + } + ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_block_device()) + } + ast::UnaryPredicate::FileExistsAndIsCharSpecialFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_char_device()) + } + ast::UnaryPredicate::FileExistsAndIsDir => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_dir()) + } + ast::UnaryPredicate::FileExistsAndIsRegularFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_file()) + } + ast::UnaryPredicate::FileExistsAndIsSetgid => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_setgid()) + } + ast::UnaryPredicate::FileExistsAndIsSymlink => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_symlink()) + } + ast::UnaryPredicate::FileExistsAndHasStickyBit => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_sticky_bit()) + } + ast::UnaryPredicate::FileExistsAndIsFifo => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_fifo()) + } + ast::UnaryPredicate::FileExistsAndIsReadable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.readable()) + } + ast::UnaryPredicate::FileExistsAndIsNotZeroLength => { + let path = shell.absolute_path(Path::new(operand)); + if let Ok(metadata) = path.metadata() { + Ok(metadata.len() > 0) + } else { + Ok(false) + } + } + ast::UnaryPredicate::FdIsOpenTerminal => { + // Trim whitespace before parsing, matching bash behavior. + if let Ok(fd) = operand.trim().parse::() { + if let Some(open_file) = params.try_fd(shell, fd) { + Ok(open_file.is_terminal()) + } else { + Ok(false) + } + } else { + Ok(false) + } + } + ast::UnaryPredicate::FileExistsAndIsSetuid => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_setuid()) + } + ast::UnaryPredicate::FileExistsAndIsWritable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.writable()) + } + ast::UnaryPredicate::FileExistsAndIsExecutable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.executable()) + } + ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => { + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.gid() == users::get_effective_gid()?) + } + ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => { + error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead") + } + ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => { + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.uid() == users::get_effective_uid()?) + } + ast::UnaryPredicate::FileExistsAndIsSocket => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_socket()) + } + ast::UnaryPredicate::ShellOptionEnabled => { + let shopt_name = operand; + if let Some(option) = + namedoptions::options(namedoptions::ShellOptionKind::SetO).get(shopt_name) + { + Ok(option.get(shell.options())) + } else { + Ok(false) + } + } + ast::UnaryPredicate::ShellVariableIsSetAndAssigned => Ok(shell.env().is_set(operand)), + ast::UnaryPredicate::ShellVariableIsSetAndNameRef => match shell.env().get(operand) { + Some((_, reffed)) => Ok(reffed.value().is_set() && reffed.is_treated_as_nameref()), + None => Ok(false), + }, + } +} + +#[expect(clippy::too_many_lines)] +async fn apply_binary_predicate( + op: &ast::BinaryPredicate, + left: &ast::Word, + right: &ast::Word, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match op { + ast::BinaryPredicate::StringMatchesRegex => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let regex = expansion::basic_expand_regex(shell, params, right) + .await? + .set_multiline(true); + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {s} {op} {right} ]]")) + .await; + } + + let (matches, captures) = match regex.matches(s.as_str()) { + Ok(Some(captures)) => (true, captures), + Ok(None) => (false, vec![]), + // If we can't compile the regex, don't abort the whole operation but make sure to + // report it. + // TODO(test): Docs indicate we should yield 2 on an invalid regex (not 1). + Err(e) => { + tracing::warn!("error using regex: {}", e); + (false, vec![]) + } + }; + + let captures_value = variables::ShellValueLiteral::Array(ArrayLiteral( + captures + .into_iter() + .map(|c| (None, c.unwrap_or_default())) + .collect(), + )); + + shell.env_mut().update_or_add( + "BASH_REMATCH", + captures_value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + Ok(matches) + } + ast::BinaryPredicate::StringExactlyMatchesString => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left == right) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchString => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left != right) + } + ast::BinaryPredicate::StringContainsSubstring => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let substring = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {s} {op} {substring} ]]")) + .await; + } + + Ok(s.contains(substring.as_str())) + } + ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + files_refer_to_same_device_and_inode_numbers(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + left_file_is_newer_or_exists_when_right_does_not(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + left_file_is_older_or_does_not_exist_when_right_does(shell, left, right) + } + ast::BinaryPredicate::LeftSortsBeforeRight => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left < right) + } + ast::BinaryPredicate::LeftSortsAfterRight => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left == right) + } + ast::BinaryPredicate::ArithmeticNotEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left != right) + } + ast::BinaryPredicate::ArithmeticLessThan => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left < right) + } + ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left <= right) + } + ast::BinaryPredicate::ArithmeticGreaterThan => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left >= right) + } + // N.B. The "=", "==", and "!=" operators don't compare 2 strings; they check + // for whether the lefthand operand (a string) is matched by the righthand + // operand (treated as a shell pattern). + // TODO(test): implement case-insensitive matching if relevant via shopt options + // (nocasematch). + ast::BinaryPredicate::StringExactlyMatchesPattern => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let pattern = expansion::basic_expand_pattern(shell, params, right) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if shell.options().print_commands_and_arguments { + let expanded_right = expansion::basic_expand_word(shell, params, right).await?; + // Like bash, leave a `~` as is. + let escaped_right = escape::quote( + expanded_right.as_str(), + &escape::QuoteOptions { + preferred_mode: escape::QuoteMode::BackslashEscape, + leave_tilde: true, + ..Default::default() + }, + ); + shell + .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]")) + .await; + } + + pattern.exactly_matches(s.as_str()) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let pattern = expansion::basic_expand_pattern(shell, params, right) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if shell.options().print_commands_and_arguments { + let expanded_right = expansion::basic_expand_word(shell, params, right).await?; + // Like bash, leave a `~` as is. + let escaped_right = escape::quote( + expanded_right.as_str(), + &escape::QuoteOptions { + preferred_mode: escape::QuoteMode::BackslashEscape, + leave_tilde: true, + ..Default::default() + }, + ); + shell + .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]")) + .await; + } + + let eq = pattern.exactly_matches(s.as_str())?; + Ok(!eq) + } + } +} + +pub(crate) fn apply_binary_predicate_to_strs( + op: &ast::BinaryPredicate, + left: &str, + right: &str, + shell: &Shell, +) -> Result { + match op { + ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => { + files_refer_to_same_device_and_inode_numbers(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => { + left_file_is_newer_or_exists_when_right_does_not(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => { + left_file_is_older_or_does_not_exist_when_right_does(shell, left, right) + } + ast::BinaryPredicate::LeftSortsBeforeRight => { + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left < right) + } + ast::BinaryPredicate::LeftSortsAfterRight => { + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticEqualTo => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left == right) + } + ast::BinaryPredicate::ArithmeticNotEqualTo => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left != right) + } + ast::BinaryPredicate::ArithmeticLessThan => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left < right) + } + ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left <= right) + } + ast::BinaryPredicate::ArithmeticGreaterThan => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left > right) + } + ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => { + apply_test_binary_arithmetic_predicate(left, right, |left, right| left >= right) + } + ast::BinaryPredicate::StringExactlyMatchesPattern => { + let pattern = patterns::Pattern::from(right) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + pattern.exactly_matches(left) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { + let pattern = patterns::Pattern::from(right) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + let eq = pattern.exactly_matches(left)?; + Ok(!eq) + } + ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right), + ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right), + _ => error::unimp("unsupported test binary predicate"), + } +} + +fn apply_test_binary_arithmetic_predicate( + left: &str, + right: &str, + op: fn(i64, i64) -> bool, +) -> Result { + Ok(op(parse_test_integer(left)?, parse_test_integer(right)?)) +} + +/// Parses an operand of an arithmetic comparison in a test command. Like bash, an operand that +/// isn't an integer is an error, not just a false result. +fn parse_test_integer(s: &str) -> Result { + // We trim leading/trailing whitespace (including newlines) before parsing integers. + s.trim() + .parse() + .map_err(|_| error::ErrorKind::IntegerExpressionExpected(s.to_owned()).into()) +} + +fn left_file_is_older_or_does_not_exist_when_right_does( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + match (l_path.metadata(), r_path.metadata()) { + (Ok(m1), Ok(m2)) => Ok(m1.modified()? < m2.modified()?), + (Err(_), Ok(_)) => Ok(true), + _ => Ok(false), + } +} + +fn left_file_is_newer_or_exists_when_right_does_not( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + match (l_path.metadata(), r_path.metadata()) { + (Ok(m1), Ok(m2)) => Ok(m1.modified()? > m2.modified()?), + (Ok(_), Err(_)) => Ok(true), + _ => Ok(false), + } +} + +fn files_refer_to_same_device_and_inode_numbers( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + if !l_path.readable() || !r_path.readable() { + return Ok(false); + } + + Ok(l_path.get_device_and_inode()? == r_path.get_device_and_inode()?) +} diff --git a/userland/brush/source/brush-core/src/extensions.rs b/userland/brush/source/brush-core/src/extensions.rs new file mode 100644 index 0000000..db008dd --- /dev/null +++ b/userland/brush/source/brush-core/src/extensions.rs @@ -0,0 +1,209 @@ +//! Definition of shell behavior traits and defaults. + +use crate::{ + Shell, error, extensions, + processes::{ChildProcess, LaunchRequest}, + sys, + tasks::TaskHandle, +}; + +/// Trait for static shell extensions. Collects all associated types needed to +/// instantiate a shell into a single containing struct. +pub trait ShellExtensions: Clone + Default + Send + Sync + 'static { + /// Type of the error behavior implementation. + type ErrorFormatter: ErrorFormatter; + /// Type of the process launcher implementation. + type ProcessLauncher: ProcessLauncher; + /// Type of the task spawner implementation. + type TaskSpawner: TaskSpawner; +} + +/// Shell extensions implementation constructed from component types. +#[derive(Clone, Default)] +pub struct ShellExtensionsImpl< + EF: ErrorFormatter = DefaultErrorFormatter, + PL: ProcessLauncher = DefaultProcessLauncher, + TS: TaskSpawner = DefaultTaskSpawner, +> { + _marker: std::marker::PhantomData<(EF, PL, TS)>, +} + +impl ShellExtensions + for ShellExtensionsImpl +{ + type ErrorFormatter = EF; + type ProcessLauncher = PL; + type TaskSpawner = TS; +} + +/// Default shell extensions implementation. +/// This is a type alias for the most common shell configuration. +pub type DefaultShellExtensions = ShellExtensionsImpl; + +/// Trait for defining shell error behaviors. +pub trait ErrorFormatter: Clone + Default + Send + Sync + 'static { + /// Format the given error for display within the context of the provided shell. + /// + /// # Arguments + /// + /// * `error` - The error to format + /// * `shell` - The shell context in which the error occurred. + fn format_error( + &self, + error: &error::Error, + shell: &Shell, + ) -> String { + let _ = shell; + std::format!("error: {error:#}\n") + } +} + +/// Default shell error behavior implementation. +#[derive(Clone, Default)] +pub struct DefaultErrorFormatter; + +impl ErrorFormatter for DefaultErrorFormatter {} + +/// Trait for launching the processes a shell runs. +/// +/// These are the external programs that commands resolve to, once the shell has +/// set up everything they start with. A host provides its own launcher to launch +/// them some other way (e.g., in a sandbox, or where the platform can't start +/// processes). +/// +/// # Limitations +/// +/// Only processes the [`DefaultProcessLauncher`] starts (including for a host's +/// launcher that hands a request on to it) are native children of the shell. +/// Those a host's launcher makes itself, with [`ChildProcess::from_future`], +/// have no process ID, and so take no part in job control: +/// +/// * `$!` expands to nothing for them, and `jobs -p` has no ID to list. +/// * `kill` can't signal them, and they can't be stopped (e.g., with Ctrl-Z) or +/// continued. The shell can only drop them, if [`LaunchRequest::kill_on_drop`] +/// asks it to. +/// * The shell doesn't forward signals to them: Ctrl-C reaches one only if it's +/// in the terminal's foreground process group. Waiting for one ends only when +/// its future does. +/// * They have no process group for others to join: under job control, each +/// pipeline stage after the first is asked to run in the shell's group +/// ([`ProcessGroup::Inherit`](crate::processes::ProcessGroup::Inherit)), not +/// the first stage's. +pub trait ProcessLauncher: Clone + Default + Send + Sync + 'static { + /// Starts a process. + /// + /// The error starting it decides what the shell reports: one of kind + /// [`std::io::ErrorKind::NotFound`] means the command could not be found + /// (status 127); any other, that it could not be executed (status 126). + /// + /// Once started, the process's result is that of the future it was made + /// from. If that future fails, the shell reports the error as it would any + /// error running a command, and the command's status is 1. + /// + /// # Arguments + /// + /// * `request` - The process to start. + fn spawn(&self, request: LaunchRequest) -> Result; + + /// Replaces the shell's process with a new one, for `exec`. Returns only if + /// that fails, with the reason why. + /// + /// By default, it fails as unsupported. + /// + /// # Arguments + /// + /// * `request` - The process to replace the shell's with. + fn exec(&self, request: LaunchRequest) -> std::io::Error { + let _ = request; + std::io::ErrorKind::Unsupported.into() + } +} + +/// Default process launcher implementation: launches native processes, where +/// the platform can start them. Elsewhere (e.g., on wasm), every launch fails as +/// unsupported. +#[derive(Clone, Default)] +pub struct DefaultProcessLauncher; + +impl ProcessLauncher for DefaultProcessLauncher { + fn spawn(&self, request: LaunchRequest) -> Result { + sys::process::spawn(request) + } + + fn exec(&self, request: LaunchRequest) -> std::io::Error { + sys::process::exec(request) + } +} + +/// Trait for starting the tasks a shell runs concurrently with itself (e.g., +/// pipeline stages, asynchronous lists, and command and process substitutions). +/// +/// By default, they run on tokio's runtime with [`tokio::spawn`], which needs +/// tokio to be driving the thread. A host provides its own spawner when +/// something else drives it: for example, a WASI 0.3 component, whose async +/// imports only make progress under the component's own executor, can start the +/// shell's tasks with [`tokio::task::spawn_local`] on a +/// [`tokio::task::LocalSet`] it drives from that executor. +pub trait TaskSpawner: Clone + Default + Send + Sync + 'static { + /// Starts running a future concurrently with the caller. + /// + /// # Arguments + /// + /// * `future` - The future to run. + fn spawn(&self, future: F) -> TaskHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static; + + /// Starts running a future that may block the thread it runs on (e.g., with + /// synchronous I/O), so that it doesn't hold up the caller where it can be + /// avoided. The returned future yields the task's handle once the caller may + /// go on: right away if the task runs elsewhere, or once it has finished if + /// it can only run on the caller's thread. + /// + /// # Arguments + /// + /// * `future` - The future to run. + fn spawn_blocking( + &self, + future: F, + ) -> impl Future> + Send + use + where + F: Future + Send + 'static, + F::Output: Send + Sync + 'static; +} + +/// Default task spawner implementation: runs tasks on tokio's runtime, with +/// blocking tasks on threads of their own where the platform has them. +#[derive(Clone, Default)] +pub struct DefaultTaskSpawner; + +impl TaskSpawner for DefaultTaskSpawner { + fn spawn(&self, future: F) -> TaskHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + sys::tasks::spawn(future) + } + + fn spawn_blocking( + &self, + future: F, + ) -> impl Future> + Send + use + where + F: Future + Send + 'static, + F::Output: Send + Sync + 'static, + { + sys::tasks::spawn_blocking(future) + } +} + +/// Trait for placeholder behavior (stub for future extension). +pub trait PlaceholderBehavior: Clone + Default + Send + Sync + 'static {} + +/// Default placeholder implementation. +#[derive(Clone, Default)] +pub struct DefaultPlaceholder; + +impl PlaceholderBehavior for DefaultPlaceholder {} diff --git a/userland/brush/source/brush-core/src/functions.rs b/userland/brush/source/brush-core/src/functions.rs new file mode 100644 index 0000000..3815cc1 --- /dev/null +++ b/userland/brush/source/brush-core/src/functions.rs @@ -0,0 +1,126 @@ +//! Structures for managing function registrations and calls. + +use std::{collections::HashMap, sync::Arc}; + +/// An environment for defined, named functions. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FunctionEnv { + functions: HashMap, +} + +impl FunctionEnv { + /// Tries to retrieve the registration for a function by name. + /// + /// # Arguments + /// + /// * `name` - The name of the function to retrieve. + pub fn get(&self, name: &str) -> Option<&Registration> { + self.functions.get(name) + } + + /// Tries to retrieve a mutable reference to the registration for a + /// function by name. + /// + /// # Arguments + /// + /// * `name` - The name of the function to retrieve. + pub fn get_mut(&mut self, name: &str) -> Option<&mut Registration> { + self.functions.get_mut(name) + } + + /// Unregisters a function from the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the function to remove. + pub fn remove(&mut self, name: &str) -> Option { + self.functions.remove(name) + } + + /// Updates a function registration in this environment. + /// + /// # Arguments + /// + /// * `name` - The name of the function to update. + /// * `registration` - The new registration for the function. + pub fn update(&mut self, name: String, registration: Registration) { + self.functions.insert(name, registration); + } + + /// Clear all functions in this environment. + pub fn clear(&mut self) { + self.functions.clear(); + } + + /// Returns an iterator over the functions registered in this environment. + pub fn iter(&self) -> impl Iterator { + self.functions.iter() + } +} + +/// Encapsulates a registration for a defined function. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Registration { + /// The parsed definition of the function. + definition: Arc, + /// The source info for the function definition. + source_info: crate::SourceInfo, + /// Whether or not this function definition should be exported to children. + exported: bool, +} + +impl From for Registration { + fn from(definition: brush_parser::ast::FunctionDefinition) -> Self { + Self { + definition: Arc::new(definition), + source_info: crate::SourceInfo::default(), + exported: false, + } + } +} + +impl Registration { + /// Creates a new function registration. + /// + /// # Arguments + /// + /// * `definition` - The function definition. + /// * `source_info` - Source information for the function definition. + pub fn new( + definition: brush_parser::ast::FunctionDefinition, + source_info: &crate::SourceInfo, + ) -> Self { + Self { + definition: Arc::new(definition), + source_info: source_info.clone(), + exported: false, + } + } + + /// Returns a reference to the function definition. + pub fn definition(&self) -> &brush_parser::ast::FunctionDefinition { + &self.definition + } + + /// Returns a reference to the source info for the function definition. + pub const fn source(&self) -> &crate::SourceInfo { + &self.source_info + } + + /// Marks the function for export. + pub const fn export(&mut self) { + self.exported = true; + } + + /// Unmarks the function for export. + pub const fn unexport(&mut self) { + self.exported = false; + } + + /// Returns whether this function is exported. + pub const fn is_exported(&self) -> bool { + self.exported + } +} diff --git a/userland/brush/source/brush-core/src/history.rs b/userland/brush/source/brush-core/src/history.rs new file mode 100644 index 0000000..76cd18f --- /dev/null +++ b/userland/brush/source/brush-core/src/history.rs @@ -0,0 +1,1060 @@ +//! Facilities for tracking and persisting the shell's command history. + +use chrono::Utc; +use std::{ + collections::BTreeSet, + io::{BufRead, Read, Write}, + path::Path, +}; + +use crate::error; + +/// Represents a unique identifier for a history item. +type ItemId = i64; + +/// Interface for querying and manipulating the shell's recorded history of commands. +// TODO(history): support maximum item count +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct History { + /// The items, oldest first. Their IDs only increase, so they're in ID order too, which + /// finds an item by ID (by binary search) without a separate map from IDs to items. + items: rpds::VectorSync, + next_id: ItemId, +} + +impl History { + /// Constructs a new `History` instance, with its contents initialized from the given readable + /// stream. If errors are encountered reading lines from the stream, unreadable lines will + /// be skipped but the call will still return successfully, with a warning logged. An error + /// result will be returned only if an internal error occurs updating the history. Like + /// bash, an entry the stream gives no timestamp gets the time it's read. + /// + /// # Arguments + /// + /// * `reader` - The readable stream to import history from. + pub fn import(reader: impl Read) -> Result { + let mut history = Self::default(); + let read_time = Utc::now(); + + let buf_reader = std::io::BufReader::new(reader); + + let mut next_timestamp = None; + for line_result in buf_reader.lines() { + let line = match line_result { + Ok(line) => line, + // If we couldn't decode the line due to invalid data (perhaps it wasn't + // valid UTF8?), skip it and make a best-effort attempt to proceed on. + // We'll later warn the user. + Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { + tracing::warn!("unreadable history line; {err}"); + continue; + } + // In the event of other kinds of errors, return an error result. We don't + // want to get stuck in a failing I/O loop. + Err(err) => { + return Err(err.into()); + } + }; + + // Look for timestamp comments; ignore other comment lines. + if let Some(comment) = line.strip_prefix("#") { + if let Ok(seconds_since_epoch) = comment.trim().parse() { + next_timestamp = ItemTimestamp::from_timestamp(seconds_since_epoch, 0); + } else { + next_timestamp = None; + } + + continue; + } + + let item = Item { + id: history.next_id, + command_line: line, + timestamp: Some(next_timestamp.take().unwrap_or(read_time)), + dirty: false, + }; + + history.add(item)?; + } + + Ok(history) + } + + /// Tries to retrieve a history item by its unique identifier. Returns `None` if no item is + /// found. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to retrieve. + pub fn get_by_id(&self, id: ItemId) -> Result, error::Error> { + Ok(self.position_of(id).and_then(|index| self.items.get(index))) + } + + /// Replaces the history item with the given ID with a new item. Returns an error if the item + /// cannot be updated. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to update. + /// * `item` - The new history item to replace the old one. + pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> { + let index = self + .position_of(id) + .ok_or(error::ErrorKind::HistoryItemNotFound)?; + // The item keeps its ID, so the items stay in ID order. + self.items.set_mut(index, Item { id, ..item }); + Ok(()) + } + + /// Removes the nth item from the history (counting from 0, the oldest). Returns whether + /// there was such an item to remove. + pub fn remove_nth_item(&mut self, n: usize) -> bool { + let len = self.items.len(); + if n >= len { + return false; + } + + // Take off the items from the nth on, and put back those after it: so removing a + // recent item (as `fc -s` does its own) touches only the few after it. + let after: Vec = (n + 1..len) + .filter_map(|i| self.items.get(i).cloned()) + .collect(); + for _ in n..len { + self.items.drop_last_mut(); + } + for item in after { + self.items.push_back_mut(item); + } + + true + } + + /// Keeps only the items that `keep` returns true for, in order. Their IDs don't change. + pub fn retain(&mut self, mut keep: impl FnMut(&Item) -> bool) { + let Some(first_removed) = self.items.iter().position(|item| !keep(item)) else { + return; + }; + + // As in `remove_nth_item`, rebuild only from the first item removed on. + let kept: Vec = self + .items + .iter() + .skip(first_removed + 1) + .filter(|item| keep(item)) + .cloned() + .collect(); + for _ in first_removed..self.items.len() { + self.items.drop_last_mut(); + } + for item in kept { + self.items.push_back_mut(item); + } + } + + /// Adds a new history item. Returns the unique identifier of the newly added item. + /// + /// # Arguments + /// + /// * `item` - The history item to add. + pub fn add(&mut self, mut item: Item) -> Result { + let id = self.next_id; + + item.id = id; + self.next_id += 1; + + self.items.push_back_mut(item); + + Ok(id) + } + + /// Deletes a history item by its unique identifier. Returns an error if the item cannot be + /// deleted. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to delete. + pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> { + if let Some(index) = self.position_of(id) { + self.remove_nth_item(index); + } + + Ok(()) + } + + /// Clears all history items. + pub fn clear(&mut self) -> Result<(), error::Error> { + self.items = rpds::VectorSync::new_sync(); + Ok(()) + } + + /// Flushes the history to backing storage (if relevant). + /// + /// # Arguments + /// + /// * `history_file_path` - The path to the history file. + /// * `append` - Whether to append to the file or overwrite it. + /// * `unsaved_items_only` - Whether to only write unsaved items; if true, any items will be + /// marked as "saved" once saved. + /// * `write_timestamps` - Whether to write timestamps for each command line. + pub fn flush( + &mut self, + history_file_path: impl AsRef, + append: bool, + unsaved_items_only: bool, + write_timestamps: bool, + ) -> Result<(), error::Error> { + // Open the file + let mut file_options = std::fs::File::options(); + + if append { + file_options.append(true); + } else { + file_options.write(true).truncate(true); + } + + let mut file = + std::io::BufWriter::new(file_options.create(true).open(history_file_path.as_ref())?); + + let mut written = Vec::new(); + for (index, item) in self.items.iter().enumerate() { + if unsaved_items_only && !item.dirty { + continue; + } + + if write_timestamps && let Some(timestamp) = item.timestamp { + writeln!(file, "#{}", timestamp.timestamp())?; + } + + writeln!(file, "{}", item.command_line)?; + + if unsaved_items_only { + written.push(index); + } + } + + // Only once they're all written out, mark the items saved: if writing fails, they + // stay unsaved, so a later flush retries them. + file.into_inner() + .map_err(std::io::IntoInnerError::into_error)?; + for index in written { + if let Some(item) = self.items.get_mut(index) { + item.dirty = false; + } + } + + Ok(()) + } + + /// Searches through history using the given query. + /// + /// # Arguments + /// + /// * `query` - The query to use. + pub fn search(&self, query: Query) -> Result, error::Error> { + Ok(Search::new(self, query)) + } + + /// Returns whether the history contains no items. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Returns an iterator over the history items. + pub fn iter(&self) -> impl Iterator { + self.items.iter() + } + + /// Retrieves the nth history item, if it exists. Returns `None` if no such item exists. + /// Indexing is zero-based, with an index of 0 referencing the oldest item in the history. + /// + /// # Arguments + /// + /// * `index` - The index of the history item to retrieve. + pub fn get(&self, index: usize) -> Option<&Item> { + self.items.get(index) + } + + /// Returns the newest item, if there is one. + pub fn last(&self) -> Option<&Item> { + self.items.last() + } + + /// Returns the number of items in the history. + pub fn count(&self) -> usize { + self.items.len() + } + + /// Returns how many items have IDs less than `id`: the position of the item with that ID, + /// if there is one. + fn position_before(&self, id: ItemId) -> usize { + // The items are in ID order, so binary search. (By hand: `slice::partition_point` + // needs a slice, which rpds's vector, a tree, isn't.) + let (mut low, mut high) = (0, self.items.len()); + while low < high { + let mid = low + (high - low) / 2; + if self.items.get(mid).is_some_and(|item| item.id < id) { + low = mid + 1; + } else { + high = mid; + } + } + low + } + + /// Returns the position of the item with the given ID, if there is one. + fn position_of(&self, id: ItemId) -> Option { + let index = self.position_before(id); + self.items + .get(index) + .is_some_and(|item| item.id == id) + .then_some(index) + } +} + +/// A value of `HISTCONTROL`, which controls what an interactive shell saves to history. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, strum_macros::EnumString)] +#[strum(serialize_all = "lowercase")] +pub(crate) enum HistoryControl { + /// Don't save a command that starts with a space. + IgnoreSpace, + /// Don't save a command that's the same as the last one saved. + IgnoreDups, + /// Both `ignorespace` and `ignoredups`. + IgnoreBoth, + /// Before saving a command, remove any earlier ones the same as it. + EraseDups, +} + +impl HistoryControl { + /// Parses `HISTCONTROL`'s colon-separated values into the set of them, with `ignoreboth` + /// as the two it stands for. Like bash, values it doesn't know are ignored. + pub(crate) fn parse_list(value: &str) -> BTreeSet { + value + .split(':') + .filter_map(|value| value.parse().ok()) + .flat_map(|control| match control { + Self::IgnoreBoth => [Self::IgnoreSpace, Self::IgnoreDups].as_slice(), + Self::IgnoreSpace => &[Self::IgnoreSpace], + Self::IgnoreDups => &[Self::IgnoreDups], + Self::EraseDups => &[Self::EraseDups], + }) + .copied() + .collect() + } +} + +/// Represents a timestamp for a history item. +pub type ItemTimestamp = chrono::DateTime; + +/// Represents an item in the history. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Item { + /// The unique identifier of the history item. + pub id: ItemId, + /// The actual command line. + pub command_line: String, + /// The timestamp when the command was started. + pub timestamp: Option, + /// Whether or not the item is dirty, i.e., has not yet been written to backing storage. + pub dirty: bool, +} + +impl Item { + /// Constructs a new `Item` with the given command line. + /// + /// # Arguments + /// + /// * `command_line` - The command line of the item. + pub fn new(command_line: impl Into) -> Self { + Self { + id: 0, // NOTE: ID will be assigned when added to the history. + command_line: command_line.into(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + } + } +} + +/// Encapsulates query parameters for searching through history. +#[derive(Default)] +pub struct Query { + /// Whether to search forward or backward + pub direction: Direction, + /// Optionally, clamp results to items with a timestamp strictly after this. + pub not_at_or_before_time: Option, + /// Optionally, clamp results to items with a timestamp strictly before this. + pub not_at_or_after_time: Option, + /// Optionally, clamp results to items with an ID equal strictly after this. + pub not_at_or_before_id: Option, + /// Optionally, clamp results to items with an ID equal strictly before this. + pub not_at_or_after_id: Option, + /// Optionally, maximum number of items to retrieve + pub max_items: Option, + /// Optionally, a string-based filter on command line. + pub command_line_filter: Option, +} + +impl Query { + /// Checks if the query includes the given item. + /// + /// # Arguments + /// + /// * `item` - The item to check. + pub fn includes(&self, item: &Item) -> bool { + // Filter based on not_at_or_before_time. + if let Some(not_at_or_before_time) = &self.not_at_or_before_time { + if item + .timestamp + .is_some_and(|ts| ts <= *not_at_or_before_time) + { + return false; + } + } + + // Filter based on not_at_or_after_time + if let Some(not_at_or_after_time) = &self.not_at_or_after_time { + if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) { + return false; + } + } + + // Filter based on not_at_or_before_id + if self + .not_at_or_before_id + .is_some_and(|query_id| item.id <= query_id) + { + return false; + } + + // Filter based on not_at_or_after_id + if self + .not_at_or_after_id + .is_some_and(|query_id| item.id >= query_id) + { + return false; + } + + // Filter based on command_line_filter + if let Some(command_line_filter) = &self.command_line_filter { + match command_line_filter { + CommandLineFilter::Prefix(prefix) => { + if !item.command_line.starts_with(prefix) { + return false; + } + } + CommandLineFilter::Suffix(suffix) => { + if !item.command_line.ends_with(suffix) { + return false; + } + } + CommandLineFilter::Contains(contains) => { + if !item.command_line.contains(contains) { + return false; + } + } + CommandLineFilter::Exact(exact) => { + if item.command_line != *exact { + return false; + } + } + } + } + + true + } +} + +/// Represents the direction of a search operation. +#[derive(Default)] +pub enum Direction { + /// Search forward from the oldest part of history. + #[default] + Forward, + /// Search backward from the youngest part of history. + Backward, +} + +/// Filter criteria for command lines. +pub enum CommandLineFilter { + /// The command line must start with this string. + Prefix(String), + /// The command line must end with this string. + Suffix(String), + /// The command line must contain this string. + Contains(String), + /// The command line must match this string exactly. + Exact(String), +} + +/// Represents a search operation. +pub struct Search<'a> { + /// The history to search through. + history: &'a History, + /// The positions of the items left to search: those within the query's ID bounds. + positions: std::ops::Range, + /// The query to apply. + query: Query, + /// Count of items returned so far. + count: usize, +} + +impl<'a> Search<'a> { + /// Constructs a new search against the provided history, querying *all* items. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + pub fn all(history: &'a History) -> Self { + Self::new(history, Query::default()) + } + + /// Constructs a new search against the provided history, using the given query. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + /// * `query` - The query to use. + pub fn new(history: &'a History, query: Query) -> Self { + // The items within the query's ID bounds are a run of them, since they're in ID + // order: search just those positions. + let start = query.not_at_or_before_id.map_or(0, |id| { + id.checked_add(1) + .map_or(history.items.len(), |id| history.position_before(id)) + }); + let end = query + .not_at_or_after_id + .map_or(history.items.len(), |id| history.position_before(id)); + + Self { + history, + positions: start..end.max(start), + query, + count: 0, + } + } +} + +impl<'a> Iterator for Search<'a> { + type Item = &'a Item; + + fn next(&mut self) -> Option { + // Once we hit the limit, we stop searching. + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_sign_loss)] + if self + .query + .max_items + .is_some_and(|max_items| self.count >= max_items as usize) + { + return None; + } + + loop { + let index = match self.query.direction { + Direction::Forward => self.positions.next(), + Direction::Backward => self.positions.next_back(), + }?; + let item = self.history.items.get(index)?; + if self.query.includes(item) { + self.count += 1; + return Some(item); + } + } + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + + fn history(lines: &[&str]) -> Result { + let mut history = History::default(); + for line in lines { + history.add(Item::new(*line))?; + } + Ok(history) + } + + fn command_lines<'a>(items: impl Iterator) -> Vec<&'a str> { + items.map(|item| item.command_line.as_str()).collect() + } + + /// Removing an item shifts the positions of those after it (as `history -d` shows), but + /// no item's ID changes: an ID names its item for good. + #[test] + fn ids_are_stable_across_removal() -> Result<(), error::Error> { + let mut history = history(&["a", "b", "c", "d"])?; + + assert!(history.remove_nth_item(1)); + assert_eq!( + history.get(1).map(|item| item.command_line.as_str()), + Some("c") + ); + assert_eq!( + history.get_by_id(2)?.map(|item| item.command_line.as_str()), + Some("c") + ); + + history.delete_item_by_id(0)?; + assert_eq!(command_lines(history.iter()), ["c", "d"]); + assert!(history.get_by_id(0)?.is_none()); + assert!(history.get_by_id(1)?.is_none()); + assert_eq!(history.get_by_id(3)?.map(|item| item.id), Some(3)); + + // New items still get new IDs. + assert_eq!(history.add(Item::new("e"))?, 4); + assert_eq!( + history.get_by_id(4)?.map(|item| item.command_line.as_str()), + Some("e") + ); + + // Even after clearing history: an old ID never comes to name a new item. + history.clear()?; + assert_eq!(history.add(Item::new("f"))?, 5); + assert!(history.get_by_id(0)?.is_none()); + + Ok(()) + } + + /// Updating an item keeps its ID, whatever ID the new item carries. + #[test] + fn update_by_id_keeps_the_id() -> Result<(), error::Error> { + let mut history = history(&["a", "b"])?; + + history.update_by_id( + 1, + Item { + id: 99, + ..Item::new("x") + }, + )?; + let updated = history + .get_by_id(1)? + .map(|item| (item.id, item.command_line.as_str())); + assert_eq!(updated, Some((1, "x"))); + assert!(history.get_by_id(99)?.is_none()); + + assert!(history.update_by_id(7, Item::new("y")).is_err()); + + Ok(()) + } + + #[test] + fn search_by_direction_bounds_and_prefix() -> Result<(), error::Error> { + let history = history(&["echo hi there", "ls", "echo hello world", "cd /tmp"])?; + let search = |query| history.search(query).map(command_lines); + let prefix = |text: &str| Some(CommandLineFilter::Prefix(text.to_owned())); + + // The newest match, as a hint asks for... + let newest = Query { + direction: Direction::Backward, + max_items: Some(1), + command_line_filter: prefix("echo h"), + ..Query::default() + }; + assert_eq!(search(newest)?, ["echo hello world"]); + + // ...and older than a given ID, as history navigation asks for. + let older = Query { + direction: Direction::Backward, + not_at_or_after_id: Some(2), + command_line_filter: prefix("echo h"), + ..Query::default() + }; + assert_eq!(search(older)?, ["echo hi there"]); + + let newer = Query { + not_at_or_before_id: Some(0), + not_at_or_after_id: Some(3), + ..Query::default() + }; + assert_eq!(search(newer)?, ["ls", "echo hello world"]); + + // Newer than a given ID, with no upper bound, as moving down through history asks. + let down = Query { + not_at_or_before_id: Some(1), + ..Query::default() + }; + assert_eq!(search(down)?, ["echo hello world", "cd /tmp"]); + + // By substring (as Ctrl-R searches) or exact match, or with no room for any. + let substring = Query { + direction: Direction::Backward, + command_line_filter: Some(CommandLineFilter::Contains("hel".to_owned())), + ..Query::default() + }; + assert_eq!(search(substring)?, ["echo hello world"]); + let exact = Query { + command_line_filter: Some(CommandLineFilter::Exact("ls".to_owned())), + ..Query::default() + }; + assert_eq!(search(exact)?, ["ls"]); + let no_room = Query { + max_items: Some(0), + ..Query::default() + }; + assert_eq!(search(no_room)?, Vec::<&str>::new()); + + let none = Query { + direction: Direction::Backward, + command_line_filter: prefix("echo hix"), + ..Query::default() + }; + assert_eq!(search(none)?, Vec::<&str>::new()); + + Ok(()) + } + + /// A search finds just what checking each item would, in order, whatever its direction, + /// ID bounds, limit, and filter -- including bounds at deleted IDs and at the extremes. + /// And an item is found by its ID just as a scan would find it. + #[test] + fn search_and_get_by_id_match_a_scan() -> Result<(), error::Error> { + for len in 0..6_i64 { + for deleted in 0..(1 << len) { + let mut history = History::default(); + for id in 0..len { + history.add(Item::new(if id % 2 == 0 { "even" } else { "odd" }))?; + } + for id in (0..len).filter(|id| deleted & (1 << id) != 0) { + history.delete_item_by_id(id)?; + } + let scan = || (0..history.count()).filter_map(|index| history.get(index)); + + let bounds = [None, Some(ItemId::MIN), Some(-1), Some(0), Some(2)] + .into_iter() + .chain([Some(len - 1), Some(len), Some(ItemId::MAX)]); + for lower in bounds.clone() { + for upper in bounds.clone() { + for backward in [false, true] { + for max_items in [None, Some(0), Some(1), Some(2), Some(-1)] { + for filter in [None, Some("odd")] { + let query = || Query { + direction: if backward { + Direction::Backward + } else { + Direction::Forward + }, + not_at_or_before_id: lower, + not_at_or_after_id: upper, + max_items, + command_line_filter: filter + .map(|f| CommandLineFilter::Prefix(f.to_owned())), + ..Query::default() + }; + + let found: Vec = + history.search(query())?.map(|item| item.id).collect(); + let mut expected: Vec = scan() + .filter(|item| query().includes(item)) + .map(|item| item.id) + .collect(); + if backward { + expected.reverse(); + } + // A negative limit is no limit. + if let Some(max) = + max_items.and_then(|m| usize::try_from(m).ok()) + { + expected.truncate(max); + } + assert_eq!( + found, expected, + "len {len}, deleted {deleted:b}, bounds {lower:?}..{upper:?}, backward {backward}, max {max_items:?}, filter {filter:?}" + ); + } + } + } + } + } + + for id in -1..=len { + let by_scan = scan().find(|item| item.id == id).map(|item| item.id); + assert_eq!(history.get_by_id(id)?.map(|item| item.id), by_scan); + } + } + } + + Ok(()) + } + + /// Appending unsaved items writes just those, once; writing everything writes it all. + #[test] + fn flush_writes_unsaved_items_once() -> Result<(), error::Error> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("history"); + let read = || std::fs::read_to_string(&path); + + // Imported items are already saved; added ones aren't. + let mut history = History::import(&b"old\n"[..])?; + history.add(Item::new("new1"))?; + history.add(Item::new("new2"))?; + + history.flush(&path, true, true, false)?; + assert_eq!(read()?, "new1\nnew2\n"); + history.flush(&path, true, true, false)?; + assert_eq!(read()?, "new1\nnew2\n"); + + history.flush(&path, false, false, false)?; + assert_eq!(read()?, "old\nnew1\nnew2\n"); + + Ok(()) + } + + /// Each of a query's filters, on its own. ID and time bounds are exclusive, and an item + /// with no timestamp isn't excluded by time bounds. + #[test] + fn query_includes_by_each_filter() { + let at = |seconds| ItemTimestamp::from_timestamp(seconds, 0); + let item = |id, command_line: &str, timestamp| Item { + id, + command_line: command_line.to_owned(), + timestamp, + dirty: false, + }; + + assert!(Query::default().includes(&item(5, "echo hi", None))); + + let after_id = Query { + not_at_or_before_id: Some(2), + ..Query::default() + }; + assert!(!after_id.includes(&item(2, "x", None))); + assert!(after_id.includes(&item(3, "x", None))); + let before_id = Query { + not_at_or_after_id: Some(2), + ..Query::default() + }; + assert!(!before_id.includes(&item(2, "x", None))); + assert!(before_id.includes(&item(1, "x", None))); + + let after_time = Query { + not_at_or_before_time: at(100), + ..Query::default() + }; + assert!(!after_time.includes(&item(0, "x", at(100)))); + assert!(after_time.includes(&item(0, "x", at(101)))); + assert!(after_time.includes(&item(0, "x", None))); + let before_time = Query { + not_at_or_after_time: at(100), + ..Query::default() + }; + assert!(!before_time.includes(&item(0, "x", at(100)))); + assert!(before_time.includes(&item(0, "x", at(99)))); + assert!(before_time.includes(&item(0, "x", None))); + + for (filter, matching, other) in [ + (CommandLineFilter::Prefix("ec".to_owned()), "echo", "sec"), + (CommandLineFilter::Suffix("ho".to_owned()), "echo", "hot"), + (CommandLineFilter::Contains("ch".to_owned()), "echo", "cd"), + (CommandLineFilter::Exact("echo".to_owned()), "echo", "echo "), + ] { + let query = Query { + command_line_filter: Some(filter), + ..Query::default() + }; + assert!(query.includes(&item(0, matching, None)), "{matching:?}"); + assert!(!query.includes(&item(0, other, None)), "{other:?}"); + } + } + + /// An empty history, positions past the end, and IDs no item has. + #[test] + fn empty_history_and_missing_items() -> Result<(), error::Error> { + let mut empty = History::default(); + assert!(empty.is_empty()); + assert_eq!(empty.count(), 0); + assert!(empty.get(0).is_none()); + assert!(empty.get_by_id(0)?.is_none()); + assert!(!empty.remove_nth_item(0)); + empty.delete_item_by_id(0)?; + assert!(empty.update_by_id(0, Item::new("x")).is_err()); + assert_eq!(empty.iter().count(), 0); + assert_eq!(empty.search(Query::default())?.count(), 0); + + let mut history = history(&["a", "b", "c"])?; + assert!(!history.is_empty()); + assert_eq!(history.count(), 3); + assert!(history.get(3).is_none()); + assert!(!history.remove_nth_item(3)); + history.delete_item_by_id(7)?; + assert_eq!(command_lines(history.iter()), ["a", "b", "c"]); + + // Updating an item changes just it, where it is. + history.update_by_id(1, Item::new("B"))?; + assert_eq!(command_lines(history.iter()), ["a", "B", "c"]); + assert_eq!(command_lines(Search::all(&history)), ["a", "B", "c"]); + + Ok(()) + } + + /// A new item is unsaved and stamped now; adding it gives it the next ID. + #[test] + fn new_items_are_unsaved_and_stamped() -> Result<(), error::Error> { + let before = Utc::now(); + let item = Item::new("ls"); + assert!(item.dirty); + assert!(item.timestamp.is_some_and(|ts| ts >= before)); + + let mut history = history(&["a"])?; + assert_eq!(history.add(item)?, 1); + assert_eq!(history.get(1).map(|item| item.id), Some(1)); + + Ok(()) + } + + /// Importing reads command lines and the timestamp comments before them (the last of + /// several; a comment that isn't one clears it), skips unreadable lines, and leaves the + /// items saved and numbered from 0. + #[test] + fn import_reads_history_files() -> Result<(), error::Error> { + let before = Utc::now(); + let file = b"#1700000000\na\n#1700000005\n#not a time\nb\n#1700000001\n#1700000002\nc\n\xff\xfe\nd\n"; + let mut history = History::import(&file[..])?; + + assert_eq!(command_lines(history.iter()), ["a", "b", "c", "d"]); + let timestamps: Vec<_> = history.iter().map(|item| item.timestamp).collect(); + assert_eq!( + timestamps[0], + ItemTimestamp::from_timestamp(1_700_000_000, 0) + ); + assert!(timestamps[1].is_some_and(|ts| ts >= before)); + assert_eq!( + timestamps[2], + ItemTimestamp::from_timestamp(1_700_000_002, 0) + ); + assert!(timestamps[3].is_some_and(|ts| ts >= before)); + assert!(history.iter().all(|item| !item.dirty)); + assert_eq!( + history.iter().map(|item| item.id).collect::>(), + [0, 1, 2, 3] + ); + assert_eq!(history.add(Item::new("e"))?, 4); + + assert!(History::import(&b""[..])?.is_empty()); + + Ok(()) + } + + /// Flushing appends to what's already in the file, with the timestamps of items that + /// have them if asked; importing that reads them back. + #[test] + fn flush_and_import_round_trip_with_timestamps() -> Result<(), error::Error> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("history"); + std::fs::write(&path, "existing\n")?; + + let mut history = History::default(); + history.add(Item { + timestamp: ItemTimestamp::from_timestamp(1_700_000_000, 0), + ..Item::new("a") + })?; + history.add(Item { + timestamp: None, + ..Item::new("b") + })?; + history.flush(&path, true, true, true)?; + assert_eq!( + std::fs::read_to_string(&path)?, + "existing\n#1700000000\na\nb\n" + ); + + let reread = History::import(std::fs::File::open(&path)?)?; + assert_eq!(command_lines(reread.iter()), ["existing", "a", "b"]); + assert_eq!( + reread.get(1).and_then(|item| item.timestamp), + ItemTimestamp::from_timestamp(1_700_000_000, 0) + ); + + Ok(()) + } + + #[test] + fn last_and_retain() -> Result<(), error::Error> { + assert!(History::default().last().is_none()); + + let mut history = history(&["a", "x", "b", "x", "c", "x"])?; + assert_eq!( + history.last().map(|item| item.command_line.as_str()), + Some("x") + ); + + // Keeping everything changes nothing. + history.retain(|_| true); + assert_eq!( + command_lines(history.iter()), + ["a", "x", "b", "x", "c", "x"] + ); + + // Removing some keeps the rest in order, with their IDs. + history.retain(|item| item.command_line != "x"); + assert_eq!(command_lines(history.iter()), ["a", "b", "c"]); + assert_eq!( + history.iter().map(|item| item.id).collect::>(), + [0, 2, 4] + ); + assert_eq!( + history.get_by_id(4)?.map(|item| item.command_line.as_str()), + Some("c") + ); + + history.retain(|item| item.command_line != "a"); + assert_eq!(command_lines(history.iter()), ["b", "c"]); + history.retain(|_| false); + assert!(history.is_empty()); + + Ok(()) + } + + /// `HISTCONTROL` parses as bash reads it: `ignoreboth` is two values, and unknown values, + /// empty ones, and other cases are ignored. + #[test] + fn history_control_parses_like_bash() { + use HistoryControl::{EraseDups, IgnoreDups, IgnoreSpace}; + let parse = |value| { + HistoryControl::parse_list(value) + .into_iter() + .collect::>() + }; + + assert_eq!(parse("ignoredups"), [IgnoreDups]); + assert_eq!(parse("ignorespace"), [IgnoreSpace]); + assert_eq!(parse("erasedups"), [EraseDups]); + assert_eq!(parse("ignoreboth"), [IgnoreSpace, IgnoreDups]); + assert_eq!(parse("erasedups:ignorespace"), [IgnoreSpace, EraseDups]); + assert_eq!(parse("bogus::ignoredups:"), [IgnoreDups]); + assert_eq!(parse("IGNOREDUPS"), []); + assert_eq!(parse(""), []); + } + + /// Searches and iterators over history can be held across an await in a spawned task. + #[test] + fn searches_are_send_and_sync() -> Result<(), error::Error> { + const fn assert_send_sync(_: &T) {} + + let history = history(&["a"])?; + assert_send_sync(&history.iter()); + assert_send_sync(&history.search(Query::default())?); + + Ok(()) + } + + /// Like bash, an entry the file gives no timestamp gets the time it's read. + #[test] + fn import_stamps_entries_without_timestamps() -> Result<(), error::Error> { + let before = Utc::now(); + let history = History::import(&b"#1700000000\nls\necho hi\n"[..])?; + + let timestamps: Vec<_> = history.iter().map(|item| item.timestamp).collect(); + assert_eq!( + timestamps[0], + ItemTimestamp::from_timestamp(1_700_000_000, 0) + ); + assert!(timestamps[1].is_some_and(|ts| ts >= before)); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/int_utils.rs b/userland/brush/source/brush-core/src/int_utils.rs new file mode 100644 index 0000000..20e8e93 --- /dev/null +++ b/userland/brush/source/brush-core/src/int_utils.rs @@ -0,0 +1,67 @@ +//! Generic utilities. + +use crate::error; + +/// Trait for integer types that support parsing from strings with a radix. +pub trait ParseIntRadix: Sized { + /// Parse a string as this integer type using the specified radix. + fn from_str_radix(s: &str, radix: u32) -> Result; + + /// Returns the name of the integer type as a static string. + fn type_name() -> &'static str; +} + +macro_rules! impl_parse_int_radix { + ($t:ty) => { + impl ParseIntRadix for $t { + fn from_str_radix(s: &str, radix: u32) -> Result { + Self::from_str_radix(s, radix) + } + + fn type_name() -> &'static str { + stringify!($t) + } + } + }; +} + +impl_parse_int_radix!(u8); +impl_parse_int_radix!(u16); +impl_parse_int_radix!(i32); +impl_parse_int_radix!(u32); +impl_parse_int_radix!(usize); + +/// Parse the given string as an integer in the specified radix. +/// +/// # Arguments +/// +/// * `s` - The string to parse. +/// * `radix` - The base to use for parsing. +/// +/// # Type Parameters +/// +/// * `T` - The integer type to parse. Must implement `ParseIntRadix`. +/// +/// # Examples +/// +/// ``` +/// use brush_core::int_utils::parse; +/// +/// let result: u32 = parse("42", 10)?; +/// assert_eq!(result, 42); +/// +/// let result: u8 = parse("FF", 16)?; +/// assert_eq!(result, 255); +/// # Ok::<(), brush_core::error::Error>(()) +/// ``` +pub fn parse(s: &str, radix: u32) -> Result { + T::from_str_radix(s, radix).map_err(|inner| { + error::ErrorKind::IntParseError { + s: s.to_owned(), + int_type_name: T::type_name(), + radix, + inner, + } + .into() + }) +} diff --git a/userland/brush/source/brush-core/src/interfaces.rs b/userland/brush/source/brush-core/src/interfaces.rs new file mode 100644 index 0000000..1bb87ab --- /dev/null +++ b/userland/brush/source/brush-core/src/interfaces.rs @@ -0,0 +1,5 @@ +//! Exports traits for shell interfaces implemented by callers. + +mod keybindings; + +pub use keybindings::{InputFunction, Key, KeyAction, KeyBindings, KeySequence, KeyStroke}; diff --git a/userland/brush/source/brush-core/src/interfaces/keybindings.rs b/userland/brush/source/brush-core/src/interfaces/keybindings.rs new file mode 100644 index 0000000..c06f2a8 --- /dev/null +++ b/userland/brush/source/brush-core/src/interfaces/keybindings.rs @@ -0,0 +1,420 @@ +use std::{ + collections::HashMap, + fmt::{self, Display, Formatter}, +}; + +/// Represents an action that can be taken in response to a key sequence. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum KeyAction { + /// Execute a shell command. + ShellCommand(String), + /// Execute an input "function". + DoInputFunction(InputFunction), + /// Execute a sequence of actions (in order). + Sequence(Vec), +} + +impl Display for KeyAction { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::ShellCommand(command) => write!(f, "shell command: {command}"), + Self::DoInputFunction(function) => function.fmt(f), + Self::Sequence(actions) => { + write!(f, "sequence[")?; + for (i, action) in actions.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + action.fmt(f)?; + } + write!(f, "]") + } + } + } +} + +/// Defines all input functions. Based on standard `readline` functions, +/// augmented with some `brush`-specific extensions. +#[derive( + Clone, + Debug, + Eq, + Hash, + PartialEq, + strum_macros::EnumString, + strum_macros::Display, + strum_macros::EnumIter, + strum_macros::IntoStaticStr, +)] +#[strum(serialize_all = "kebab-case")] +#[expect(missing_docs)] +pub enum InputFunction { + Abort, + AcceptLine, + AliasExpandLine, + ArrowKeyPrefix, + BackwardByte, + BackwardChar, + BackwardDeleteChar, + BackwardKillLine, + BackwardKillWord, + BackwardWord, + BashViComplete, + BeginningOfHistory, + BeginningOfLine, + BracketedPasteBegin, + BrushAcceptHint, + BrushAcceptHintWord, + CallLastKbdMacro, + CapitalizeWord, + CharacterSearch, + CharacterSearchBackward, + ClearDisplay, + ClearScreen, + Complete, + CompleteCommand, + CompleteFilename, + CompleteHostname, + CompleteIntoBraces, + CompleteUsername, + CompleteVariable, + CopyBackwardWord, + CopyForwardWord, + CopyRegionAsKill, + DabbrevExpand, + DeleteChar, + DeleteCharOrList, + DeleteHorizontalSpace, + DigitArgument, + DisplayShellVersion, + DoLowercaseVersion, + DowncaseWord, + DumpFunctions, + DumpMacros, + DumpVariables, + DynamicCompleteHistory, + EditAndExecuteCommand, + EmacsEditingMode, + EndKbdMacro, + EndOfHistory, + EndOfLine, + ExchangePointAndMark, + ExecuteNamedCommand, + ExportCompletions, + FetchHistory, + ForwardBackwardDeleteChar, + ForwardByte, + ForwardChar, + ForwardSearchHistory, + ForwardWord, + GlobCompleteWord, + GlobExpandWord, + GlobListExpansions, + HistoryAndAliasExpandLine, + HistoryExpandLine, + HistorySearchBackward, + HistorySearchForward, + HistorySubstringSearchBackward, + HistorySubstringSearchForward, + InsertComment, + InsertCompletions, + InsertLastArgument, + KillLine, + KillRegion, + KillWholeLine, + KillWord, + MagicSpace, + MenuComplete, + MenuCompleteBackward, + NextHistory, + NextScreenLine, + NonIncrementalForwardSearchHistory, + NonIncrementalForwardSearchHistoryAgain, + NonIncrementalReverseSearchHistory, + NonIncrementalReverseSearchHistoryAgain, + OldMenuComplete, + OperateAndGetNext, + OverwriteMode, + PossibleCommandCompletions, + PossibleCompletions, + PossibleFilenameCompletions, + PossibleHostnameCompletions, + PossibleUsernameCompletions, + PossibleVariableCompletions, + PreviousHistory, + PreviousScreenLine, + PrintLastKbdMacro, + QuotedInsert, + ReReadInitFile, + RedrawCurrentLine, + ReverseSearchHistory, + RevertLine, + SelfInsert, + SetMark, + ShellBackwardKillWord, + ShellBackwardWord, + ShellExpandLine, + ShellForwardWord, + ShellKillWord, + ShellTransposeWords, + SkipCsiSequence, + SpellCorrectWord, + StartKbdMacro, + TabInsert, + TildeExpand, + TransposeChars, + TransposeWords, + TtyStatus, + Undo, + UniversalArgument, + UnixFilenameRubout, + UnixLineDiscard, + UnixWordRubout, + UpcaseWord, + ViAppendEol, + ViAppendMode, + ViArgDigit, + #[strum(serialize = "vi-bWord")] + ViBWord, + ViBackToIndent, + ViBackwardBigword, + ViBackwardWord, + ViBword, + ViChangeCase, + ViChangeChar, + ViChangeTo, + ViCharSearch, + ViColumn, + ViComplete, + ViDelete, + ViDeleteTo, + #[strum(serialize = "vi-eWord")] + ViEWord, + ViEditAndExecuteCommand, + ViEditingMode, + ViEndBigword, + ViEndWord, + ViEofMaybe, + ViEword, + #[strum(serialize = "vi-fWord")] + ViFWord, + ViFetchHistory, + ViFirstPrint, + ViForwardBigword, + ViForwardWord, + ViFword, + ViGotoMark, + ViInsertBeg, + ViInsertionMode, + ViMatch, + ViMovementMode, + ViNextWord, + ViOverstrike, + ViOverstrikeDelete, + ViPrevWord, + ViPut, + ViRedo, + ViReplace, + ViRubout, + ViSearch, + ViSearchAgain, + ViSetMark, + ViSubst, + ViTildeExpand, + ViUndo, + ViUnixWordRubout, + ViYankArg, + ViYankPop, + ViYankTo, + Yank, + YankLastArg, + YankNthArg, + YankPop, +} + +/// Represents a sequence of keys. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum KeySequence { + /// Strokes that make up the sequence. + Strokes(Vec), + /// Raw bytes that were used to generate this sequence. + Bytes(Vec>), +} + +impl Display for KeySequence { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Strokes(strokes) => { + for stroke in strokes { + stroke.fmt(f)?; + } + } + Self::Bytes(bytes) => { + for byte in bytes.iter().flatten() { + if !byte.is_ascii_control() { + write!(f, "{}", *byte as char)?; + } else if *byte == b'\x1b' { + write!(f, r"\e")?; + } else if *byte >= 0x01 && *byte <= 0x1A { + // Control characters: display as \C- + let letter = (b'a' + (*byte - 1)) as char; + write!(f, r"\C-{letter}")?; + } else { + write!(f, r"\x{byte:02x}")?; + } + } + } + } + + Ok(()) + } +} + +impl From for KeySequence { + /// Creates a new key sequence with a single stroke. + fn from(value: KeyStroke) -> Self { + Self::Strokes(vec![value]) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Represents a single key press. +pub struct KeyStroke { + /// Alt key was pressed. + pub alt: bool, + /// Control key was pressed. + pub control: bool, + /// Shift key was pressed. + pub shift: bool, + /// Primary key pressed. + pub key: Key, +} + +impl Display for KeyStroke { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if self.alt { + write!(f, "\\e")?; + } + if self.control { + write!(f, "\\C-")?; + } + if self.shift { + // TODO(input): Figure out what to do here or if the key encodes the shift in it. + } + self.key.fmt(f) + } +} + +impl From for KeyStroke { + /// Creates a new key stroke with a single key. + fn from(value: Key) -> Self { + Self { + alt: false, + control: false, + shift: false, + key: value, + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Represents a single key. +pub enum Key { + /// A simple character key. + Character(char), + /// Backspace key. + Backspace, + /// Enter key. + Enter, + /// Left arrow key. + Left, + /// Right arrow key. + Right, + /// Up arrow key. + Up, + /// Down arrow key. + Down, + /// Home key. + Home, + /// End key. + End, + /// Page up key. + PageUp, + /// Page down key. + PageDown, + /// Tab key. + Tab, + /// Shift + Tab key. + BackTab, + /// Delete key. + Delete, + /// Insert key. + Insert, + /// F key. + F(u8), + /// Escape key. + Escape, +} + +impl Display for Key { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Character(c @ ('\\' | '\"' | '\'')) => write!(f, "\\{c}")?, + Self::Character(c) => write!(f, "{c}")?, + Self::Backspace => write!(f, "Backspace")?, + Self::Enter => write!(f, "Enter")?, + Self::Left => write!(f, "Left")?, + Self::Right => write!(f, "Right")?, + Self::Up => write!(f, "Up")?, + Self::Down => write!(f, "Down")?, + Self::Home => write!(f, "Home")?, + Self::End => write!(f, "End")?, + Self::PageUp => write!(f, "PageUp")?, + Self::PageDown => write!(f, "PageDown")?, + Self::Tab => write!(f, "Tab")?, + Self::BackTab => write!(f, "BackTab")?, + Self::Delete => write!(f, "Delete")?, + Self::Insert => write!(f, "Insert")?, + Self::F(n) => write!(f, "F{n}")?, + Self::Escape => write!(f, "Esc")?, + } + + Ok(()) + } +} + +/// Encapsulates the shell's interaction with key bindings for input. +pub trait KeyBindings: Send { + /// Retrieves current bindings. + fn get_current(&self) -> HashMap; + + /// Tries to find a binding for an untranslated byte sequence. + fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction>; + + /// Sets or updates a binding. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to bind. + /// * `action` - The action to bind to the sequence. + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error>; + + /// Unbinds a key sequence. Returns true if a binding was removed. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to unbind. + fn try_unbind(&mut self, seq: KeySequence) -> bool; + + /// Defines a macro that remaps a key sequence to another key sequence. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to bind the macro to. + /// * `target` - The sequence that makes up the macro. + fn define_macro(&mut self, seq: KeySequence, target: KeySequence) + -> Result<(), std::io::Error>; + + /// Retrieves all defined macros. + fn get_macros(&self) -> HashMap; +} diff --git a/userland/brush/source/brush-core/src/interp.rs b/userland/brush/source/brush-core/src/interp.rs new file mode 100644 index 0000000..56d5183 --- /dev/null +++ b/userland/brush/source/brush-core/src/interp.rs @@ -0,0 +1,2183 @@ +use brush_parser::ast::{self, CommandPrefixOrSuffixItem}; +use itertools::Itertools; +use std::borrow::Cow; +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::arithmetic::{self, ExpandAndEvaluate}; +use crate::commands::{self, CommandArg}; +use crate::env::{EnvironmentLookup, EnvironmentScope, valid_variable_name}; +use crate::extensions::TaskSpawner as _; +use crate::openfiles::{OpenFile, OpenFiles}; +use crate::results::{ + ExecutionExitCode, ExecutionResult, ExecutionSpawnResult, ExecutionWaitResult, +}; +use crate::shell::Shell; +use crate::variables::{ + ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, +}; +use crate::{ + ShellFd, builtins, error, expansion, extendedtests, extensions, ioutils, jobs, openfiles, sys, + tasks, timing, +}; + +/// Encapsulates the context of execution in a command pipeline. +struct PipelineExecutionContext<'a, SE: extensions::ShellExtensions> { + /// The shell in which the command should be executed. + shell: commands::ShellForCommand<'a, SE>, + /// Process group ID for spawned processes. + process_group_id: Option, +} + +/// Parameters for execution. +#[derive(Clone, Default)] +pub struct ExecutionParameters { + /// The open files tracked by the current context. + open_files: openfiles::OpenFiles, + /// Policy for how to manage spawned external processes. + pub process_group_policy: ProcessGroupPolicy, + /// Whether `errexit` (exit on error) behavior should be + /// suppressed in this execution context. Defaults to `false`. + pub suppress_errexit: bool, +} + +impl ExecutionParameters { + /// Returns the standard input file; usable with `write!` et al. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdin( + &self, + shell: &Shell, + ) -> impl std::io::Read + 'static { + self.try_stdin(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard input not available").into() + }) + } + + /// Tries to retrieve the standard input file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdin(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDIN_FD) + } + + /// Returns the standard output file; usable with `write!` et al. In the event that + /// no such file is available, returns a valid implementation of `std::io::Write` + /// that fails all I/O requests. + /// + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdout( + &self, + shell: &Shell, + ) -> impl std::io::Write + 'static { + self.try_stdout(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard output not available").into() + }) + } + + /// Tries to retrieve the standard output file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdout(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDOUT_FD) + } + + /// Returns the standard error file; usable with `write!` et al. In the event that + /// no such file is available, returns a valid implementation of `std::io::Write` + /// that fails all I/O requests. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stderr( + &self, + shell: &Shell, + ) -> impl std::io::Write + 'static { + self.try_stderr(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard error not available").into() + }) + } + + /// Tries to retrieve the standard error file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stderr(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDERR_FD) + } + + /// Returns the file descriptor with the given number. Returns `None` + /// if the file descriptor is not open. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + /// * `fd` - The file descriptor number to retrieve. + pub fn try_fd( + &self, + shell: &Shell, + fd: ShellFd, + ) -> Option { + match self.open_files.fd_entry(fd) { + openfiles::OpenFileEntry::Open(f) => Some(f.clone()), + openfiles::OpenFileEntry::NotPresent => None, + openfiles::OpenFileEntry::NotSpecified => { + // We didn't have this fd specified one way or the other; we fallback + // to what's represented in the shell's open files. + shell.persistent_open_files().try_fd(fd).cloned() + } + } + } + + /// Sets the given file descriptor to the provided open file. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor number to set. + /// * `file` - The open file to set. + pub fn set_fd(&mut self, fd: ShellFd, file: openfiles::OpenFile) { + self.open_files.set_fd(fd, file); + } + + /// Iterates over all open file descriptors in this context. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn iter_fds( + &self, + shell: &Shell, + ) -> impl Iterator { + let our_fds = self.open_files.iter_fds(); + let shell_fds = shell + .persistent_open_files() + .iter_fds() + .filter(|(fd, _)| !self.open_files.contains_fd(*fd)); + + #[allow(clippy::needless_collect)] + let all_fds: Vec<_> = our_fds + .chain(shell_fds) + .map(|(fd, file)| (fd, file.clone())) + .collect(); + + all_fds.into_iter() + } +} + +#[derive(Clone, Debug, Default)] +/// Policy for how to manage spawned external processes. +pub enum ProcessGroupPolicy { + /// Place the process in a new process group. + #[default] + NewProcessGroup, + /// Place the process in the same process group as its parent. + SameProcessGroup, +} + +#[async_trait::async_trait] +pub trait Execute { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +trait ExecuteInPipeline { + async fn execute_in_pipeline( + &self, + context: PipelineExecutionContext<'_, SE>, + params: ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +impl Execute for ast::Program { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for command in &self.complete_commands { + // Execute the command and handle any errors without immediately propagating them. + // This allows interactive shells to continue executing subsequent commands even after + // errors. + match command.execute(shell, params).await { + Ok(exec_result) => result = exec_result, + Err(err) => { + // Display the error and convert to an execution result. + let _ = shell.display_error(&mut params.stderr(shell), &err); + result = err.into_result(shell); + } + } + + // Update status + shell.set_last_exit_status(result.exit_code.into()); + + // Check if we should stop executing subsequent commands + if !result.is_normal_flow() { + break; + } + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for ast::CompoundListItem(ao_list, sep) in &self.0 { + let run_async = matches!(sep, ast::SeparatorOperator::Async); + + if run_async { + let job = spawn_async_ao_list_in_task(ao_list, shell, params); + let job_formatted = job.to_pid_style_string(); + + if shell.options().interactive && !shell.is_subshell() { + writeln!(params.stderr(shell), "{job_formatted}")?; + } + + result = ExecutionResult::success(); + } else { + result = ao_list.execute(shell, params).await?; + + // Update status + shell.set_last_exit_status(result.exit_code.into()); + } + + if !result.is_normal_flow() { + break; + } + } + + Ok(result) + } +} + +/// Starts running a compound command in a subshell of its own. +/// +/// Borrowing `shell` for its task spawner inside an `async fn` makes the +/// compiler keep the whole `Shell` in that function's future across the next +/// `.await`, even after the task has taken it. Doing it here, in a function +/// that isn't `async`, keeps `Command::execute_in_pipeline`'s future small. +fn spawn_compound_in_subshell( + mut shell: Shell, + compound: ast::CompoundCommand, + params: ExecutionParameters, +) -> impl Future>> + use { + let spawner = shell.task_spawner().clone(); + spawner.spawn_blocking(async move { + let result = compound.execute(&mut shell, ¶ms).await; + Ok(shell.end_subshell(result, || params.try_stderr(&shell))) + }) +} + +fn spawn_async_ao_list_in_task<'a, SE: extensions::ShellExtensions>( + ao_list: &ast::AndOrList, + shell: &'a mut Shell, + params: &ExecutionParameters, +) -> &'a jobs::Job { + // Clone the inputs. + let mut cloned_shell = shell.clone(); + let mut cloned_params = params.clone(); + let cloned_ao_list = ao_list.clone(); + + // Mark the child shell as not interactive; we don't want it messing with the terminal too much. + cloned_shell.options_mut().interactive = false; + + // Redirect stdin to null, per spec. + if let Ok(null) = openfiles::null() { + cloned_params.set_fd(openfiles::OpenFiles::STDIN_FD, null); + } + + // The job runs in a shell of its own, so only its status leaves it. + let join_handle = shell.task_spawner().spawn(async move { + let result = cloned_ao_list + .execute(&mut cloned_shell, &cloned_params) + .await; + Ok(cloned_shell.end_subshell(result, || cloned_params.try_stderr(&cloned_shell))) + }); + + shell.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::Internal(join_handle)], + ao_list.to_string(), + jobs::JobState::Running, + )) +} + +#[async_trait::async_trait] +impl Execute for ast::AndOrList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let has_operators = !self.additional.is_empty(); + + // For the first command, suppress errexit if there are more commands after it + let mut first_params = params.clone(); + if has_operators { + first_params.suppress_errexit = true; + } + + let mut result = self.first.execute(shell, &first_params).await?; + + for (index, next_ao) in self.additional.iter().enumerate() { + // Check for non-normal control flow. + if !result.is_normal_flow() { + break; + } + + let (is_and, pipeline) = match next_ao { + ast::AndOr::And(p) => (true, p), + ast::AndOr::Or(p) => (false, p), + }; + + // If we short-circuit, then we don't break out of the whole loop + // but we skip evaluating the current pipeline. We'll then continue + // on and possibly evaluate a subsequent one (depending on the + // operator before it). + if is_and { + if !result.is_success() { + continue; + } + } else if result.is_success() { + continue; + } + + // For the last command in the chain, use original params (errexit not suppressed) + // For earlier commands, suppress errexit + let mut params = params.clone(); + + let is_last = index == self.additional.len() - 1; + if !is_last { + params.suppress_errexit = true; + } + + result = pipeline.execute(shell, ¶ms).await?; + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::Pipeline { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // Capture current timing if so requested. + let stopwatch = self + .timed + .is_some() + .then(timing::start_timing) + .transpose()?; + + let mut params = params.clone(); + + // If this pipeline is negated, suppress errexit for commands within it + if self.bang { + params.suppress_errexit = true; + } + + // Spawn all the processes required for the pipeline, connecting outputs/inputs with pipes + // as needed. + let spawn_results = spawn_pipeline_processes(self, shell, ¶ms).await?; + + // Wait for the processes. This also has a side effect of updating pipeline status. + let mut result = + wait_for_pipeline_processes_and_update_status(self, spawn_results, shell, ¶ms) + .await?; + + // Invert the exit code if requested. + if self.bang { + result.exit_code = ExecutionExitCode::from(if result.is_success() { 1 } else { 0 }); + } + + // Update exit status. + shell.set_last_exit_status(result.exit_code.into()); + + // Fire the ERR trap if the pipeline failed in a non-conditional context. + // We reuse `suppress_errexit` here because bash suppresses the ERR trap in + // exactly the same contexts it suppresses errexit (conditionals, `!`-prefixed + // pipelines, etc.). A pipeline that leaves this shell through `exit` or `return` + // hasn't failed; its status is the one it leaves with. Only a stage run in this + // shell can leave it: a stage in a shell of its own ended as a subshell, so + // `: | exit 3` comes back here as a plain failed status. + if !result.is_success() && result.is_normal_flow() && !params.suppress_errexit && !self.bang + { + if shell.traps().handles(crate::traps::TrapSignal::Err) { + let trap_result = shell + .invoke_trap_handler(crate::traps::TrapSignal::Err, ¶ms) + .await?; + + // An `exit` or `return` in the handler takes effect here, with its own status. + // + // TODO(traps): so should a `break` or `continue`, keeping the failed pipeline's + // status, but the ERR trap also fires for the compound command around the + // pipeline, where it would break again. Tracked by the compat case "trap ERR - + // break in the handler ends the loop". + if trap_result.is_return_or_exit() { + result = trap_result; + shell.set_last_exit_status(result.exit_code.into()); + } + } + } + + // Apply errexit if not suppressed (and not negated) + if !params.suppress_errexit && !self.bang { + shell.apply_errexit_if_enabled(&mut result); + } + + // If requested, report timing. + if let (Some(timed), Some(stopwatch)) = (&self.timed, &stopwatch) + && let Some(mut stderr) = params.try_fd(shell, openfiles::OpenFiles::STDERR_FD) + { + let timing = stopwatch.stop()?; + if timed.is_posix_output() { + std::write!( + stderr, + "real {}\nuser {}\nsys {}\n", + timing::format_duration_posixly(&timing.wall), + timing::format_duration_posixly(&timing.user), + timing::format_duration_posixly(&timing.system), + )?; + } else { + std::write!( + stderr, + "\nreal\t{}\nuser\t{}\nsys\t{}\n", + timing::format_duration_non_posixly(&timing.wall), + timing::format_duration_non_posixly(&timing.user), + timing::format_duration_non_posixly(&timing.system), + )?; + } + } + + Ok(result) + } +} + +async fn spawn_pipeline_processes( + pipeline: &ast::Pipeline, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result, error::Error> { + let pipeline_len = pipeline.seq.len(); + let mut pipe_readers = vec![]; + let mut pipe_writers = vec![]; + let mut spawn_results = VecDeque::new(); + let mut process_group_id: Option = None; + + // Create pipes to use between commands, but only bother doing so if there's more than one + // command. + if pipeline_len > 1 { + pipe_readers.reserve_exact(pipeline_len - 1); + pipe_writers.reserve_exact(pipeline_len - 1); + + for _ in 0..(pipeline_len - 1) { + let (reader, writer) = sys::pipes::pipe()?; + pipe_readers.push(Some(reader.into())); + pipe_writers.push(Some(writer.into())); + } + // Push `None` to the readers; it will be popped off by the *first* command, which will + // mean that command gets its stdin from the execution parameters' current stdin. + pipe_readers.push(None); + } + + for (current_pipeline_index, command) in pipeline.seq.iter().enumerate() { + // + // We run a command directly in the current shell if either of the following is true: + // * There's only one command in the pipeline. + // * This is the *last* command in the pipeline, the lastpipe option is enabled, and job + // monitoring is disabled. + // Otherwise, we spawn a separate subshell for each command in the pipeline. + // + + let run_in_current_shell = pipeline_len == 1 + || (current_pipeline_index == pipeline_len - 1 + && shell.options().run_last_pipeline_cmd_in_current_shell + && !shell.options().enable_job_control); + + // Set up parameters appropriate for this command. + let mut cmd_params = params.clone(); + + // Install pipes. + if let Some(Some(reader)) = pipe_readers.pop() { + cmd_params.open_files.set_fd(OpenFiles::STDIN_FD, reader); + } + if let Some(Some(writer)) = pipe_writers.pop() { + cmd_params.open_files.set_fd(OpenFiles::STDOUT_FD, writer); + } + + // A stage in a shell of its own is a subshell: an error ends the stage + // alone, reported on the stage's standard error. + let stage_stderr = if run_in_current_shell { + None + } else { + cmd_params.try_stderr(shell) + }; + + let pipeline_context = if !run_in_current_shell { + // Make sure that all commands in the pipeline are in the same process group. + if current_pipeline_index > 0 { + cmd_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + } + + PipelineExecutionContext { + shell: commands::ShellForCommand::OwnedShell { + target: Box::new(shell.clone()), + parent: shell, + }, + process_group_id, + } + } else { + PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(shell), + process_group_id, + } + }; + + let spawn_result = match command + .execute_in_pipeline(pipeline_context, cmd_params) + .await + { + // A stage in a shell of its own that's done already ends here. + Ok(ExecutionSpawnResult::Completed(result)) if !run_in_current_shell => { + shell.end_subshell(Ok(result), || stage_stderr).into() + } + Ok(spawn_result) => spawn_result, + Err(error) if !run_in_current_shell => { + shell.end_subshell(Err(error), || stage_stderr).into() + } + Err(error) => return Err(error), + }; + + // Update the process group ID if something was spawned. + if let ExecutionSpawnResult::StartedProcess(child) = &spawn_result { + if process_group_id.is_none() { + process_group_id = child.pgid(); + } + } + + spawn_results.push_back(spawn_result); + } + + Ok(spawn_results) +} + +async fn wait_for_pipeline_processes_and_update_status( + pipeline: &ast::Pipeline, + mut process_spawn_results: VecDeque, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + let mut result = ExecutionResult::success(); + let mut stopped_children = vec![]; + let mut last_failure_exit_code: Option = None; + + // Clear our the pipeline status so we can start filling it out. + shell.last_pipeline_statuses_mut().clear(); + + while let Some(child) = process_spawn_results.pop_front() { + let wait_result = if !stopped_children.is_empty() { + child.poll().await? + } else { + child.wait().await? + }; + + match wait_result { + ExecutionWaitResult::Completed(current_result) => { + result = current_result; + shell.set_last_exit_status(result.exit_code.into()); + shell + .last_pipeline_statuses_mut() + .push(result.exit_code.into()); + + // Track the last failure for pipefail option + if !result.is_success() { + last_failure_exit_code = Some(result.exit_code); + } + } + ExecutionWaitResult::Stopped(child) => { + result = ExecutionResult::stopped(); + shell.set_last_exit_status(result.exit_code.into()); + shell + .last_pipeline_statuses_mut() + .push(result.exit_code.into()); + + stopped_children.push(jobs::JobTask::External(child)); + } + } + } + + // Apply pipefail semantics if enabled + if shell.options().return_last_failure_from_pipeline { + if let Some(failure_exit_code) = last_failure_exit_code { + result.exit_code = failure_exit_code; + } + } + + if shell.options().interactive { + sys::terminal::move_self_to_foreground()?; + } + + // If there were stopped jobs, then encapsulate the pipeline as a managed job and hand it + // off to the job manager. + if !stopped_children.is_empty() { + let job = shell.jobs_mut().add_as_current(jobs::Job::new( + stopped_children, + pipeline.to_string(), + jobs::JobState::Stopped, + )); + + let formatted = job.to_string(); + + // N.B. We use the '\r' to overwrite any ^Z output. + writeln!(params.stderr(shell), "\r{formatted}")?; + } + + Ok(result) +} + +#[async_trait::async_trait] +impl ExecuteInPipeline for ast::Command { + async fn execute_in_pipeline( + &self, + mut pipeline_context: PipelineExecutionContext<'_, SE>, + mut params: ExecutionParameters, + ) -> Result { + if pipeline_context.shell.options().do_not_execute_commands { + return Ok(ExecutionSpawnResult::Completed(ExecutionResult::success())); + } + + // Updates the shell with information about the currently executing command. + pipeline_context.shell.set_current_cmd(self); + + match self { + Self::Simple(simple) => simple.execute_in_pipeline(pipeline_context, params).await, + Self::Compound(compound, redirects) => { + // Set up any additional redirects. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + setup_redirect(&mut pipeline_context.shell, &mut params, redirect).await?; + } + } + + match pipeline_context.shell { + // An owned shell is a discarded clone, so this stage can run asynchronously. + // That lets the pipeline start readers before writers fill the pipe. + commands::ShellForCommand::OwnedShell { target, .. } => { + let task = spawn_compound_in_subshell(*target, compound.clone(), params); + Ok(ExecutionSpawnResult::StartedTask(task.await)) + } + commands::ShellForCommand::ParentShell(shell) => { + Ok(compound.execute(shell, ¶ms).await?.into()) + } + } + } + Self::Function(func) => Ok(func + .execute(&mut pipeline_context.shell, ¶ms) + .await? + .into()), + } + } +} + +enum WhileOrUntil { + While, + Until, +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + match self { + Self::BraceGroup(ast::BraceGroupCommand { list, .. }) => { + list.execute(shell, params).await + } + Self::Subshell(ast::SubshellCommand { list, .. }) => { + // Clone off a new subshell, and run the body of the subshell there. + // TODO(source-info): Do we need to reset the line number? + let mut subshell = shell.clone(); + + // Handle errors within the subshell context to prevent fatal errors + // from propagating to the parent shell. + let result = list.execute(&mut subshell, params).await; + Ok(shell.end_subshell(result, || Some(params.stderr(shell)))) + } + Self::ForClause(f) => f.execute(shell, params).await, + Self::CaseClause(c) => c.execute(shell, params).await, + Self::IfClause(i) => i.execute(shell, params).await, + Self::WhileClause(w) => (WhileOrUntil::While, w).execute(shell, params).await, + Self::UntilClause(u) => (WhileOrUntil::Until, u).execute(shell, params).await, + Self::Arithmetic(a) => a.execute(shell, params).await, + Self::ArithmeticForClause(a) => a.execute(shell, params).await, + Self::Coprocess(c) => c.execute(shell, params).await, + Self::ExtendedTest(e) => { + let result = + if extendedtests::eval_extended_test_expr(&e.expr, shell, params).await? { + 0 + } else { + 1 + }; + Ok(ExecutionResult::new(result)) + } + } + } +} + +#[async_trait::async_trait] +impl Execute for ast::CoprocessCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + if shell.options().do_not_execute_commands { + return Ok(ExecutionResult::success()); + } + + // Resolve the name of the variable that will receive the coprocess's file descriptors. + let name = self + .name + .as_ref() + .map_or(Cow::Borrowed("COPROC"), |w| Cow::Owned(w.to_string())); + + if !valid_variable_name(&name) { + writeln!( + params.stderr(shell), + "coproc {name}: not a valid identifier" + )?; + return Ok(ExecutionExitCode::GeneralError.into()); + } + + // Set up the pipes that we'll use to communicate with the coprocess. + let (stdin_reader, stdin_writer) = sys::pipes::pipe()?; + let (stdout_reader, stdout_writer) = sys::pipes::pipe()?; + + // Allocate new fds in the (parent) shell for the read end of the coprocess's stdout + // and the write end of the coprocess's stdin. + let stdout_fd = shell.open_files_mut().add(stdout_reader.into())?; + let stdin_fd = shell.open_files_mut().add(stdin_writer.into())?; + + // Crete a subshell that the coprocess will own and run in. + let mut child_shell = shell.clone(); + child_shell.options_mut().interactive = false; + + // Setup redirection for the coprocess's shell's stdin/stdout. + let mut child_params = params.clone(); + child_params + .open_files + .set_fd(OpenFiles::STDIN_FD, stdin_reader.into()); + child_params + .open_files + .set_fd(OpenFiles::STDOUT_FD, stdout_writer.into()); + + let body = self.body.clone(); + let join_handle = shell.task_spawner().spawn(async move { + let pipeline_context = PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(&mut child_shell), + process_group_id: None, + }; + let spawn_result = body + .execute_in_pipeline(pipeline_context, child_params) + .await?; + match spawn_result.wait().await? { + ExecutionWaitResult::Completed(result) => Ok(result), + ExecutionWaitResult::Stopped(_) => Ok(ExecutionResult::stopped()), + } + }); + + let job = shell.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::Internal(join_handle)], + format!("coproc {name}"), + jobs::JobState::Running, + )); + let job_id = job.id; + + // Fill out the fd variable. + let arr_value = ShellValue::from(vec![stdout_fd.to_string(), stdin_fd.to_string()]); + shell + .env_mut() + .set_global(name.clone(), ShellVariable::new(arr_value))?; + + // Set the job ID for the coprocess in a separate variable with the _PID suffix. + let pid_name = format!("{name}_PID"); + shell + .env_mut() + .set_global(pid_name, ShellVariable::new(job_id.to_string()))?; + + Ok(ExecutionResult::success()) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + // If we were given explicit words to iterate over, then expand them all, with splitting + // enabled. + let expanded_values = if let Some(unexpanded_values) = &self.values { + expand_words(shell, params, unexpanded_values).await? + } else { + // Otherwise, we use the current positional parameters. + shell.current_shell_args().to_vec() + }; + + for value in expanded_values { + if shell.options().print_commands_and_arguments { + if let Some(unexpanded_values) = &self.values { + shell + .trace_command( + params, + std::format!( + "for {} in {}", + self.variable_name, + unexpanded_values.iter().join(" ") + ), + ) + .await; + } else { + shell + .trace_command(params, std::format!("for {}", self.variable_name)) + .await; + } + } + + // Update the variable. + shell.env_mut().update_or_add( + &self.variable_name, + ShellValueLiteral::Scalar(value), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CaseClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // N.B. One would think it makes sense to trace the expanded value being switched + // on, but that's not it. + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("case {} in", self.value)) + .await; + } + + let expanded_value = expansion::basic_expand_word(shell, params, &self.value).await?; + let mut result: ExecutionResult = ExecutionResult::success(); + let mut force_execute_next_case = false; + + for case in &self.cases { + if force_execute_next_case { + force_execute_next_case = false; + } else { + let mut matches = false; + for pattern in &case.patterns { + let expanded_pattern = expansion::basic_expand_pattern(shell, params, pattern) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if expanded_pattern.exactly_matches(expanded_value.as_str())? { + matches = true; + break; + } + } + + if !matches { + continue; + } + } + + result = if let Some(case_cmd) = &case.cmd { + case_cmd.execute(shell, params).await? + } else { + ExecutionResult::success() + }; + + // Check for early return (return/exit) or loop control flow (break/continue) + if !result.is_normal_flow() { + break; + } + + match case.post_action { + ast::CaseItemPostAction::ExitCase => break, + ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem => { + force_execute_next_case = true; + } + ast::CaseItemPostAction::ContinueEvaluatingCases => (), + } + } + + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::IfClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // Execute condition with errexit suppressed + let mut condition_params = params.clone(); + condition_params.suppress_errexit = true; + let condition = self.condition.execute(shell, &condition_params).await?; + + // Check if the condition itself resulted in non-normal control flow. + if !condition.is_normal_flow() { + return Ok(condition); + } + + if condition.is_success() { + return self.then.execute(shell, params).await; + } + + if let Some(elses) = &self.elses { + for else_clause in elses { + match &else_clause.condition { + Some(else_condition) => { + let else_condition_result = + else_condition.execute(shell, &condition_params).await?; + + // Check if the elif condition caused non-normal control flow. + if !else_condition_result.is_normal_flow() { + return Ok(else_condition_result); + } + + if else_condition_result.is_success() { + return else_clause.body.execute(shell, params).await; + } + } + None => { + return else_clause.body.execute(shell, params).await; + } + } + } + } + + // If we got down here, then no branch was taken; we make sure to + // reset the last exit status to success and then return success. + let result = ExecutionResult::success(); + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for (WhileOrUntil, &ast::WhileOrUntilClauseCommand) { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let is_while = match self.0 { + WhileOrUntil::While => true, + WhileOrUntil::Until => false, + }; + let test_condition = &self.1.0; + let body = &self.1.1; + + let mut result = ExecutionResult::success(); + + // Execute loop condition with errexit suppressed + let mut condition_params = params.clone(); + condition_params.suppress_errexit = true; + + loop { + let condition_result = test_condition.execute(shell, &condition_params).await?; + + // Update status for condition + shell.set_last_exit_status(condition_result.exit_code.into()); + + if !condition_result.is_normal_flow() { + result = condition_result; + + // If the condition has break/continue, the while/until loop itself + // consumes one level. We need to decrement the level before returning. + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + break; + } + + if condition_result.is_success() != is_while { + break; + } + + result = body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let value = self.expr.eval(shell, params, true).await?; + let result = if value != 0 { + ExecutionResult::success() + } else { + ExecutionResult::general_error() + }; + + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + if let Some(initializer) = &self.initializer { + initializer.eval(shell, params, true).await?; + } + + loop { + if let Some(condition) = &self.condition { + // An empty condition (e.g., `for (( ; ; ))`) means "always true". + if !condition.value.is_empty() && condition.eval(shell, params, true).await? == 0 { + break; + } + } + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + + if let Some(updater) = &self.updater { + updater.eval(shell, params, true).await?; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::FunctionDefinition { + async fn execute( + &self, + shell: &mut Shell, + _params: &ExecutionParameters, + ) -> Result { + let func_name = self.fname.value.clone(); + + // In POSIX mode, function names can't shadow special builtins. + if shell.options().posix_mode + && shell + .builtins() + .get(&func_name) + .is_some_and(|r| r.is_special()) + { + return Err( + error::Error::from(error::ErrorKind::FunctionNameShadowsSpecialBuiltin { + name: func_name, + }) + .into_fatal(), + ); + } + + // The function definition's source context should be the same as the current frame + // so we directly pass that through. + let source_info = shell + .call_stack() + .current_frame() + .map_or_else(crate::SourceInfo::default, |frame| { + frame.adjusted_source_info() + }); + shell.define_func(func_name, self.clone(), &source_info); + + let result = ExecutionResult::success(); + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +#[allow(clippy::too_many_lines)] +impl ExecuteInPipeline for ast::SimpleCommand { + async fn execute_in_pipeline( + &self, + mut context: PipelineExecutionContext<'_, SE>, + mut params: ExecutionParameters, + ) -> Result { + let prefix_iter = self.prefix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let suffix_iter = self.suffix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let cmd_name_items = self + .word_or_name + .as_ref() + .map(|won| CommandPrefixOrSuffixItem::Word(won.clone())); + + let mut assignments = vec![]; + let mut args: Vec = vec![]; + let mut command_takes_assignments = false; + + // Capture the status change count before expansion, so we can detect + // if expansion (e.g., command substitution) set an exit status. + let status_change_count_before_expansion = context.shell.last_exit_status_change_count(); + + for item in prefix_iter.chain(cmd_name_items.iter()).chain(suffix_iter) { + match item { + CommandPrefixOrSuffixItem::IoRedirect(redirect) => { + if let Err(e) = setup_redirect(&mut context.shell, &mut params, redirect).await + { + writeln!(params.stderr(&context.shell), "error: {e}")?; + return Ok(ExecutionResult::general_error().into()); + } + } + CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell_command) => { + let (installed_fd_num, substitution_file) = setup_process_substitution( + &context.shell, + ¶ms, + kind, + subshell_command, + )?; + + params + .open_files + .set_fd(installed_fd_num, substitution_file); + + args.push(CommandArg::String(std::format!( + "/dev/fd/{installed_fd_num}" + ))); + } + CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => { + if args.is_empty() { + // If we haven't yet seen any arguments, then this must be a proper + // scoped assignment. Add it to the list we're accumulating. + assignments.push(assignment); + } else { + if command_takes_assignments { + // This looks like an assignment, and the command being invoked is a + // well-known builtin that takes arguments that need to function like + // assignments (but which are processed by the builtin). + let expanded = + expand_assignment(&mut context.shell, ¶ms, assignment).await?; + args.push(CommandArg::Assignment(expanded)); + } else { + // This *looks* like an assignment, but it's really a string we should + // fully treat as a regular looking + // argument. + let mut next_args = expansion::full_expand_and_split_word( + &mut context.shell, + ¶ms, + word, + ) + .await? + .into_iter() + .map(CommandArg::String) + .collect(); + args.append(&mut next_args); + } + } + } + CommandPrefixOrSuffixItem::Word(arg) => { + let mut next_args = + expansion::full_expand_and_split_word(&mut context.shell, ¶ms, arg) + .await?; + + if args.is_empty() { + if let Some(cmd_name) = next_args.first() { + // Aliases are only expanded when `expand_aliases` is enabled; it's + // enabled by default for interactive shells. + if context.shell.options().expand_aliases + && let Some(alias_value) = + context.shell.aliases().get(cmd_name.as_str()) + { + // + // TODO(#57): This is a total hack; aliases are supposed to be + // handled much earlier in the process. + // + // N.B. Tokenizing first releases our borrow of the shell's aliases, + // so we can take a mutable borrow of the shell to expand the words. + let alias_words = tokenize_alias_body(&context.shell, alias_value); + let mut alias_pieces = + expand_words(&mut context.shell, ¶ms, alias_words).await?; + + next_args.remove(0); + alias_pieces.append(&mut next_args); + + next_args = alias_pieces; + } + + // Check if we're going to be invoking a special declaration builtin. + // That will change how we parse and process args. (An alias with an + // empty body leaves us with no words at all.) + if let Some(first_arg) = next_args.first() + && context + .shell + .builtins() + .get(first_arg.as_str()) + .is_some_and(|r| { + r.is_enabled() + && r.declaration_role() + == builtins::DeclarationRole::Utility + }) + { + command_takes_assignments = true; + } + } + } + + let mut next_args = next_args.into_iter().map(CommandArg::String).collect(); + args.append(&mut next_args); + } + } + } + + // If we have a command, then execute it. + if let Some(CommandArg::String(cmd_name)) = args.first() { + let cmd_name = cmd_name.clone(); + let mut stderr = params.stderr(&context.shell); + + let (owned_shell, parent_shell) = match context.shell { + commands::ShellForCommand::ParentShell(shell) => (None, shell), + commands::ShellForCommand::OwnedShell { target, parent } => (Some(target), parent), + }; + let in_owned_shell = owned_shell.is_some(); + + let shell = if let Some(owned_shell) = owned_shell { + commands::ShellForCommand::OwnedShell { + target: owned_shell, + parent: parent_shell, + } + } else { + commands::ShellForCommand::ParentShell(parent_shell) + }; + + let context = PipelineExecutionContext { + shell, + process_group_id: context.process_group_id, + }; + + match execute_command(context, params, cmd_name, &assignments, args).await { + Ok(result) => Ok(result), + // In a shell of its own, as an expansion error in a prefix + // assignment is, an error ends the stage alone. + Err(err) if in_owned_shell => { + Ok(parent_shell.end_subshell(Err(err), || Some(stderr)).into()) + } + Err(err) => { + let _ = parent_shell.display_error(&mut stderr, &err); + + let result = err.into_result(parent_shell); + Ok(result.into()) + } + } + } else { + // No command to run; assignments must be applied to this shell. + for assignment in assignments { + // Apply the assignment. Don't mark as fatal - let errors be handled + // at the program level so multiple complete_commands can execute independently. + apply_assignment( + assignment, + &mut context.shell, + ¶ms, + false, + None, + EnvironmentScope::Global, + ) + .await?; + } + + // Assignment-only statements clear $_ (set to empty string). + // This matches bash behavior where assignments don't have a "last + // argument". + context.shell.update_last_arg_variable(None); + + // We need to set the last exit status to indicate assignment success, + // but only if there was no status set during expansion. We use the + // status count captured before expansion to detect if command + // substitution (or other expansion) set an exit status. + if status_change_count_before_expansion == context.shell.last_exit_status_change_count() + { + context.shell.set_last_exit_status(0); + } + + // Return the last exit status we have; in some cases, an expansion + // might result in a non-zero exit status stored in the shell. + Ok(ExecutionResult::new(context.shell.last_exit_status()).into()) + } + } +} + +async fn execute_command( + context: PipelineExecutionContext<'_, impl extensions::ShellExtensions>, + params: ExecutionParameters, + cmd_name: String, + assignments: &[&ast::Assignment], + args: Vec, +) -> Result { + // Run the command in a new ephemeral environment scope, for its variable assignments. + let mut scoped; + let mut shell = match context.shell { + commands::ShellForCommand::ParentShell(shell) => { + // The scope is left however the command ends, even if it's cancelled. + scoped = shell.enter_scope(EnvironmentScope::Command); + commands::ShellForCommand::ParentShell(&mut scoped) + } + commands::ShellForCommand::OwnedShell { mut target, parent } => { + // The command's shell is discarded after it runs, scope and all. + target.env_mut().push_scope(EnvironmentScope::Command); + commands::ShellForCommand::OwnedShell { target, parent } + } + }; + + for assignment in assignments { + // Ensure it's tagged as exported and created in the command scope. + apply_assignment( + assignment, + &mut shell, + ¶ms, + true, + Some(EnvironmentScope::Command), + EnvironmentScope::Command, + ) + .await?; + } + + if shell.options().print_commands_and_arguments { + shell + .trace_command( + ¶ms, + args.iter().map(|arg| arg.quote_for_tracing()).join(" "), + ) + .await; + } + + // Construct the command struct. + let mut cmd = commands::SimpleCommand::new(shell, params, cmd_name, args); + cmd.process_group_id = context.process_group_id; + + // Run through any pre-execution hooks as best effort. + let _ = commands::on_preexecute(&mut cmd).await; + + // Execute + // TODO(jobs): do we need to move self back to foreground on error here? + cmd.execute().await +} + +/// Tokenizes the body of an alias into the unexpanded words that should replace the aliased command +/// name. +/// +/// This only handles alias bodies that amount to a simple sequence of words; bodies containing +/// operators (pipes, redirections, `&&`, ...) and recursive expansion of chained aliases require +/// alias substitution to happen in the tokenizer instead (see issue #57). +fn tokenize_alias_body( + shell: &Shell, + alias_value: &str, +) -> Vec { + // Tokenize the body the same way the shell would tokenize any other input it's given. + let options = shell.parser_options().tokenizer_options(); + + // If we can't tokenize the body, fall back to naively splitting it on whitespace. + brush_parser::tokenize_str_with_options(alias_value, &options).map_or_else( + |_| { + alias_value + .split_ascii_whitespace() + .map(str::to_owned) + .collect() + }, + |tokens| tokens.iter().map(|t| t.to_str().to_owned()).collect(), + ) +} + +/// Expands the given words, with splitting enabled, yielding the fields they expand to. +async fn expand_words( + shell: &mut Shell, + params: &ExecutionParameters, + words: impl IntoIterator>, +) -> Result, error::Error> { + // N.B. Expansion needs `&mut shell`, so the words have to be expanded in sequence. + let mut fields = vec![]; + for word in words { + fields.extend(expansion::full_expand_and_split_word(shell, params, word).await?); + } + Ok(fields) +} + +async fn expand_assignment( + shell: &mut Shell, + params: &ExecutionParameters, + assignment: &ast::Assignment, +) -> Result { + let value = expand_assignment_value(shell, params, &assignment.value).await?; + Ok(ast::Assignment { + name: basic_expand_assignment_name(shell, params, &assignment.name).await?, + value, + append: assignment.append, + loc: assignment.loc.clone(), + }) +} + +async fn basic_expand_assignment_name( + shell: &mut Shell, + params: &ExecutionParameters, + name: &ast::AssignmentName, +) -> Result { + match name { + ast::AssignmentName::VariableName(name) => { + let expanded = expansion::basic_expand_word(shell, params, name).await?; + Ok(ast::AssignmentName::VariableName(expanded)) + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded_name = expansion::basic_expand_word(shell, params, name).await?; + let expanded_index = expansion::basic_expand_word(shell, params, index).await?; + Ok(ast::AssignmentName::ArrayElementName( + expanded_name, + expanded_index, + )) + } + } +} + +async fn expand_assignment_value( + shell: &mut Shell, + params: &ExecutionParameters, + value: &ast::AssignmentValue, +) -> Result { + let expanded = match value { + ast::AssignmentValue::Scalar(s) => { + let expanded_word = expansion::basic_expand_assignment_word(shell, params, s).await?; + ast::AssignmentValue::Scalar(ast::Word::from(expanded_word)) + } + ast::AssignmentValue::Array(arr) => { + let mut expanded_values = vec![]; + for (key, value) in arr { + if let Some(k) = key { + let expanded_key = expansion::basic_expand_assignment_word(shell, params, k) + .await? + .into(); + let expanded_value = + expansion::basic_expand_assignment_word(shell, params, value) + .await? + .into(); + expanded_values.push((Some(expanded_key), expanded_value)); + } else { + // Array elements are treated as regular words, not assignments + let split_expanded_value = + expansion::full_expand_and_split_word(shell, params, value).await?; + for expanded_value in split_expanded_value { + expanded_values.push((None, expanded_value.into())); + } + } + } + + ast::AssignmentValue::Array(expanded_values) + } + }; + + Ok(expanded) +} + +#[expect(clippy::too_many_lines)] +async fn apply_assignment( + assignment: &ast::Assignment, + shell: &mut Shell, + params: &ExecutionParameters, + mut export: bool, + required_scope: Option, + creation_scope: EnvironmentScope, +) -> Result<(), error::Error> { + // Figure out if we are trying to assign to a variable or assign to an element of an existing + // array. + let mut array_index; + let variable_name = match &assignment.name { + ast::AssignmentName::VariableName(name) => { + array_index = None; + name + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded = expansion::basic_expand_word(shell, params, index).await?; + array_index = Some(expanded); + name + } + }; + + // Expand the values. + let new_value = match &assignment.value { + ast::AssignmentValue::Scalar(unexpanded_value) => { + let value = + expansion::basic_expand_assignment_word(shell, params, unexpanded_value).await?; + ShellValueLiteral::Scalar(value) + } + ast::AssignmentValue::Array(unexpanded_values) => { + let mut elements = vec![]; + for (unexpanded_key, unexpanded_value) in unexpanded_values { + let key = match unexpanded_key { + Some(unexpanded_key) => Some( + expansion::basic_expand_assignment_word(shell, params, unexpanded_key) + .await?, + ), + None => None, + }; + + if key.is_some() { + let value = + expansion::basic_expand_assignment_word(shell, params, unexpanded_value) + .await?; + elements.push((key, value)); + } else { + // Array elements are treated as regular words, not assignments + let values = + expansion::full_expand_and_split_word(shell, params, unexpanded_value) + .await?; + for value in values { + elements.push((None, value)); + } + } + } + ShellValueLiteral::Array(ArrayLiteral(elements)) + } + }; + + if shell.options().print_commands_and_arguments { + let op = if assignment.append { "+=" } else { "=" }; + shell + .trace_command(params, std::format!("{}{op}{new_value}", assignment.name)) + .await; + } + + // See if we need to eval an array index. + if let Some(idx) = &array_index { + // An array subscript is arithmetically evaluated unless the target is an + // associative array (in which case the subscript is used as a literal key). + // A scalar or unset/untyped variable becomes an indexed array, so its + // subscript still needs to be evaluated. + let will_be_indexed_array = + if let Some((_, existing_value)) = shell.env().get(variable_name) { + !matches!( + existing_value.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + true + }; + + if will_be_indexed_array { + array_index = Some( + arithmetic::expand_and_eval(shell, params, idx.as_str(), false) + .await? + .to_string(), + ); + } + } + + // Read option before taking mutable borrow on env. + let export_variables_on_modification = shell.options().export_variables_on_modification; + + // Like bash, a variable for one command can't override a readonly one: report that and + // skip this assignment. The command still runs. + if required_scope.is_some() + && shell + .env() + .get(variable_name.as_str()) + .is_some_and(|(_, var)| var.is_readonly()) + { + let err = error::Error::from(error::ErrorKind::ReadonlyVariable); + let _ = shell.display_error(&mut params.stderr(shell), &err); + return Ok(()); + } + + // See if we can find an existing value associated with the variable. A variable for one + // command (e.g. `FOO=1 cmd`) can only be one already set for that same command, in its + // own scope -- not one set for a command it was called from. + let lookup = required_scope.map_or( + EnvironmentLookup::Anywhere, + EnvironmentLookup::OnlyInCurrent, + ); + let existing_value = shell + .env_mut() + .get_mut_using_policy(variable_name.as_str(), lookup); + if let Some(existing_value) = existing_value { + if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + existing_value.assign_at_index(array_index, s, assignment.append)?; + } + ShellValueLiteral::Array(_) => { + return error::unimp("replacing an array item with an array"); + } + } + } else { + if !export + && export_variables_on_modification + && !matches!(new_value, ShellValueLiteral::Array(_)) + { + export = true; + } + + existing_value.assign(new_value, assignment.append)?; + } + + if export { + existing_value.export(); + } + + // That's it! + return Ok(()); + } + + // If we fell down here, then we need to add it. + let new_value = if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)])) + } + ShellValueLiteral::Array(_) => { + return error::unimp("cannot assign list to array member"); + } + } + } else { + match new_value { + ShellValueLiteral::Scalar(s) => { + export = export || shell.options().export_variables_on_modification; + + // A new variable for a command (e.g. `FOO+=x cmd`) appends to the value the + // variable has where it's visible, as that variable would (e.g. adding, for + // an integer); the result is a plain string. + let s = match shell.env().get(variable_name.as_str()) { + Some((_, visible)) if assignment.append => { + let mut appended = visible.clone(); + appended.assign(ShellValueLiteral::Scalar(s), true)?; + appended.value().to_cow_str(shell).into_owned() + } + _ => s, + }; + ShellValue::String(s) + } + ShellValueLiteral::Array(values) => ShellValue::indexed_array_from_literals(values), + } + }; + + let mut new_var = ShellVariable::new(new_value); + + if export { + new_var.export(); + } + + shell.env_mut().add(variable_name, new_var, creation_scope) +} + +#[expect(clippy::too_many_lines)] +pub(crate) async fn setup_redirect( + shell: &mut Shell, + params: &'_ mut ExecutionParameters, + redirect: &ast::IoRedirect, +) -> Result<(), error::Error> { + match redirect { + ast::IoRedirect::OutputAndError(f, append) => { + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path = expanded_fields.remove(0); + setup_redirect_output_and_error_to(shell, params, &expanded_file_path, *append)?; + } + + ast::IoRedirect::File(specified_fd_num, kind, target) => { + match target { + ast::IoFileRedirectTarget::Filename(f) => { + let mut options = std::fs::File::options(); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path: PathBuf = + shell.absolute_path(Path::new(expanded_fields.remove(0).as_str())); + + let default_fd_if_unspecified = get_default_fd_for_redirect_kind(kind); + match kind { + ast::IoFileRedirectKind::Read => { + options.read(true); + } + ast::IoFileRedirectKind::Write => { + if shell + .options() + .disallow_overwriting_regular_files_via_output_redirection + { + // First check to see if the path points to an existing regular + // file. + if !expanded_file_path.is_file() { + options.create(true); + } else { + options.create_new(true); + } + options.write(true); + } else { + options.create(true); + options.write(true); + options.truncate(true); + } + } + ast::IoFileRedirectKind::Append => { + options.create(true); + options.append(true); + } + ast::IoFileRedirectKind::ReadAndWrite => { + options.create(true); + options.read(true); + options.write(true); + } + ast::IoFileRedirectKind::Clobber => { + options.create(true); + options.write(true); + options.truncate(true); + } + ast::IoFileRedirectKind::DuplicateInput => { + options.read(true); + } + ast::IoFileRedirectKind::DuplicateOutput => { + options.create(true); + options.write(true); + } + } + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let opened_file = shell + .open_file(&options, &expanded_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + expanded_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + params.open_files.set_fd(fd_num, opened_file); + } + + ast::IoFileRedirectTarget::Fd(fd) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + if let Some(target_file) = params.try_fd(shell, *fd) { + params.open_files.set_fd(fd_num, target_file); + } else { + return Err(error::ErrorKind::BadFileDescriptor(*fd).into()); + } + } + + ast::IoFileRedirectTarget::Duplicate(word) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, word).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let mut expanded = expanded_fields.remove(0); + + let dash = if expanded.ends_with('-') { + expanded.pop(); + true + } else { + false + }; + + if expanded.is_empty() { + // Nothing to do + } else if expanded.chars().all(|c: char| c.is_ascii_digit()) { + let source_fd_num = expanded + .parse::() + .map_err(|_| error::ErrorKind::InvalidRedirection)?; + + // Reference the same open file as the source fd (shared handle; no OS-level duplication). + let Some(target_file) = params.try_fd(shell, source_fd_num) else { + return Err(error::ErrorKind::BadFileDescriptor(source_fd_num).into()); + }; + + params.open_files.set_fd(fd_num, target_file); + } else if fd_num == 1 && !dash { + // Special case for compatibility: redirect stdout and stderr to the file + // given by `expanded`. + setup_redirect_output_and_error_to( + shell, params, &expanded, false, /* append? */ + )?; + } else { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + if dash { + // Close the specified fd. Ignore it if it's not valid. + params.open_files.remove_fd(fd_num); + } + } + + ast::IoFileRedirectTarget::ProcessSubstitution(substitution_kind, subshell_cmd) => { + match kind { + ast::IoFileRedirectKind::Read + | ast::IoFileRedirectKind::Write + | ast::IoFileRedirectKind::Append + | ast::IoFileRedirectKind::ReadAndWrite + | ast::IoFileRedirectKind::Clobber => { + let (substitution_fd, substitution_file) = setup_process_substitution( + shell, + params, + substitution_kind, + subshell_cmd, + )?; + + let target_file = substitution_file.clone(); + params.open_files.set_fd(substitution_fd, substitution_file); + + let fd_num = specified_fd_num + .unwrap_or_else(|| get_default_fd_for_redirect_kind(kind)); + + params.open_files.set_fd(fd_num, target_file); + } + _ => return error::unimp("invalid process substitution"), + } + } + } + } + + ast::IoRedirect::HereDocument(fd_num, io_here) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + // Expand if required. + let io_here_doc = if io_here.requires_expansion { + expansion::basic_expand_heredoc_word(shell, params, &io_here.doc).await? + } else { + io_here.doc.flatten() + }; + + let f = setup_open_file_with_contents(io_here_doc.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + + ast::IoRedirect::HereString(fd_num, word) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + let mut expanded_word = expansion::basic_expand_word(shell, params, word).await?; + expanded_word.push('\n'); + + let f = setup_open_file_with_contents(expanded_word.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + } + + Ok(()) +} + +/// Sets up redirection of both stdout and stderr to the same file, given by `file_path`. +/// +/// # Arguments +/// +/// * `shell` - The shell instance. +/// * `params` - The execution parameters to modify. +/// * `file_path` - The path to the file to redirect output and error to. +/// * `append` - Whether to append. If `false`, the file will be truncated. +fn setup_redirect_output_and_error_to( + shell: &Shell, + params: &mut ExecutionParameters, + file_path: &str, + append: bool, +) -> Result<(), error::Error> { + let abs_file_path: PathBuf = shell.absolute_path(Path::new(file_path)); + + let mut file_options = std::fs::File::options(); + file_options + .create(true) + .write(true) + .truncate(!append) + .append(append); + + let stdout_file = shell + .open_file(&file_options, &abs_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + abs_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + let stderr_file = stdout_file.clone(); + + params.open_files.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.open_files.set_fd(OpenFiles::STDERR_FD, stderr_file); + + Ok(()) +} + +const fn get_default_fd_for_redirect_kind(kind: &ast::IoFileRedirectKind) -> ShellFd { + match kind { + ast::IoFileRedirectKind::Read => 0, + ast::IoFileRedirectKind::Write => 1, + ast::IoFileRedirectKind::Append => 1, + ast::IoFileRedirectKind::ReadAndWrite => 0, + ast::IoFileRedirectKind::Clobber => 1, + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + } +} + +fn setup_process_substitution( + shell: &Shell, + params: &ExecutionParameters, + kind: &ast::ProcessSubstitutionKind, + subshell_cmd: &ast::SubshellCommand, +) -> Result<(ShellFd, OpenFile), error::Error> { + // TODO(execute): Don't execute synchronously! + // Execute in a subshell. + let mut subshell = shell.clone(); + + // Set up execution parameters for the child execution. + let mut child_params = params.clone(); + child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Set up pipe so we can connect to the command. + let (reader, writer) = sys::pipes::pipe()?; + let (reader, writer) = (reader.into(), writer.into()); + + let target_file = match kind { + ast::ProcessSubstitutionKind::Read => { + child_params.open_files.set_fd(OpenFiles::STDOUT_FD, writer); + reader + } + ast::ProcessSubstitutionKind::Write => { + child_params.open_files.set_fd(OpenFiles::STDIN_FD, reader); + writer + } + }; + + // Asynchronously spawn off the subshell; we intentionally don't block on its + // completion. + let subshell_cmd = subshell_cmd.to_owned(); + shell.task_spawner().spawn(async move { + // Intentionally ignore the result of the subshell command. + let _ = subshell_cmd + .list + .execute(&mut subshell, &child_params) + .await; + }); + + // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an + // available fd. + let mut candidate_fd_num = 63; + while params.open_files.contains_fd(candidate_fd_num) { + candidate_fd_num -= 1; + if candidate_fd_num == 0 { + return error::unimp("no available file descriptors"); + } + } + + Ok((candidate_fd_num, target_file)) +} + +fn setup_open_file_with_contents(contents: &str) -> Result { + let (reader, mut writer) = sys::pipes::pipe()?; + + let bytes = contents.as_bytes(); + + // ast-grep-ignore: platform-cfg-outside-sys + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use std::os::fd::AsFd as _; + + let len = i32::try_from(bytes.len()) + .map_err(|_err| error::Error::from(error::ErrorKind::TooMuchData))?; + nix::fcntl::fcntl(reader.as_fd(), nix::fcntl::FcntlArg::F_SETPIPE_SZ(len))?; + } + + writer.write_all(bytes)?; + drop(writer); + + Ok(reader.into()) +} + +// Sizes depend on the platform's types, so they're only checked where they were measured. +#[cfg(test)] +#[cfg(target_os = "linux")] // ast-grep-ignore: platform-cfg-outside-sys +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + use crate::{ProfileLoadBehavior, RcLoadBehavior}; + + /// Every simple command allocates these futures, so keep them small. A rarely + /// taken branch can grow one by a kilobyte if it holds a `Shell` across an + /// `.await`, and clippy's `large_futures` can't see futures whose layout + /// depends on `SE`. The limits leave room for compiler updates; if one is + /// exceeded, find out why before raising it. + #[tokio::test] + async fn futures_for_a_simple_command_stay_small() -> anyhow::Result<()> { + let mut shell = Shell::builder() + .profile(ProfileLoadBehavior::Skip) + .rc(RcLoadBehavior::Skip) + .build() + .await?; + let params = shell.default_exec_params(); + + let program = shell.parse_string("true")?; + let list = &program.complete_commands[0]; + let and_or = &list.0[0].0; + let pipeline = &and_or.first; + let command = &pipeline.seq[0]; + let ast::Command::Simple(simple) = command else { + anyhow::bail!("expected a simple command"); + }; + + let mut sizes = vec![]; + let context = PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(&mut shell), + process_group_id: None, + }; + let future = simple.execute_in_pipeline(context, params.clone()); + sizes.push(( + "SimpleCommand::execute_in_pipeline", + size_of_val(&*future), + 3072, + )); + drop(future); + + let context = PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(&mut shell), + process_group_id: None, + }; + let future = command.execute_in_pipeline(context, params.clone()); + sizes.push(("Command::execute_in_pipeline", size_of_val(&*future), 768)); + drop(future); + + let future = pipeline.execute(&mut shell, ¶ms); + sizes.push(("Pipeline::execute", size_of_val(&*future), 768)); + drop(future); + + let future = and_or.execute(&mut shell, ¶ms); + sizes.push(("AndOrList::execute", size_of_val(&*future), 256)); + drop(future); + + let future = list.execute(&mut shell, ¶ms); + sizes.push(("CompoundList::execute", size_of_val(&*future), 128)); + drop(future); + + for (name, size, limit) in sizes { + assert!( + size <= limit, + "{name}'s future takes {size} bytes; the limit is {limit}" + ); + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/ioutils.rs b/userland/brush/source/brush-core/src/ioutils.rs new file mode 100644 index 0000000..6372d37 --- /dev/null +++ b/userland/brush/source/brush-core/src/ioutils.rs @@ -0,0 +1,80 @@ +//! Internal I/O utilities. + +use crate::openfiles; + +/// Error type for `FailingReaderWriter`. +#[derive(Clone, Debug, thiserror::Error)] +enum ReaderWriterError { + /// I/O read error. + #[error("I/O read error: {0}")] + Read(&'static str), + /// I/O write error. + #[error("I/O write error: {0}")] + Write(&'static str), + /// I/O flush error. + #[error("I/O flush error: {0}")] + Flush(&'static str), +} + +/// An implementation of `std::io::Read` and `std::io::Write` that always fails. +/// +/// Also implements `openfiles::Stream` for use with `openfiles::OpenFile`. Useful +/// for providing a valid stream that gracefully fails all operations in cases where +/// no real I/O is possible. +#[derive(Clone)] +pub(crate) struct FailingReaderWriter { + message: &'static str, +} + +impl FailingReaderWriter { + /// Creates a new `FailingReaderWriter` with the given error message. + /// + /// # Arguments + /// + /// * `message` - The error message to use for all operations. + pub const fn new(message: &'static str) -> Self { + Self { message } + } +} + +impl openfiles::Stream for FailingReaderWriter { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + fn try_clone_to_owned(&self) -> Result { + Err(crate::error::ErrorKind::CannotConvertToNativeFd.into()) + } + + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + fn try_borrow_as_fd(&self) -> Result, crate::error::Error> { + Err(crate::error::ErrorKind::CannotConvertToNativeFd.into()) + } +} + +impl std::io::Read for FailingReaderWriter { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other(ReaderWriterError::Read(self.message))) + } +} + +impl std::io::Write for FailingReaderWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other(ReaderWriterError::Write( + self.message, + ))) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other(ReaderWriterError::Flush( + self.message, + ))) + } +} + +impl From for openfiles::OpenFile { + fn from(frw: FailingReaderWriter) -> Self { + Self::Stream(Box::new(frw)) + } +} diff --git a/userland/brush/source/brush-core/src/jobs.rs b/userland/brush/source/brush-core/src/jobs.rs new file mode 100644 index 0000000..72cfe80 --- /dev/null +++ b/userland/brush/source/brush-core/src/jobs.rs @@ -0,0 +1,496 @@ +//! Job management + +use std::borrow::Cow; +use std::collections::VecDeque; +use std::fmt::Display; + +use futures::FutureExt; + +use crate::ExecutionResult; +use crate::error; +use crate::processes; +use crate::sys; +use crate::tasks; +use crate::trace_categories; +use crate::traps; + +pub(crate) type JobResult = (Job, Result); + +/// Manages the jobs that are currently managed by the shell. +#[derive(Default)] +pub struct JobManager { + /// The jobs that are currently managed by the shell. + pub jobs: Vec, +} + +/// Represents a task that is part of a job. +pub enum JobTask { + /// An external process. + External(processes::ChildProcess), + /// An internal asynchronous task. + Internal(tasks::TaskHandle>), +} + +/// Represents the result of waiting on a job task. +pub enum JobTaskWaitResult { + /// The task has completed. + Completed(ExecutionResult), + /// The task was stopped. + Stopped, +} + +impl JobTask { + /// Returns whether the task is an external process. + pub const fn is_external(&self) -> bool { + matches!(self, Self::External(_)) + } + + /// Waits for the task to complete. Returns the result of the wait. + pub async fn wait(&mut self) -> Result { + match self { + Self::External(process) => { + let wait_result = process.wait().await?; + match wait_result { + processes::ProcessWaitResult::Completed(result) => { + Ok(JobTaskWaitResult::Completed(result)) + } + processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped), + } + } + Self::Internal(handle) => Ok(JobTaskWaitResult::Completed(handle.await??)), + } + } + + /// Polls the task for completion. Returns `Some(result)` if the task has completed, + /// or `None` if it is still running. The result is the execution result of the task. + /// Behaves in a best-effort manner; if an internal error occurs during polling, + /// it will return `None`. + fn poll(&mut self) -> Option> { + match self { + Self::External(process) => process.poll(), + Self::Internal(handle) => { + let checkable_handle = handle; + checkable_handle.now_or_never().and_then(|r| r.ok()) + } + } + } +} + +impl JobManager { + /// Returns a new job manager. + pub fn new() -> Self { + Self::default() + } + + /// Adds a job to the job manager and marks it as the current job; + /// returns an immutable reference to the job. + /// + /// # Arguments + /// + /// * `job` - The job to add. + #[allow( + clippy::missing_panics_doc, + reason = "push() guarantees the vector length is >= 1" + )] + pub fn add_as_current(&mut self, mut job: Job) -> &Job { + for j in &mut self.jobs { + if matches!(j.annotation, JobAnnotation::Current) { + j.annotation = JobAnnotation::Previous; + break; + } + } + + let id = self.jobs.len() + 1; + job.id = id; + job.annotation = JobAnnotation::Current; + self.jobs.push(job); + + #[allow(clippy::unwrap_used, reason = "we just pushed an element")] + self.jobs.last().unwrap() + } + + /// Returns the current job, if there is one. + pub fn current_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns a mutable reference to the current job, if there is one. + pub fn current_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns the previous job, if there is one. + pub fn prev_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Returns a mutable reference to the previous job, if there is one. + pub fn prev_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Tries to resolve the given job specification to a job. + /// + /// # Arguments + /// + /// * `job_spec` - The job specification to resolve. + pub fn resolve_job_spec(&mut self, job_spec: &str) -> Option<&mut Job> { + let remainder = job_spec.strip_prefix('%')?; + + match remainder { + "%" | "+" => self.current_job_mut(), + "-" => self.prev_job_mut(), + s if s.chars().all(char::is_numeric) => { + let id = s.parse::().ok()?; + self.jobs.iter_mut().find(|j| j.id == id) + } + _ => { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'"); + None + } + } + } + + /// Waits for all managed jobs to complete. + pub async fn wait_all(&mut self) -> Result, error::Error> { + for job in &mut self.jobs { + // The caller wants the jobs, not their statuses: `wait` with no operands returns 0 + // whatever they were. + let _ = job.wait().await?; + } + + Ok(self.sweep_completed_jobs()) + } + + /// Polls all managed jobs for completion. + pub fn poll(&mut self) -> Result, error::Error> { + let mut results = Vec::with_capacity(self.jobs.len()); + + let mut i = 0; + while i != self.jobs.len() { + if let Some(result) = self.jobs[i].poll_done()? { + let job = self.jobs.remove(i); + results.push((job, result)); + } else if matches!(self.jobs[i].state, JobState::Done) { + // TODO(jobs): This is a workaround to remove jobs that are done but for which we + // don't know what happened. + results.push((self.jobs.remove(i), Ok(ExecutionResult::success()))); + } else { + i += 1; + } + } + + Ok(results) + } + + fn sweep_completed_jobs(&mut self) -> Vec { + let mut completed_jobs = vec![]; + + let mut i = 0; + while i != self.jobs.len() { + if self.jobs[i].tasks.is_empty() { + completed_jobs.push(self.jobs.remove(i)); + } else { + i += 1; + } + } + + completed_jobs + } +} + +/// Represents the current execution state of a job. +#[derive(Clone)] +pub enum JobState { + /// Unknown state. + Unknown, + /// The job is running. + Running, + /// The job is stopped. + Stopped, + /// The job has completed. + Done, +} + +impl Display for JobState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "Unknown"), + Self::Running => write!(f, "Running"), + Self::Stopped => write!(f, "Stopped"), + Self::Done => write!(f, "Done"), + } + } +} + +/// Represents an annotation for a job. +#[derive(Clone)] +pub enum JobAnnotation { + /// No annotation. + None, + /// The job is the current job. + Current, + /// The job is the previous job. + Previous, +} + +impl Display for JobAnnotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, ""), + Self::Current => write!(f, "+"), + Self::Previous => write!(f, "-"), + } + } +} + +/// Encapsulates a set of processes managed by the shell as a single unit. +pub struct Job { + /// The tasks that make up the job. + tasks: VecDeque, + + /// The annotation of the job (e.g., current, previous). + annotation: JobAnnotation, + + /// The shell-internal ID of the job. + pub id: usize, + + /// The command line of the job. + pub command_line: String, + + /// The current operational state of the job. + pub state: JobState, +} + +impl Display for Job { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[{}]{:3}{}\t{}", + self.id, self.annotation, self.state, self.command_line + ) + } +} + +impl Job { + /// Returns a new job object. + /// + /// # Arguments + /// + /// * `children` - The job's known child processes. + /// * `command_line` - The command line of the job. + /// * `state` - The current operational state of the job. + pub(crate) fn new(tasks: I, command_line: String, state: JobState) -> Self + where + I: IntoIterator, + { + Self { + id: 0, + tasks: tasks.into_iter().collect(), + annotation: JobAnnotation::None, + command_line, + state, + } + } + + /// Returns a pid-style string for the job. + pub fn to_pid_style_string(&self) -> String { + let display_pid = self + .representative_pid() + .map_or(Cow::Borrowed(""), |pid| { + Cow::Owned(pid.to_string()) + }); + std::format!("[{}]{}\t{}", self.id, self.annotation, display_pid) + } + + /// Returns the annotation of the job. + pub fn annotation(&self) -> JobAnnotation { + self.annotation.clone() + } + + /// Returns the command name of the job. + pub fn command_name(&self) -> &str { + self.command_line + .split_ascii_whitespace() + .next() + .unwrap_or_default() + } + + /// Returns whether the job is the current job. + pub const fn is_current(&self) -> bool { + matches!(self.annotation, JobAnnotation::Current) + } + + /// Returns whether the job is the previous job. + pub const fn is_prev(&self) -> bool { + matches!(self.annotation, JobAnnotation::Previous) + } + + /// Polls whether the job has completed. + pub fn poll_done( + &mut self, + ) -> Result>, error::Error> { + let mut result: Option> = None; + + tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", self.id); + + while !self.tasks.is_empty() { + let task = &mut self.tasks[0]; + match task.poll() { + Some(r) => { + self.tasks.remove(0); + result = Some(r); + } + None => { + return Ok(None); + } + } + } + + tracing::debug!(target: trace_categories::JOBS, "Job {} has completed.", self.id); + + self.state = JobState::Done; + + Ok(result) + } + + /// Waits for the job to complete. + pub async fn wait(&mut self) -> Result { + let mut result = ExecutionResult::success(); + + while let Some(task) = self.tasks.back_mut() { + match task.wait().await? { + JobTaskWaitResult::Completed(execution_result) => { + result = execution_result; + self.tasks.pop_back(); + } + JobTaskWaitResult::Stopped => { + self.state = JobState::Stopped; + return Ok(ExecutionResult::stopped()); + } + } + } + + self.state = JobState::Done; + + Ok(result) + } + + /// Moves the job to execute in the background. + pub fn move_to_background(&mut self) -> Result<(), error::Error> { + self.continue_job() + } + + /// Moves the job to execute in the foreground. + pub fn move_to_foreground(&mut self) -> Result<(), error::Error> { + // The job gets the terminal before it's continued, so that it doesn't find itself in the + // background and stop again on reading from the terminal. (Without a terminal, as in a + // script that enables job control, there's none to give it, and it's continued anyway.) + if let Some(pgid) = self.own_process_group() + && let Err(err) = sys::terminal::move_to_foreground(pgid) + { + tracing::debug!(target: trace_categories::JOBS, "not giving job {} the terminal: {err}", self.id); + } + + self.continue_job() + } + + /// Continues the job, and records it running. + fn continue_job(&mut self) -> Result<(), error::Error> { + if matches!(self.state, JobState::Stopped) { + self.continue_processes()?; + self.state = JobState::Running; + } else { + // As bash does for `fg`, continue it anyway: it may have been stopped without the + // shell noticing yet (e.g., by reading from the terminal in the background). + let _ = self.continue_processes(); + } + + Ok(()) + } + + /// Continues the job's stopped processes: all of its process group, if it has one. + fn continue_processes(&self) -> Result<(), error::Error> { + if let Some(pgid) = self.own_process_group() { + sys::signal::continue_process_group(pgid) + } else if let Some(pid) = self.representative_pid() { + sys::signal::continue_process(pid) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } + + /// Checks whether the job can be signaled. + pub fn check_signalable(&self) -> Result<(), error::Error> { + if let Some(pgid) = self.own_process_group() { + sys::signal::check_process_group_signalable(pgid) + } else if let Some(pid) = self.representative_pid() { + sys::signal::check_signalable(pid) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } + + /// Kills the job: sends the signal to all of its process group, if it has one. A job sent + /// SIGTERM or SIGHUP is also continued, so that it acts on it if it's stopped. + /// + /// # Arguments + /// + /// * `signal` - The signal to send to the job. + pub fn kill(&mut self, signal: traps::TrapSignal) -> Result<(), error::Error> { + if let Some(pgid) = self.own_process_group() { + sys::signal::kill_process_group(pgid, signal)?; + } else if let Some(pid) = self.representative_pid() { + sys::signal::kill_process(pid, signal)?; + } else { + return Err(error::ErrorKind::FailedToSendSignal.into()); + } + + // As bash does, continue a stopped job sent SIGTERM or SIGHUP, so that it acts on it; + // and one not known to be stopped, which may have stopped without the shell's noticing. + if sys::signal::continue_stopped_on(signal) { + self.continue_job()?; + } + + Ok(()) + } + + /// Tries to retrieve a "representative" pid for the job. + pub fn representative_pid(&self) -> Option { + for task in &self.tasks { + match task { + JobTask::External(p) => { + if let Some(pid) = p.pid() { + return Some(pid); + } + } + JobTask::Internal(_) => (), + } + } + None + } + + /// Tries to retrieve the process group ID (PGID) of the job: that of its own process group, + /// if it has one, or else its representative pid. + pub fn process_group_id(&self) -> Option { + self.own_process_group() + .or_else(|| self.representative_pid()) + } + + /// Returns the job's own process group, if it has one: under job control, its processes + /// run in a group of their own, which needn't be led by the first of them. + fn own_process_group(&self) -> Option { + self.tasks.iter().find_map(|task| match task { + JobTask::External(child) => child.pgid(), + JobTask::Internal(_) => None, + }) + } +} diff --git a/userland/brush/source/brush-core/src/keywords.rs b/userland/brush/source/brush-core/src/keywords.rs new file mode 100644 index 0000000..f3c272c --- /dev/null +++ b/userland/brush/source/brush-core/src/keywords.rs @@ -0,0 +1,34 @@ +/// The shell's reserved words, in the order bash lists them (e.g. for `compgen -k`), each +/// with whether it's reserved in sh mode too. +const KEYWORDS: [(&str, bool); 22] = [ + ("if", true), + ("then", true), + ("else", true), + ("elif", true), + ("fi", true), + ("case", true), + ("esac", true), + ("for", true), + ("select", false), + ("while", true), + ("until", true), + ("do", true), + ("done", true), + ("in", true), + ("function", false), + ("time", false), + ("{", true), + ("}", true), + ("!", true), + ("[[", false), + ("]]", false), + ("coproc", false), +]; + +/// Returns the reserved words, in bash's order; in sh mode, only those reserved there. +pub(crate) fn keywords(sh_mode: bool) -> impl Iterator { + KEYWORDS + .iter() + .filter(move |(_, in_sh_mode)| *in_sh_mode || !sh_mode) + .map(|(keyword, _)| *keyword) +} diff --git a/userland/brush/source/brush-core/src/lib.rs b/userland/brush/source/brush-core/src/lib.rs new file mode 100644 index 0000000..bceeb27 --- /dev/null +++ b/userland/brush/source/brush-core/src/lib.rs @@ -0,0 +1,65 @@ +//! Core implementation of the brush shell. Implements the shell's abstraction, its interpreter, and +//! various facilities used internally by the shell. + +pub mod arithmetic; +mod braceexpansion; +pub mod builtins; +pub mod callstack; +pub mod commands; +pub mod completion; +pub mod env; +pub mod error; +pub mod escape; +pub mod expansion; +mod extendedtests; +pub mod extensions; +pub mod functions; +pub mod history; +pub mod int_utils; +pub mod interfaces; +mod interp; +mod ioutils; +pub mod jobs; +mod keywords; +pub mod namedoptions; +pub mod openfiles; +pub mod options; +pub mod pathcache; +pub mod pathsearch; +pub mod patterns; +pub mod processes; +mod prompt; +mod regex; +pub mod results; +mod shell; +pub mod sourceinfo; +pub mod sys; +pub mod tasks; +pub mod terminal; +pub mod tests; +pub mod timing; +pub mod trace_categories; +pub mod traps; +pub mod variables; +mod wellknownvars; + +/// Re-export parser types used in core definitions. +pub mod parser { + pub use brush_parser::{ + BindingParseError, ParseError, ParserImpl, SourcePosition, SourcePositionOffset, + SourceSpan, TestCommandParseError, WordParseError, ast, + }; +} + +pub use commands::{CommandArg, ExecutionContext}; +pub use error::{BuiltinError, Error, ErrorKind}; +pub use extensions::ShellExtensions; +pub use interp::{ExecutionParameters, ProcessGroupPolicy}; +pub use parser::{SourcePosition, SourcePositionOffset, SourceSpan}; +pub use results::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult}; +pub use shell::{ + CreateOptions, ProfileLoadBehavior, RcLoadBehavior, SavedCommandStatus, Shell, ShellBuilder, + ShellBuilderState, ShellFd, ShellState, +}; +pub use sourceinfo::SourceInfo; +pub use variables::{ShellValue, ShellVariable}; diff --git a/userland/brush/source/brush-core/src/namedoptions.rs b/userland/brush/source/brush-core/src/namedoptions.rs new file mode 100644 index 0000000..16280d3 --- /dev/null +++ b/userland/brush/source/brush-core/src/namedoptions.rs @@ -0,0 +1,878 @@ +//! Defines shell options. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use crate::options::RuntimeOptions; + +type OptionGetter = fn(shell: &RuntimeOptions) -> bool; +type OptionSetter = fn(shell: &mut RuntimeOptions, value: bool) -> (); + +/// Defines an option. +pub struct ShellOptionDef { + /// Getter function that retrieves the current value of the option. + getter: OptionGetter, + /// Setter function that may be used to set the current value of the option. + setter: OptionSetter, +} + +impl ShellOptionDef { + /// Constructs a new option definition. + /// + /// # Arguments + /// + /// * `getter` - A function that retrieves the current value of the option. + /// * `setter` - A function that sets the current value of the option. + fn new(getter: OptionGetter, setter: OptionSetter) -> Self { + Self { getter, setter } + } + + /// Retrieves the current value of this option from the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to retrieve the value from. + pub fn get(&self, options: &RuntimeOptions) -> bool { + (self.getter)(options) + } + + /// Sets the value of this option in the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to modify. + /// * `value` - The new value to set for the option. + pub fn set(&self, options: &mut RuntimeOptions, value: bool) { + (self.setter)(options, value); + } +} + +/// Describes a shell option. +pub struct ShellOption { + /// The name of the option. + pub name: &'static str, + /// The definition of the option. + pub definition: &'static ShellOptionDef, +} + +/// Describes a set of shell options. +pub struct ShellOptionSet { + inner: &'static HashMap<&'static str, ShellOptionDef>, +} + +/// Kind of shell option. +#[derive(Clone, Copy)] +pub enum ShellOptionKind { + /// `set` option. + Set, + /// `set -o` option. + SetO, + /// `shopt` option. + Shopt, +} + +/// Returns the options for the given shell option kind. +/// +/// # Arguments +/// +/// * `kind` - The kind of shell options to retrieve. +pub fn options(kind: ShellOptionKind) -> ShellOptionSet { + match kind { + ShellOptionKind::Set => ShellOptionSet { + inner: &SET_OPTIONS, + }, + ShellOptionKind::SetO => ShellOptionSet { + inner: &SET_O_OPTIONS, + }, + ShellOptionKind::Shopt => ShellOptionSet { + inner: &SHOPT_OPTIONS, + }, + } +} + +impl ShellOptionSet { + /// Returns an iterator over the options defined in this set. + pub fn iter(&self) -> impl Iterator { + self.inner + .iter() + .map(|(&name, definition)| ShellOption { name, definition }) + } + + /// Returns the option with the given name, if it exists. + /// + /// # Arguments + /// + /// * `name` - The name of the option to retrieve. + pub fn get(&self, name: &str) -> Option<&'static ShellOptionDef> { + self.inner.get(name) + } +} + +static SET_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "a", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "b", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "c", + ShellOptionDef::new( + |options| options.command_string_mode, + |options, value| options.command_string_mode = value, + ), + ), + ( + "e", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "f", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ( + "h", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "i", + ShellOptionDef::new( + |options| options.interactive, + |options, value| options.interactive = value, + ), + ), + ( + "k", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "m", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "n", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "p", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "t", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "u", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "v", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "x", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ( + "B", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "C", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "E", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "H", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "P", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "T", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "s", + ShellOptionDef::new( + |options| options.read_commands_from_stdin, + |options, value| options.read_commands_from_stdin = value, + ), + ), + ]) +}); + +static SET_O_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "allexport", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "braceexpand", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "emacs", + ShellOptionDef::new( + |options| options.emacs_mode, + |options, value| options.emacs_mode = value, + ), + ), + ( + "errexit", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "errtrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "functrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "hashall", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "histexpand", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "history", + ShellOptionDef::new( + |options| options.enable_command_history, + |options, value| options.enable_command_history = value, + ), + ), + ( + "ignoreeof", + ShellOptionDef::new( + |options| options.ignore_eof, + |options, value| options.ignore_eof = value, + ), + ), + ( + "interactive-comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "keyword", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "monitor", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "noclobber", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "noexec", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "noglob", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ("nolog", ShellOptionDef::new(|_| false, |_, _| ())), + ( + "notify", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "nounset", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "onecmd", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "physical", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "pipefail", + ShellOptionDef::new( + |options| options.return_last_failure_from_pipeline, + |options, value| options.return_last_failure_from_pipeline = value, + ), + ), + ( + "posix", + ShellOptionDef::new( + |options| options.posix_mode, + |options, value| options.posix_mode = value, + ), + ), + ( + "privileged", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "verbose", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "vi", + ShellOptionDef::new( + |options| options.vi_mode, + |options, value| options.vi_mode = value, + ), + ), + ( + "xtrace", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ]) +}); + +static SHOPT_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "autocd", + ShellOptionDef::new( + |options| options.auto_cd, + |options, value| options.auto_cd = value, + ), + ), + ( + "array_expand_once", + ShellOptionDef::new( + |options| options.array_expand_once, + |options, value| options.array_expand_once = value, + ), + ), + ( + "assoc_expand_once", + ShellOptionDef::new( + |options| options.assoc_expand_once, + |options, value| options.assoc_expand_once = value, + ), + ), + ( + "bash_source_fullpath", + ShellOptionDef::new( + |options| options.bash_source_full_path, + |options, value| options.bash_source_full_path = value, + ), + ), + ( + "cdable_vars", + ShellOptionDef::new( + |options| options.cdable_vars, + |options, value| options.cdable_vars = value, + ), + ), + ( + "cdspell", + ShellOptionDef::new( + |options| options.cd_autocorrect_spelling, + |options, value| options.cd_autocorrect_spelling = value, + ), + ), + ( + "checkhash", + ShellOptionDef::new( + |options| options.check_hashtable_before_command_exec, + |options, value| options.check_hashtable_before_command_exec = value, + ), + ), + ( + "checkjobs", + ShellOptionDef::new( + |options| options.check_jobs_before_exit, + |options, value| options.check_jobs_before_exit = value, + ), + ), + ( + "checkwinsize", + ShellOptionDef::new( + |options| options.check_window_size_after_external_commands, + |options, value| options.check_window_size_after_external_commands = value, + ), + ), + ( + "cmdhist", + ShellOptionDef::new( + |options| options.save_multiline_cmds_in_history, + |options, value| options.save_multiline_cmds_in_history = value, + ), + ), + ( + "compat31", + ShellOptionDef::new( + |options| options.compat31, + |options, value| options.compat31 = value, + ), + ), + ( + "compat32", + ShellOptionDef::new( + |options| options.compat32, + |options, value| options.compat32 = value, + ), + ), + ( + "compat40", + ShellOptionDef::new( + |options| options.compat40, + |options, value| options.compat40 = value, + ), + ), + ( + "compat41", + ShellOptionDef::new( + |options| options.compat41, + |options, value| options.compat41 = value, + ), + ), + ( + "compat42", + ShellOptionDef::new( + |options| options.compat42, + |options, value| options.compat42 = value, + ), + ), + ( + "compat43", + ShellOptionDef::new( + |options| options.compat43, + |options, value| options.compat43 = value, + ), + ), + ( + "compat44", + ShellOptionDef::new( + |options| options.compat44, + |options, value| options.compat44 = value, + ), + ), + ( + "complete_fullquote", + ShellOptionDef::new( + |options| options.quote_all_metachars_in_completion, + |options, value| options.quote_all_metachars_in_completion = value, + ), + ), + ( + "direxpand", + ShellOptionDef::new( + |options| options.expand_dir_names_on_completion, + |options, value| options.expand_dir_names_on_completion = value, + ), + ), + ( + "dirspell", + ShellOptionDef::new( + |options| options.autocorrect_dir_spelling_on_completion, + |options, value| options.autocorrect_dir_spelling_on_completion = value, + ), + ), + ( + "dotglob", + ShellOptionDef::new( + |options| options.glob_matches_dotfiles, + |options, value| options.glob_matches_dotfiles = value, + ), + ), + ( + "execfail", + ShellOptionDef::new( + |options| options.exit_on_exec_fail, + |options, value| options.exit_on_exec_fail = value, + ), + ), + ( + "expand_aliases", + ShellOptionDef::new( + |options| options.expand_aliases, + |options, value| options.expand_aliases = value, + ), + ), + ( + "extdebug", + ShellOptionDef::new( + |options| options.enable_debugger, + |options, value| options.enable_debugger = value, + ), + ), + ( + "extglob", + ShellOptionDef::new( + |options| options.extended_globbing, + |options, value| options.extended_globbing = value, + ), + ), + ( + "extquote", + ShellOptionDef::new( + |options| options.extquote, + |options, value| options.extquote = value, + ), + ), + ( + "failglob", + ShellOptionDef::new( + |options| options.fail_expansion_on_globs_without_match, + |options, value| options.fail_expansion_on_globs_without_match = value, + ), + ), + ( + "force_fignore", + ShellOptionDef::new( + |options| options.force_fignore, + |options, value| options.force_fignore = value, + ), + ), + ( + "globasciiranges", + ShellOptionDef::new( + |options| options.glob_ranges_use_c_locale, + |options, value| options.glob_ranges_use_c_locale = value, + ), + ), + ( + "globskipdots", + ShellOptionDef::new( + |options| options.glob_skip_dots, + |options, value| options.glob_skip_dots = value, + ), + ), + ( + "globstar", + ShellOptionDef::new( + |options| options.enable_star_star_glob, + |options, value| options.enable_star_star_glob = value, + ), + ), + ( + "gnu_errfmt", + ShellOptionDef::new( + |options| options.errors_in_gnu_format, + |options, value| options.errors_in_gnu_format = value, + ), + ), + ( + "histappend", + ShellOptionDef::new( + |options| options.append_to_history_file, + |options, value| options.append_to_history_file = value, + ), + ), + ( + "histreedit", + ShellOptionDef::new( + |options| options.allow_reedit_failed_history_subst, + |options, value| options.allow_reedit_failed_history_subst = value, + ), + ), + ( + "histverify", + ShellOptionDef::new( + |options| options.allow_modifying_history_substitution, + |options, value| options.allow_modifying_history_substitution = value, + ), + ), + ( + "hostcomplete", + ShellOptionDef::new( + |options| options.enable_hostname_completion, + |options, value| options.enable_hostname_completion = value, + ), + ), + ( + "huponexit", + ShellOptionDef::new( + |options| options.send_sighup_to_all_jobs_on_exit, + |options, value| options.send_sighup_to_all_jobs_on_exit = value, + ), + ), + ( + "inherit_errexit", + ShellOptionDef::new( + |options| options.command_subst_inherits_errexit, + |options, value| options.command_subst_inherits_errexit = value, + ), + ), + ( + "interactive_comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "lastpipe", + ShellOptionDef::new( + |options| options.run_last_pipeline_cmd_in_current_shell, + |options, value| options.run_last_pipeline_cmd_in_current_shell = value, + ), + ), + ( + "lithist", + ShellOptionDef::new( + |options| options.embed_newlines_in_multiline_cmds_in_history, + |options, value| options.embed_newlines_in_multiline_cmds_in_history = value, + ), + ), + ( + "localvar_inherit", + ShellOptionDef::new( + |options| options.local_vars_inherit_value_and_attrs, + |options, value| options.local_vars_inherit_value_and_attrs = value, + ), + ), + ( + "localvar_unset", + ShellOptionDef::new( + |options| options.localvar_unset, + |options, value| options.localvar_unset = value, + ), + ), + ( + "login_shell", + ShellOptionDef::new( + |options| options.login_shell, + |options, value| options.login_shell = value, + ), + ), + ( + "mailwarn", + ShellOptionDef::new( + |options| options.mail_warn, + |options, value| options.mail_warn = value, + ), + ), + ( + "no_empty_cmd_completion", + ShellOptionDef::new( + |options| options.no_empty_cmd_completion, + |options, value| options.no_empty_cmd_completion = value, + ), + ), + ( + "nocaseglob", + ShellOptionDef::new( + |options| options.case_insensitive_pathname_expansion, + |options, value| options.case_insensitive_pathname_expansion = value, + ), + ), + ( + "nocasematch", + ShellOptionDef::new( + |options| options.case_insensitive_conditionals, + |options, value| options.case_insensitive_conditionals = value, + ), + ), + ( + "noexpand_translation", + ShellOptionDef::new( + |options| options.no_expand_translation, + |options, value| options.no_expand_translation = value, + ), + ), + ( + "nullglob", + ShellOptionDef::new( + |options| options.expand_non_matching_patterns_to_null, + |options, value| options.expand_non_matching_patterns_to_null = value, + ), + ), + ( + "patsub_replacement", + ShellOptionDef::new( + |options| options.patsub_replacement, + |options, value| options.patsub_replacement = value, + ), + ), + ( + "progcomp", + ShellOptionDef::new( + |options| options.programmable_completion, + |options, value| options.programmable_completion = value, + ), + ), + ( + "progcomp_alias", + ShellOptionDef::new( + |options| options.programmable_completion_alias, + |options, value| options.programmable_completion_alias = value, + ), + ), + ( + "promptvars", + ShellOptionDef::new( + |options| options.expand_prompt_strings, + |options, value| options.expand_prompt_strings = value, + ), + ), + ( + "restricted_shell", + ShellOptionDef::new( + |options| options.restricted_shell, + |options, value| options.restricted_shell = value, + ), + ), + ( + "shift_verbose", + ShellOptionDef::new( + |options| options.shift_verbose, + |options, value| options.shift_verbose = value, + ), + ), + ( + "sourcepath", + ShellOptionDef::new( + |options| options.source_builtin_searches_path, + |options, value| options.source_builtin_searches_path = value, + ), + ), + ( + "varredir_close", + ShellOptionDef::new( + |options| options.var_redir_close, + |options, value| options.var_redir_close = value, + ), + ), + ( + "xpg_echo", + ShellOptionDef::new( + |options| options.echo_builtin_expands_escape_sequences, + |options, value| options.echo_builtin_expands_escape_sequences = value, + ), + ), + ]) +}); diff --git a/userland/brush/source/brush-core/src/openfiles.rs b/userland/brush/source/brush-core/src/openfiles.rs new file mode 100644 index 0000000..418a2b4 --- /dev/null +++ b/userland/brush/source/brush-core/src/openfiles.rs @@ -0,0 +1,446 @@ +//! Managing files open within a shell instance. + +use std::collections::HashMap; +use std::io::IsTerminal; +use std::sync::Arc; + +use crate::ShellFd; +use crate::error; +use crate::sys; + +/// A trait representing a stream that can be read from and written to. +/// This is used for custom stream implementations in `OpenFile`. +/// +/// Types that implement this trait are expected to be cloneable via the +/// `clone_box` function. +pub trait Stream: std::io::Read + std::io::Write + Send + Sync { + /// Clones the stream into a boxed trait object. + fn clone_box(&self) -> Box; + + /// Converts the stream into an `OwnedFd`. Returns an error if the operation + /// is not supported or if it fails. + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + fn try_clone_to_owned(&self) -> Result; + + /// Borrows the stream as a `BorrowedFd`. Returns an error if the operation + /// is not supported or if it fails. + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + fn try_borrow_as_fd(&self) -> Result, error::Error>; +} + +/// Represents a file open in a shell context. +/// +/// File- and pipe-backed variants hold their handle behind an [`Arc`], so cloning an `OpenFile` +/// shares the underlying descriptor by reference count. The shell opens a fresh context for each +/// subshell, command substitution, background job, and function call and runs them as in-process +/// tasks against one process-wide descriptor table (rather than via `fork(2)` like a traditional +/// shell); sharing the descriptor keeps deeply nested or highly concurrent execution from +/// exhausting that table. A descriptor is duplicated for real only when an independently owned +/// copy is needed to hand to an external child process (see `OpenFile::try_clone_to_owned`, which +/// the native launcher uses for a process's standard streams and other descriptors alike). +#[non_exhaustive] +pub enum OpenFile { + /// The original standard input this process was started with. + Stdin(std::io::Stdin), + /// The original standard output this process was started with. + Stdout(std::io::Stdout), + /// The original standard error this process was started with. + Stderr(std::io::Stderr), + /// A file open for reading or writing. + File(Arc), + /// A read end of a pipe. + PipeReader(Arc), + /// A write end of a pipe. + PipeWriter(Arc), + /// A custom stream. + Stream(Box), +} + +#[cfg(feature = "serde")] +impl serde::Serialize for OpenFile { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Stdin(_) => serializer.serialize_str("stdin"), + Self::Stdout(_) => serializer.serialize_str("stdout"), + Self::Stderr(_) => serializer.serialize_str("stderr"), + Self::File(_) => serializer.serialize_str("file"), + Self::PipeReader(_) => serializer.serialize_str("pipe_reader"), + Self::PipeWriter(_) => serializer.serialize_str("pipe_writer"), + Self::Stream(_) => serializer.serialize_str("stream"), + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for OpenFile { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match String::deserialize(deserializer)?.as_str() { + "stdin" => return Ok(std::io::stdin().into()), + "stdout" => return Ok(std::io::stdout().into()), + "stderr" => return Ok(std::io::stderr().into()), + "file" => (), + "pipe_reader" => (), + "pipe_writer" => (), + "stream" => (), + _ => return Err(serde::de::Error::custom("invalid open file")), + } + + // TODO(serde): Figure out something better to do with open pipes and files. + null().map_err(serde::de::Error::custom) + } +} + +/// Returns an open file that will discard all I/O. +pub fn null() -> Result { + let file = sys::fs::open_null_file()?; + Ok(file.into()) +} + +impl Clone for OpenFile { + fn clone(&self) -> Self { + match self { + Self::Stdin(_) => std::io::stdin().into(), + Self::Stdout(_) => std::io::stdout().into(), + Self::Stderr(_) => std::io::stderr().into(), + // File and pipe handles are shared by reference count; cloning never issues a + // syscall and so cannot fail. + Self::File(f) => Self::File(Arc::clone(f)), + Self::PipeReader(r) => Self::PipeReader(Arc::clone(r)), + Self::PipeWriter(w) => Self::PipeWriter(Arc::clone(w)), + Self::Stream(s) => Self::Stream(s.clone_box()), + } + } +} + +impl std::fmt::Display for OpenFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdin(_) => write!(f, "stdin"), + Self::Stdout(_) => write!(f, "stdout"), + Self::Stderr(_) => write!(f, "stderr"), + Self::File(_) => write!(f, "file"), + Self::PipeReader(_) => write!(f, "pipe reader"), + Self::PipeWriter(_) => write!(f, "pipe writer"), + Self::Stream(_) => write!(f, "stream"), + } + } +} + +impl OpenFile { + /// Converts the open file into an `OwnedFd`. For shared file/pipe handles this materializes + /// a real duplicate via `dup(2)` so the caller receives an independently owned descriptor. + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + pub(crate) fn try_clone_to_owned(self) -> Result { + use std::os::fd::AsFd as _; + + match self { + Self::Stdin(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stdout(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stderr(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::File(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::PipeReader(r) => Ok(r.as_fd().try_clone_to_owned()?), + Self::PipeWriter(w) => Ok(w.as_fd().try_clone_to_owned()?), + Self::Stream(s) => s.try_clone_to_owned(), + } + } + + /// Borrows the open file as a `BorrowedFd`. + /// + /// # Errors + /// + /// Returns an error if the operation is not supported for the underlying file type. + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + pub fn try_borrow_as_fd(&self) -> Result, error::Error> { + use std::os::fd::AsFd as _; + + match self { + Self::Stdin(f) => Ok(f.as_fd()), + Self::Stdout(f) => Ok(f.as_fd()), + Self::Stderr(f) => Ok(f.as_fd()), + Self::File(f) => Ok(f.as_fd()), + Self::PipeReader(r) => Ok(r.as_fd()), + Self::PipeWriter(w) => Ok(w.as_fd()), + Self::Stream(s) => s.try_borrow_as_fd(), + } + } + + pub(crate) fn is_dir(&self) -> bool { + match self { + Self::Stdin(_) | Self::Stdout(_) | Self::Stderr(_) => false, + Self::File(file) => file.metadata().is_ok_and(|m| m.is_dir()), + Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false, + } + } + + /// Checks if the open file is associated with a terminal. + pub fn is_terminal(&self) -> bool { + match self { + Self::Stdin(f) => f.is_terminal(), + Self::Stdout(f) => f.is_terminal(), + Self::Stderr(f) => f.is_terminal(), + Self::File(f) => f.is_terminal(), + Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false, + } + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard input. + fn from(stdin: std::io::Stdin) -> Self { + Self::Stdin(stdin) + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard output. + fn from(stdout: std::io::Stdout) -> Self { + Self::Stdout(stdout) + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard error. + fn from(stderr: std::io::Stderr) -> Self { + Self::Stderr(stderr) + } +} + +impl From for OpenFile { + fn from(file: std::fs::File) -> Self { + Self::File(Arc::new(file)) + } +} + +impl From for OpenFile { + fn from(reader: std::io::PipeReader) -> Self { + Self::PipeReader(Arc::new(reader)) + } +} + +impl From for OpenFile { + fn from(writer: std::io::PipeWriter) -> Self { + Self::PipeWriter(Arc::new(writer)) + } +} + +impl std::io::Read for OpenFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Stdin(f) => f.read(buf), + Self::Stdout(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stdout"), + )), + Self::Stderr(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stderr"), + )), + // The handle is shared behind an `Arc`; read through a shared reference (`&File` + // and `&PipeReader` both implement `Read`). + Self::File(f) => f.as_ref().read(buf), + Self::PipeReader(reader) => reader.as_ref().read(buf), + Self::PipeWriter(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("pipe writer"), + )), + Self::Stream(s) => s.read(buf), + } + } +} + +impl std::io::Write for OpenFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Stdin(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("stdin"), + )), + Self::Stdout(f) => f.write(buf), + Self::Stderr(f) => f.write(buf), + // The handle is shared behind an `Arc`; write through a shared reference (`&File` + // and `&PipeWriter` both implement `Write`). + Self::File(f) => f.as_ref().write(buf), + Self::PipeReader(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("pipe reader"), + )), + Self::PipeWriter(writer) => writer.as_ref().write(buf), + Self::Stream(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Stdin(_) => Ok(()), + Self::Stdout(f) => f.flush(), + Self::Stderr(f) => f.flush(), + Self::File(f) => f.as_ref().flush(), + Self::PipeReader(_) => Ok(()), + Self::PipeWriter(writer) => writer.as_ref().flush(), + Self::Stream(s) => s.flush(), + } + } +} + +/// Tristate representing the an `OpenFile` entry in an `OpenFiles` structure. +pub enum OpenFileEntry<'a> { + /// File descriptor is present and has a valid associated `OpenFile`. + Open(&'a OpenFile), + /// File descriptor is explicitly marked as not being mapped to any `OpenFile`. + NotPresent, + /// File descriptor is not specified in any way; it may be provided by a + /// parent context of some kind. + NotSpecified, +} + +/// Represents the open files in a shell context. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct OpenFiles { + /// Maps shell file descriptors to open files. + files: HashMap>, +} + +impl OpenFiles { + /// File descriptor used for standard input. + pub const STDIN_FD: ShellFd = 0; + /// File descriptor used for standard output. + pub const STDOUT_FD: ShellFd = 1; + /// File descriptor used for standard error. + pub const STDERR_FD: ShellFd = 2; + + /// First file descriptor available for non-stdio files. + const FIRST_NON_STDIO_FD: ShellFd = 3; + /// Maximum file descriptor number allowed. + const MAX_FD: ShellFd = 1024; + + /// Creates a new `OpenFiles` instance populated with stdin, stdout, and stderr + /// from the host environment. + pub(crate) fn new() -> Self { + Self { + files: HashMap::from([ + (Self::STDIN_FD, Some(std::io::stdin().into())), + (Self::STDOUT_FD, Some(std::io::stdout().into())), + (Self::STDERR_FD, Some(std::io::stderr().into())), + ]), + } + } + + /// Updates the open files from the provided iterator of (fd number, `OpenFile`) pairs. + /// Any existing entries for the provided file descriptors will be overwritten. + /// + /// # Arguments + /// + /// * `files`: An iterator of (fd number, `OpenFile`) pairs to update the open files with. + pub fn update_from(&mut self, files: impl Iterator) { + for (fd, file) in files { + let _ = self.files.insert(fd, Some(file)); + } + } + + /// Retrieves the file backing standard input in this context. + pub fn try_stdin(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDIN_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard output in this context. + pub fn try_stdout(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDOUT_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard error in this context. + pub fn try_stderr(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDERR_FD).and_then(|f| f.as_ref()) + } + + /// Tries to remove an open file by its file descriptor. If the file descriptor + /// is not used, `None` will be returned; otherwise, the removed file will + /// be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to remove. + pub fn remove_fd(&mut self, fd: ShellFd) -> Option { + self.files.insert(fd, None).and_then(|f| f) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. + /// Returns `None` if the file descriptor is not present. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn try_fd(&self, fd: ShellFd) -> Option<&OpenFile> { + self.files.get(&fd).and_then(|f| f.as_ref()) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. Returns + /// an `OpenFileEntry` representing the state of the file descriptor. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn fd_entry(&self, fd: ShellFd) -> OpenFileEntry<'_> { + self.files + .get(&fd) + .map_or(OpenFileEntry::NotSpecified, |opt_file| match opt_file { + Some(f) => OpenFileEntry::Open(f), + None => OpenFileEntry::NotPresent, + }) + } + + /// Checks if the given file descriptor is in use. + pub fn contains_fd(&self, fd: ShellFd) -> bool { + self.files.contains_key(&fd) + } + + /// Associates the given file descriptor with the provided file. If the file descriptor + /// is already in use, the previous file will be returned; otherwise, `None` + /// will be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to associate with the file. + /// * `file`: The file to associate with the file descriptor. + pub fn set_fd(&mut self, fd: ShellFd, file: OpenFile) -> Option { + self.files.insert(fd, Some(file)).and_then(|f| f) + } + + /// Iterates over all file descriptors. + pub fn iter_fds(&self) -> impl Iterator { + self.files + .iter() + .filter_map(|(fd, file)| file.as_ref().map(|f| (*fd, f))) + } + + /// Adds a new open file, returning the assigned file descriptor. + /// + /// # Arguments + /// + /// * `file`: The open file to add. + pub fn add(&mut self, file: OpenFile) -> Result { + // Start searching for free file descriptors after the standard ones. + let mut fd = Self::FIRST_NON_STDIO_FD; + while self.files.contains_key(&fd) { + if fd >= Self::MAX_FD { + return Err(error::ErrorKind::TooManyOpenFiles.into()); + } + + fd += 1; + } + + self.files.insert(fd, Some(file)); + Ok(fd) + } +} + +impl From for OpenFiles +where + I: Iterator, +{ + fn from(iter: I) -> Self { + let files = iter.map(|(fd, file)| (fd, Some(file))).collect(); + Self { files } + } +} diff --git a/userland/brush/source/brush-core/src/options.rs b/userland/brush/source/brush-core/src/options.rs new file mode 100644 index 0000000..d0a244a --- /dev/null +++ b/userland/brush/source/brush-core/src/options.rs @@ -0,0 +1,395 @@ +//! Defines runtime options for the shell. + +use itertools::Itertools; + +use crate::{CreateOptions, extensions, namedoptions}; + +/// Runtime changeable options for a shell instance. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[expect(clippy::module_name_repetitions)] +pub struct RuntimeOptions { + // + // Single-character options. + /// -a + pub export_variables_on_modification: bool, + /// -b + pub notify_job_termination_immediately: bool, + /// -e + pub exit_on_nonzero_command_exit: bool, + /// -f + pub disable_filename_globbing: bool, + /// -h + pub remember_command_locations: bool, + /// -k + pub place_all_assignment_args_in_command_env: bool, + /// -m + pub enable_job_control: bool, + /// -n + pub do_not_execute_commands: bool, + /// -p + pub real_effective_uid_mismatch: bool, + /// -t + pub exit_after_one_command: bool, + /// -u + pub treat_unset_variables_as_error: bool, + /// -v + pub print_shell_input_lines: bool, + /// -x + pub print_commands_and_arguments: bool, + /// -B + pub perform_brace_expansion: bool, + /// -C + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// -E + pub shell_functions_inherit_err_trap: bool, + /// -H + pub enable_bang_style_history_substitution: bool, + /// -P + pub do_not_resolve_symlinks_when_changing_dir: bool, + /// -T + pub shell_functions_inherit_debug_and_return_traps: bool, + + // + // Options set through -o. + /// 'emacs' + pub emacs_mode: bool, + /// 'history' + pub enable_command_history: bool, + /// 'ignoreeof' + pub ignore_eof: bool, + /// 'pipefail' + pub return_last_failure_from_pipeline: bool, + /// 'posix' + pub posix_mode: bool, + /// 'vi' + pub vi_mode: bool, + + // + // Options set through shopt. + /// `array_expand_once` + pub array_expand_once: bool, + /// `assoc_expand_once` + pub assoc_expand_once: bool, + /// 'autocd' + pub auto_cd: bool, + /// `bash_source_full_path` + pub bash_source_full_path: bool, + /// `cdable_vars` + pub cdable_vars: bool, + /// 'cdspell' + pub cd_autocorrect_spelling: bool, + /// 'checkhash' + pub check_hashtable_before_command_exec: bool, + /// 'checkjobs' + pub check_jobs_before_exit: bool, + /// 'checkwinsize' + pub check_window_size_after_external_commands: bool, + /// 'cmdhist' + pub save_multiline_cmds_in_history: bool, + /// 'compat31' + pub compat31: bool, + /// 'compat32' + pub compat32: bool, + /// 'compat40' + pub compat40: bool, + /// 'compat41' + pub compat41: bool, + /// 'compat42' + pub compat42: bool, + /// 'compat43' + pub compat43: bool, + /// 'compat44' + pub compat44: bool, + /// `complete_fullquote` + pub quote_all_metachars_in_completion: bool, + /// 'direxpand' + pub expand_dir_names_on_completion: bool, + /// 'dirspell' + pub autocorrect_dir_spelling_on_completion: bool, + /// 'dotglob' + pub glob_matches_dotfiles: bool, + /// 'execfail' + pub exit_on_exec_fail: bool, + /// `expand_aliases` + pub expand_aliases: bool, + /// 'extdebug' + pub enable_debugger: bool, + /// 'extglob' + pub extended_globbing: bool, + /// 'extquote' + pub extquote: bool, + /// 'failglob' + pub fail_expansion_on_globs_without_match: bool, + /// `force_fignore` + pub force_fignore: bool, + /// 'globasciiranges' + pub glob_ranges_use_c_locale: bool, + /// 'globskipdots' + pub glob_skip_dots: bool, + /// 'globstar' + pub enable_star_star_glob: bool, + /// `gnu_errfmt` + pub errors_in_gnu_format: bool, + /// 'histappend' + pub append_to_history_file: bool, + /// 'histreedit' + pub allow_reedit_failed_history_subst: bool, + /// 'histverify' + pub allow_modifying_history_substitution: bool, + /// 'hostcomplete' + pub enable_hostname_completion: bool, + /// 'huponexit' + pub send_sighup_to_all_jobs_on_exit: bool, + /// `inherit_errexit` + pub command_subst_inherits_errexit: bool, + /// `interactive_comments` + pub interactive_comments: bool, + /// 'lastpipe' + pub run_last_pipeline_cmd_in_current_shell: bool, + /// 'lithist' + pub embed_newlines_in_multiline_cmds_in_history: bool, + /// `localvar_inherit` + pub local_vars_inherit_value_and_attrs: bool, + /// `localvar_unset` + pub localvar_unset: bool, + /// `login_shell` + pub login_shell: bool, + /// 'mailwarn' + pub mail_warn: bool, + /// `no_empty_cmd_completion` + pub no_empty_cmd_completion: bool, + /// 'nocaseglob' + pub case_insensitive_pathname_expansion: bool, + /// 'nocasematch' + pub case_insensitive_conditionals: bool, + /// `noexpand_translation` + pub no_expand_translation: bool, + /// 'nullglob' + pub expand_non_matching_patterns_to_null: bool, + /// `patsub_replacement` + pub patsub_replacement: bool, + /// 'progcomp' + pub programmable_completion: bool, + /// `progcomp_alias` + pub programmable_completion_alias: bool, + /// 'promptvars' + pub expand_prompt_strings: bool, + /// `restricted_shell` + pub restricted_shell: bool, + /// `shift_verbose` + pub shift_verbose: bool, + /// `sourcepath` + pub source_builtin_searches_path: bool, + /// `varredir_close` + pub var_redir_close: bool, + /// `xpg_echo` + pub echo_builtin_expands_escape_sequences: bool, + + // + // Options set by the shell. + /// Whether or not the shell is interactive. + pub interactive: bool, + /// Whether commands are being read from stdin. + pub read_commands_from_stdin: bool, + /// Whether the shell is in command string mode (-c). + pub command_string_mode: bool, + /// Whether or not the shell is in maximal `sh` compatibility mode. + pub sh_mode: bool, + /// Whether to treat external commands as session leaders. + pub external_cmd_leads_session: bool, + /// Whether externally spawned commands are killed when their spawning shell is dropped. + pub kill_external_commands_on_drop: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, +} + +impl RuntimeOptions { + /// Creates a default set of runtime options based on the given creation options. + /// + /// # Arguments + /// + /// * `create_options` - The options used to create the shell. + pub fn defaults_from( + create_options: &CreateOptions, + ) -> Self { + // There's a set of options enabled by default for all shells. + let mut options = Self { + interactive: create_options.interactive, + disallow_overwriting_regular_files_via_output_redirection: create_options + .disallow_overwriting_regular_files_via_output_redirection, + do_not_execute_commands: create_options.do_not_execute_commands, + enable_command_history: create_options.interactive, + enable_job_control: create_options.interactive, + exit_after_one_command: create_options.exit_after_one_command, + read_commands_from_stdin: create_options.read_commands_from_stdin, + command_string_mode: create_options.command_string_mode, + sh_mode: create_options.sh_mode, + posix_mode: create_options.posix, + print_commands_and_arguments: create_options.print_commands_and_arguments, + print_shell_input_lines: create_options.verbose, + treat_unset_variables_as_error: create_options.treat_unset_variables_as_error, + exit_on_nonzero_command_exit: create_options.exit_on_nonzero_command_exit, + external_cmd_leads_session: create_options.external_cmd_leads_session, + kill_external_commands_on_drop: create_options.kill_external_commands_on_drop, + login_shell: create_options.login, + disable_filename_globbing: create_options.disable_pathname_expansion, + remember_command_locations: true, + check_window_size_after_external_commands: true, + save_multiline_cmds_in_history: true, + extquote: true, + force_fignore: true, + case_insensitive_pathname_expansion: + crate::sys::fs::default_case_insensitive_path_expansion(), + enable_hostname_completion: true, + interactive_comments: true, + expand_prompt_strings: true, + source_builtin_searches_path: true, + perform_brace_expansion: true, + quote_all_metachars_in_completion: true, + programmable_completion: true, + glob_ranges_use_c_locale: true, + glob_skip_dots: true, + patsub_replacement: true, + max_function_call_depth: create_options.max_function_call_depth, + ..Self::default() + }; + + // Additional options are enabled by default for interactive shells. + if create_options.interactive { + options.enable_bang_style_history_substitution = true; + options.emacs_mode = !create_options.no_editing; + options.expand_aliases = true; + } + + // Update any options. + for enabled_option in &create_options.enabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(enabled_option.as_str()) + { + option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(disabled_option.as_str()) + { + option.set(&mut options, false); + } + } + + // Update any shopt options. + for enabled_option in &create_options.enabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(enabled_option.as_str()) + { + shopt_option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(disabled_option.as_str()) + { + shopt_option.set(&mut options, false); + } + } + + options + } + + /// Returns a string representing the current `set`-style option flags set in the shell. + pub fn option_flags(&self) -> String { + let mut cs = vec![]; + + for o in namedoptions::options(namedoptions::ShellOptionKind::Set).iter() { + if o.definition.get(self) + && let Some(c) = o.name.chars().next() + { + cs.push(c); + } + } + + // Sort the flags in a way that matches what bash does. + cs.sort_by_key(|flag| option_flag_sort_key(*flag)); + + cs.into_iter().collect() + } + + /// Returns a colon-separated list of sorted 'set -o' options enabled. + pub fn seto_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } + + /// Returns a colon-separated list of sorted 'shopt' options enabled. + pub fn shopt_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } +} + +/// Sort option flag character in a way that mirrors bash behavior. +/// +/// # Arguments +/// +/// * `ch` - The option flag character. +const fn option_flag_sort_key(ch: char) -> (u8, char) { + // NOTE: bash appears to sort in 3 groups. We mimic them: + // 1) Lowercase letters excluding 'c' and 's' (sorted) + // 2) Uppercase letters (sorted) + // 3) All other characters (sorted) + let group = if ch.is_ascii_lowercase() && !matches!(ch, 'c' | 's') { + 0 + } else if ch.is_ascii_uppercase() { + 1 + } else { + 2 + }; + + (group, ch) +} + +#[cfg(test)] +mod tests { + use super::option_flag_sort_key; + + #[test] + fn lowercase_excluding_c_and_s_sort_first() { + let mut flags = vec!['b', 'A', 'Z', 's', 'c', 'a']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['a', 'b', 'A', 'Z', 'c', 's']); + } + + #[test] + fn uppercase_sorted_before_miscellaneous() { + let mut flags = vec!['P', 'B', '1', 'T']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['B', 'P', 'T', '1']); + } + + #[test] + fn miscellaneous_characters_respect_ascii_order() { + let mut flags = vec!['s', 'c', '%', ':']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['%', ':', 'c', 's']); + } +} diff --git a/userland/brush/source/brush-core/src/pathcache.rs b/userland/brush/source/brush-core/src/pathcache.rs new file mode 100644 index 0000000..4cf71d6 --- /dev/null +++ b/userland/brush/source/brush-core/src/pathcache.rs @@ -0,0 +1,59 @@ +//! Path cache + +use crate::{error, variables}; +use std::path::PathBuf; + +/// A cache of paths associated with names. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PathCache { + /// The cache itself. + cache: std::collections::HashMap, +} + +impl PathCache { + /// Clears all elements from the cache. + pub fn reset(&mut self) { + self.cache.clear(); + } + + /// Returns the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to lookup. + pub fn get>(&self, name: S) -> Option { + self.cache.get(name.as_ref()).cloned() + } + + /// Sets the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to set. + /// * `path` - The path to associate with the name. + pub fn set>(&mut self, name: T, path: PathBuf) { + self.cache.insert(name.into(), path); + } + + /// Projects the cache into a shell value. + pub fn to_value(&self) -> Result { + let pairs = self + .cache + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_string_lossy().to_string())) + .collect::>(); + + variables::ShellValue::associative_array_from_literals(variables::ArrayLiteral(pairs)) + } + + /// Removes the path associated with the given name, if there is one. + /// Returns whether or not an entry was removed. + /// + /// # Arguments + /// + /// * `name` - The name to remove. + pub fn unset>(&mut self, name: S) -> bool { + self.cache.remove(name.as_ref()).is_some() + } +} diff --git a/userland/brush/source/brush-core/src/pathsearch.rs b/userland/brush/source/brush-core/src/pathsearch.rs new file mode 100644 index 0000000..d6c1087 --- /dev/null +++ b/userland/brush/source/brush-core/src/pathsearch.rs @@ -0,0 +1,235 @@ +//! Path searching utilities. + +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +use crate::sys; +use crate::sys::fs::PathExt; + +/// Encapsulates the result of a path search. +pub struct ExecutablePathSearch { + paths: VecDeque, + filename: N, +} + +impl Iterator for ExecutablePathSearch +where + PI: AsRef, + N: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()).join(self.filename.as_ref()); + + // Ask the platform to resolve the path to an actual executable file, which on + // Windows may involve appending a PATHEXT extension. The helper takes ownership + // so Unix — where no resolution is needed — can return the path unchanged + // without allocating. + // + // A directory carries the execute bit on Unix but is never a command. Filter + // the *resolved* path rather than the input: on Windows, resolution appends a + // PATHEXT extension, so a `prog` directory must not stop `prog.exe` in the + // same PATH entry from being found. + if let Some(resolved) = sys::fs::resolve_executable(path) + && !resolved.is_dir() + { + return Some(resolved); + } + } + + None + } +} + +pub(crate) struct ExecutablePathPrefixSearch { + paths: VecDeque, + queued_items: VecDeque, + filename_prefix: String, + case_insensitive: bool, +} + +impl Iterator for ExecutablePathPrefixSearch +where + PI: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + // If we already found some items and queued them, then yield one now. + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()); + + if let Ok(readdir) = path.read_dir() { + for entry in readdir.flatten() { + if let Ok(mut filename) = entry.file_name().into_string() { + if self.case_insensitive { + filename = filename.to_ascii_lowercase(); + } + + if !filename.starts_with(&self.filename_prefix) { + continue; + } + } + + let entry_path = entry.path(); + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() && entry_path.executable() { + self.queued_items.push_back(entry_path); + continue; + } + if file_type.is_symlink() && entry_path.executable() { + self.queued_items.push_back(entry_path); + } + } + } + } + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + } + + None + } +} + +/// Search for the given executable name in the provided paths. +/// +/// # Arguments +/// +/// * `paths` - An iterator over the paths to search. +/// * `filename` - The name of the executable file to search for. +pub fn search_for_executable(paths: P, filename: N) -> ExecutablePathSearch +where + P: Iterator, + PI: AsRef, + N: AsRef, +{ + ExecutablePathSearch { + paths: paths.collect(), + filename, + } +} + +/// Resolves a command name the way the shell does when it is about to run it. +/// +/// Returns the first executable in search order, or -- if there is none -- the first entry +/// that exists and is not a directory, which the shell reports as the command and then +/// fails to run. +/// +/// # Arguments +/// +/// * `paths` - An iterator over the paths to search. +/// * `filename` - The name of the command to resolve. +pub fn resolve_command(paths: P, filename: N) -> Option +where + P: IntoIterator, + PI: AsRef, + N: AsRef, +{ + let mut first_non_executable = None; + + for dir in paths { + let path = dir.as_ref().join(filename.as_ref()); + + // Remember the first non-directory entry seen, in case no executable turns up. + if first_non_executable.is_none() && path.metadata().is_ok_and(|m| !m.is_dir()) { + first_non_executable = Some(path.clone()); + } + + // Resolve, then reject directories; see `ExecutablePathSearch::next`. + if let Some(resolved) = sys::fs::resolve_executable(path) + && !resolved.is_dir() + { + return Some(resolved); + } + } + + first_non_executable +} + +pub(crate) fn search_for_executable_with_prefix( + paths: P, + filename_prefix: &str, + case_insensitive: bool, +) -> ExecutablePathPrefixSearch +where + P: Iterator, + PI: AsRef, +{ + let stored_prefix = if case_insensitive { + filename_prefix.to_ascii_lowercase() + } else { + filename_prefix.into() + }; + + ExecutablePathPrefixSearch { + paths: paths.collect(), + queued_items: VecDeque::new(), + filename_prefix: stored_prefix, + case_insensitive, + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use anyhow::Result; + + use super::*; + + /// A directory carries the execute bit on Unix, so it must not be mistaken for a + /// command; an executable later in the search order takes its place. + #[test] + fn directory_is_not_a_command() -> Result<()> { + let scratch = tempfile::tempdir()?; + let first = scratch.path().join("first"); + let second = scratch.path().join("second"); + std::fs::create_dir_all(first.join("prog"))?; + std::fs::create_dir_all(&second)?; + std::fs::write(second.join("prog"), "")?; + + let paths = [first.as_path(), second.as_path()]; + + // The plain (non-executable) file wins over the directory, rather than the + // directory being reported as the command. + assert_eq!(resolve_command(paths, "prog"), Some(second.join("prog"))); + + // ...and a directory is never yielded as an executable at all. + assert_eq!(search_for_executable(paths.iter(), "prog").next(), None); + + Ok(()) + } + + /// On Windows, `PATHEXT` resolution has to run before directories are rejected: a + /// `prog` directory must not keep `prog.bat` in the same PATH entry from being found. + #[cfg(windows)] // ast-grep-ignore: platform-cfg-outside-sys + #[test] + fn same_named_directory_does_not_hide_a_pathext_match() -> Result<()> { + let scratch = tempfile::tempdir()?; + std::fs::create_dir_all(scratch.path().join("prog"))?; + std::fs::write(scratch.path().join("prog.bat"), "@echo off\r\n")?; + + let paths = [scratch.path()]; + for found in [ + search_for_executable(paths.iter(), "prog").next(), + resolve_command(paths, "prog"), + ] { + assert!( + found.as_ref().is_some_and(|path| path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("bat"))), + "unexpected resolution: {found:?}" + ); + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/patterns.rs b/userland/brush/source/brush-core/src/patterns.rs new file mode 100644 index 0000000..7dc6b92 --- /dev/null +++ b/userland/brush/source/brush-core/src/patterns.rs @@ -0,0 +1,1076 @@ +//! Shell patterns + +use crate::{error, regex, sys, trace_categories}; +use std::{borrow::Cow, collections::VecDeque, path::Path}; + +/// Represents a piece of a shell pattern. +#[derive(Clone, Debug)] +pub(crate) enum PatternPiece { + /// A pattern that should be interpreted as a shell pattern. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl PatternPiece { + pub fn as_str(&self) -> &str { + match self { + Self::Pattern(s) => s, + Self::Literal(s) => s, + } + } + + /// Returns the text this piece matches when it holds no glob characters. A backslash in + /// a pattern piece escapes the next character and is discarded when matching. + fn to_unescaped_str(&self) -> Cow<'_, str> { + match self { + Self::Pattern(s) if s.contains('\\') => Cow::Owned(remove_pattern_escapes(s)), + Self::Pattern(s) | Self::Literal(s) => Cow::Borrowed(s), + } + } +} + +type PatternWord = Vec; + +/// Options for filename expansion. +#[derive(Clone, Debug, Default)] +pub(crate) struct FilenameExpansionOptions { + pub require_dot_in_pattern_to_match_dot_files: bool, +} + +/// Result of a pattern expansion, distinguishing "no glob metacharacters" from +/// "glob expansion attempted but found no matches". +#[derive(Debug, Default)] +pub(crate) enum PatternExpansionResult { + /// No glob metacharacters found; no expansion was attempted. + #[default] + NoGlob, + /// Glob expansion was attempted. Contains matching paths (may be empty). + Expanded(Vec), +} + +impl PatternExpansionResult { + /// Returns the expansion results, regardless of variant. + pub fn into_paths(self) -> Vec { + match self { + Self::NoGlob => vec![], + Self::Expanded(paths) => paths, + } + } + + /// Returns true if glob expansion was attempted but produced no results. + pub const fn is_unmatched_glob(&self) -> bool { + matches!(self, Self::Expanded(paths) if paths.is_empty()) + } +} + +/// Encapsulates a shell pattern. +#[derive(Clone, Debug)] +pub struct Pattern { + pieces: PatternWord, + enable_extended_globbing: bool, + multiline: bool, + case_insensitive: bool, +} + +impl Default for Pattern { + fn default() -> Self { + Self { + pieces: vec![], + enable_extended_globbing: false, + multiline: true, + case_insensitive: false, + } + } +} + +impl From for Pattern { + fn from(pieces: PatternWord) -> Self { + Self { + pieces, + ..Default::default() + } + } +} + +impl From<&PatternWord> for Pattern { + fn from(value: &PatternWord) -> Self { + Self { + pieces: value.clone(), + ..Default::default() + } + } +} + +impl From<&str> for Pattern { + fn from(value: &str) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value.to_owned())], + ..Default::default() + } + } +} + +impl From for Pattern { + fn from(value: String) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value)], + ..Default::default() + } + } +} + +impl Pattern { + /// Enables (or disables) extended globbing support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable extended globbing (extglob). + #[must_use] + pub const fn set_extended_globbing(mut self, value: bool) -> Self { + self.enable_extended_globbing = value; + self + } + + /// Enables (or disables) multiline support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable multiline matching. + #[must_use] + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Enables (or disables) case-insensitive matching for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable case-insensitive matching. + #[must_use] + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Returns whether or not the pattern is empty. + pub fn is_empty(&self) -> bool { + self.pieces.iter().all(|p| p.as_str().is_empty()) + } + + /// Placeholder function that always returns true. + pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool { + true + } + + /// Expands the pattern into a list of matching file paths. + /// + /// # Arguments + /// + /// * `working_dir` - The current working directory, used for relative paths. + /// * `path_filter` - Optionally provides a function that filters paths after expansion. + #[expect(clippy::too_many_lines)] + pub(crate) fn expand( + &self, + working_dir: &Path, + path_filter: Option<&PF>, + options: &FilenameExpansionOptions, + ) -> Result + where + PF: Fn(&Path) -> bool, + { + // If the pattern has no pieces at all, short-circuit; there's nothing to expand. + // Note: we intentionally do NOT short-circuit when pieces are present but empty + // (e.g. from a quoted empty string ""); those fall through to the literal branch + // below which correctly returns Expanded([""]) instead of NoGlob, preserving + // the argument even when nullglob is enabled. + if self.pieces.is_empty() { + return Ok(PatternExpansionResult::NoGlob); + + // Similarly, if we're *confident* the pattern doesn't require expansion, then we + // know there's a single expansion (before filtering). + } else if !self.pieces.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) + && requires_expansion(piece.as_str(), self.enable_extended_globbing) + }) { + let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect(); + + if let Some(filter) = path_filter + && !filter(Path::new(&concatenated)) + { + // No globs, but the literal was filtered out. Return NoGlob + // (not Expanded) so that callers don't mistake this for a + // failed glob match (which would trigger failglob). + return Ok(PatternExpansionResult::NoGlob); + } + + return Ok(PatternExpansionResult::Expanded(vec![concatenated])); + } + + tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}"); + + let mut components: Vec = vec![]; + for piece in &self.pieces { + let mut split_result: VecDeque<_> = sys::fs::split_path_for_pattern(piece.as_str()) + .map(|s| match piece { + PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()), + PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()), + }) + .collect(); + + if let Some(first_piece) = split_result.pop_front() { + if let Some(last_component) = components.last_mut() { + last_component.push(first_piece); + } else { + components.push(vec![first_piece]); + } + } + + while let Some(piece) = split_result.pop_front() { + components.push(vec![piece]); + } + } + + // Check if the path appears to be absolute by inspecting the first component. + // On Unix, a leading `/` produces an empty first component. On Windows, a + // drive-letter prefix like `c:` is also recognized. The platform-specific + // logic lives in `sys::fs::pattern_path_root`. + let absolute_root = components.first().and_then(|first_component| { + let flattened: String = first_component.iter().map(|p| p.as_str()).collect(); + sys::fs::pattern_path_root(&flattened) + }); + + let prefix_to_remove; + let mut paths_so_far = if let Some(root) = absolute_root { + prefix_to_remove = None; + // Skip the first component; it was consumed to determine the root. + components.remove(0); + vec![root] + } else { + // Build a prefix to remove after glob expansion so results are + // returned relative to the working directory. The prefix is + // normalized to use `/` separators because `push_path_for_pattern` + // also uses `/` on Windows (to avoid `PathBuf::push` drive-letter + // semantics) — if we left `\` here, the strip_prefix below would + // miss on Windows and leave results as absolute paths. + let working_dir_str = working_dir.to_string_lossy(); + let mut working_dir_str = + sys::fs::normalize_path_separators(&working_dir_str).into_owned(); + if !working_dir_str.ends_with('/') { + working_dir_str.push('/'); + } + + prefix_to_remove = Some(working_dir_str); + vec![working_dir.to_path_buf()] + }; + + for component in components { + if !component.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) + && requires_expansion(piece.as_str(), self.enable_extended_globbing) + }) { + let flattened = component + .iter() + .map(PatternPiece::to_unescaped_str) + .collect::(); + paths_so_far.retain_mut(|p| { + sys::fs::push_path_for_pattern(p, &flattened); + + // Drop candidates that don't name an existing directory entry. + // We use `symlink_metadata` (lstat) rather than `exists` (stat) so + // that we match on directory-entry existence -- consistent with the + // `read_dir`-based matching used for glob components below, and with + // bash. In particular this keeps dangling symlinks (which bash + // includes) while still rejecting a literal like `file/` whose + // trailing slash makes lstat fail with ENOTDIR for a regular file. + p.symlink_metadata().is_ok() + }); + continue; + } + + let current_paths = std::mem::take(&mut paths_so_far); + for current_path in current_paths { + let subpattern = Self::from(&component) + .set_extended_globbing(self.enable_extended_globbing) + .set_case_insensitive(self.case_insensitive); + + // Skip empty pieces (e.g. from `"".*`) so that a dot right after + // them still counts as the leading dot. + let subpattern_starts_with_dot = subpattern + .pieces + .iter() + .find(|piece| !piece.as_str().is_empty()) + .is_some_and(|piece| piece.as_str().starts_with('.')); + + let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files + || subpattern_starts_with_dot; + + let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| { + !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files + }; + + let regex = subpattern.to_regex(true, true)?; + let matches_regex = |dir_entry: &std::fs::DirEntry| { + regex + .is_match(dir_entry.file_name().to_string_lossy().as_ref()) + .unwrap_or(false) + }; + + let mut matching_paths_in_dir: Vec<_> = current_path + .read_dir() + .map_or_else(|_| vec![], |dir| dir.into_iter().collect()) + .into_iter() + .filter_map(|result| result.ok()) + .filter(matches_regex) + .filter(matches_dotfile_policy) + .map(|entry| entry.path()) + .collect(); + + matching_paths_in_dir.sort(); + + paths_so_far.append(&mut matching_paths_in_dir); + } + } + + let results: Vec<_> = paths_so_far + .into_iter() + .filter_map(|path| { + if let Some(filter) = path_filter + && !filter(path.as_path()) + { + return None; + } + + // Normalize separators *before* stripping the working-dir + // prefix so that `prefix_to_remove` (already normalized to + // use `/`) matches paths that may contain a mix of `\` and + // `/` on Windows. + let path_str = path.to_string_lossy(); + let normalized = sys::fs::normalize_path_separators(&path_str); + let mut path_ref: &str = normalized.as_ref(); + + if let Some(prefix_to_remove) = &prefix_to_remove + && let Some(stripped) = path_ref.strip_prefix(prefix_to_remove.as_str()) + { + path_ref = stripped; + } + + Some(path_ref.to_string()) + }) + .collect(); + + tracing::debug!(target: trace_categories::PATTERN, " => results: {results:?}"); + + Ok(PatternExpansionResult::Expanded(results)) + } + + /// Converts the pattern to a regular expression string. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex_str( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let mut regex_str = String::new(); + + if strict_prefix_match { + regex_str.push('^'); + } + + let mut current_pattern = String::new(); + for piece in &self.pieces { + match piece { + PatternPiece::Pattern(s) => { + current_pattern.push_str(s); + } + PatternPiece::Literal(s) => { + for c in s.chars() { + if crate::regex::regex_char_is_special(c) { + current_pattern.push('\\'); + } + current_pattern.push(c); + } + } + } + } + + let regex_piece = + pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?; + regex_str.push_str(regex_piece.as_str()); + + if strict_suffix_match { + regex_str.push('$'); + } + + Ok(regex_str) + } + + /// Converts the pattern to a regular expression. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?; + + tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'"); + + let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?; + Ok(re) + } + + /// Checks if the pattern exactly matches the given string. An error result + /// is returned if the pattern is found to be invalid or malformed + /// during processing. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn exactly_matches(&self, value: &str) -> Result { + let re = self.to_regex(true, true)?; + Ok(re.is_match(value)?) + } +} + +/// Removes the backslashes that escape characters in a shell pattern. +fn remove_pattern_escapes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + // A trailing backslash has nothing to escape; keep it. + result.push(chars.next().unwrap_or('\\')); + } else { + result.push(c); + } + } + result +} + +/// Checks whether a string contains glob metacharacters that would trigger +/// pathname expansion. Delegates to the pattern parser's grammar, which is +/// the single source of truth for what constitutes a glob metacharacter. +fn requires_expansion(s: &str, enable_extended_globbing: bool) -> bool { + brush_parser::pattern::pattern_has_glob_metacharacters(s, enable_extended_globbing) +} + +fn pattern_to_regex_str( + pattern: &str, + enable_extended_globbing: bool, +) -> Result { + Ok(brush_parser::pattern::pattern_to_regex_str( + pattern, + enable_extended_globbing, + )?) +} + +/// Removes the largest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_largest_matching_prefix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + let indices = s.char_indices().rev(); + let mut last_idx = s.len(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in indices { + let prefix = &s[0..last_idx]; + if re.is_match(prefix)? { + return Ok(&s[last_idx..]); + } + + last_idx = idx; + } + } + Ok(s) +} + +/// Removes the smallest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_smallest_matching_prefix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + let mut indices = s.char_indices(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + while indices.next().is_some() { + let next_index = indices.offset(); + let prefix = &s[0..next_index]; + if re.is_match(prefix)? { + return Ok(&s[next_index..]); + } + } + } + Ok(s) +} + +/// Removes the largest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_largest_matching_suffix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices() { + let suffix = &s[idx..]; + if re.is_match(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +/// Removes the smallest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_smallest_matching_suffix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices().rev() { + let suffix = &s[idx..]; + if re.is_match(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + fn pattern_to_exact_regex_str

(pattern: P) -> Result + where + P: Into, + { + let pattern: Pattern = pattern + .into() + .set_extended_globbing(true) + .set_multiline(false); + + pattern.to_regex_str(true, true) + } + + #[test] + fn test_pattern_translation() -> Result<()> { + assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$"); + assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$"); + assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$"); + assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$"); + assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$"); + assert_eq!( + pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(), + "^a(ab|ac)*$" + ); + assert_eq!( + pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(), + "^a(ab|ac)+$" + ); + assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$"); + assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$"); + assert_eq!( + pattern_to_exact_regex_str("[<{().[]*")?.as_str(), + r"^[<{().\[].*$" + ); + assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$"); + assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$"); + + Ok(()) + } + + #[test] + fn test_pattern_word_translation() -> Result<()> { + assert_eq!( + pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(), + "^a.*$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Pattern("a*".to_owned()), + PatternPiece::Literal("b".to_owned()), + ])? + .as_str(), + "^a.*b$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Literal("a*".to_owned()), + PatternPiece::Pattern("b".to_owned()), + ])? + .as_str(), + r"^a\*b$" + ); + + Ok(()) + } + + #[test] + fn test_remove_largest_matching_prefix() -> Result<()> { + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?, + "f" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*")))?, + "" + ); + assert_eq!( + remove_largest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_prefix() -> Result<()> { + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?, + "of" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("ooof")))?, + "" + ); + assert_eq!( + remove_smallest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_largest_matching_suffix() -> Result<()> { + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("x")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("o")))?, + "fo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("o*")))?, + "f" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("foo")))?, + "" + ); + assert_eq!( + remove_largest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_suffix() -> Result<()> { + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("x")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*o")))?, + "fo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("fooo")))?, + "" + ); + assert_eq!( + remove_smallest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + #[expect(clippy::cognitive_complexity)] + fn test_matching() -> Result<()> { + assert!(Pattern::from("abc").exactly_matches("abc")?); + + assert!(!Pattern::from("abc").exactly_matches("ABC")?); + assert!(!Pattern::from("abc").exactly_matches("xabcx")?); + assert!(!Pattern::from("abc").exactly_matches("")?); + assert!(!Pattern::from("abc").exactly_matches("abcd")?); + assert!(!Pattern::from("abc").exactly_matches("def")?); + + assert!(Pattern::from("*").exactly_matches("")?); + assert!(Pattern::from("*").exactly_matches("abc")?); + assert!(Pattern::from("*").exactly_matches(" ")?); + + assert!(Pattern::from("a*").exactly_matches("a")?); + assert!(Pattern::from("a*").exactly_matches("ab")?); + assert!(Pattern::from("a*").exactly_matches("a ")?); + + assert!(!Pattern::from("a*").exactly_matches("A")?); + assert!(!Pattern::from("a*").exactly_matches("")?); + assert!(!Pattern::from("a*").exactly_matches("bc")?); + assert!(!Pattern::from("a*").exactly_matches("xax")?); + assert!(!Pattern::from("a*").exactly_matches(" a")?); + + assert!(Pattern::from("*a").exactly_matches("a")?); + assert!(Pattern::from("*a").exactly_matches("ba")?); + assert!(Pattern::from("*a").exactly_matches("aa")?); + assert!(Pattern::from("*a").exactly_matches(" a")?); + + assert!(!Pattern::from("*a").exactly_matches("BA")?); + assert!(!Pattern::from("*a").exactly_matches("")?); + assert!(!Pattern::from("*a").exactly_matches("ab")?); + assert!(!Pattern::from("*a").exactly_matches("xax")?); + + Ok(()) + } + + fn make_extglob(s: &str) -> Pattern { + let pattern = Pattern::from(s).set_extended_globbing(true); + let regex_str = pattern.to_regex_str(true, true).unwrap(); + eprintln!("pattern: '{s}' => regex: '{regex_str}'"); + + pattern + } + + #[test] + fn test_extglob_or_matching() -> Result<()> { + assert!(make_extglob("@(a|b)").exactly_matches("a")?); + assert!(make_extglob("@(a|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(!make_extglob("@(a|b)").exactly_matches("c")?); + assert!(!make_extglob("@(a|b)").exactly_matches("ab")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_matching() -> Result<()> { + // Basic cases. + assert!(make_extglob("!(a)").exactly_matches("")?); + assert!(make_extglob("!(a)").exactly_matches(" ")?); + assert!(make_extglob("!(a)").exactly_matches("x")?); + assert!(make_extglob("!(a)").exactly_matches(" a ")?); + assert!(make_extglob("!(a)").exactly_matches("a ")?); + assert!(make_extglob("!(a)").exactly_matches("aa")?); + assert!(!make_extglob("!(a)").exactly_matches("a")?); + + assert!(make_extglob("a!(a)a").exactly_matches("aa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aba")?); + assert!(!make_extglob("a!(a)a").exactly_matches("a")?); + assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?); + assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?); + + // Alternates. + assert!(make_extglob("!(a|b)").exactly_matches("c")?); + assert!(make_extglob("!(a|b)").exactly_matches("ab")?); + assert!(make_extglob("!(a|b)").exactly_matches("aa")?); + assert!(make_extglob("!(a|b)").exactly_matches("bb")?); + assert!(!make_extglob("!(a|b)").exactly_matches("a")?); + assert!(!make_extglob("!(a|b)").exactly_matches("b")?); + + Ok(()) + } + + #[test] + fn test_extglob_advanced_not_matching() -> Result<()> { + assert!(make_extglob("!(a*)").exactly_matches("b")?); + assert!(make_extglob("!(a*)").exactly_matches("")?); + assert!(!make_extglob("!(a*)").exactly_matches("a")?); + assert!(!make_extglob("!(a*)").exactly_matches("abc")?); + assert!(!make_extglob("!(a*)").exactly_matches("aabc")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_degenerate_matching() -> Result<()> { + // Degenerate case. + assert!(make_extglob("!()").exactly_matches("a")?); + assert!(!make_extglob("!()").exactly_matches("")?); + + Ok(()) + } + + #[test] + fn test_extglob_zero_or_more_matching() -> Result<()> { + assert!(make_extglob("x*(a)x").exactly_matches("xx")?); + assert!(make_extglob("x*(a)x").exactly_matches("xax")?); + assert!(make_extglob("x*(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x*(a)x").exactly_matches("x")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("*(a|b)").exactly_matches("")?); + assert!(make_extglob("*(a|b)").exactly_matches("a")?); + assert!(make_extglob("*(a|b)").exactly_matches("b")?); + assert!(make_extglob("*(a|b)").exactly_matches("aba")?); + assert!(make_extglob("*(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("*(a|b)").exactly_matches("c")?); + assert!(!make_extglob("*(a|b)").exactly_matches("ca")?); + + Ok(()) + } + + #[test] + fn test_extglob_one_or_more_matching() -> Result<()> { + fn make_extglob(s: &str) -> Pattern { + Pattern::from(s).set_extended_globbing(true) + } + + assert!(make_extglob("x+(a)x").exactly_matches("xax")?); + assert!(make_extglob("x+(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x+(a)x").exactly_matches("xx")?); + assert!(!make_extglob("x+(a)x").exactly_matches("x")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("+(a|b)").exactly_matches("a")?); + assert!(make_extglob("+(a|b)").exactly_matches("b")?); + assert!(make_extglob("+(a|b)").exactly_matches("aba")?); + assert!(make_extglob("+(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("+(a|b)").exactly_matches("")?); + assert!(!make_extglob("+(a|b)").exactly_matches("c")?); + assert!(!make_extglob("+(a|b)").exactly_matches("ca")?); + + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?); + + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?); + + Ok(()) + } + + #[test] + fn test_remove_pattern_escapes() { + assert_eq!(remove_pattern_escapes("abc"), "abc"); + assert_eq!(remove_pattern_escapes(r"s\ub"), "sub"); + assert_eq!(remove_pattern_escapes(r"a\\b"), r"a\b"); + assert_eq!(remove_pattern_escapes(r"\*x"), "*x"); + assert_eq!(remove_pattern_escapes("ab\\"), "ab\\"); + } + + #[test] + fn test_requires_expansion() { + // Delegates to the PEG grammar; thorough coverage is in brush-parser. + // Here we just verify the integration works. + assert!(requires_expansion("*", false)); + assert!(requires_expansion("[abc]", false)); + assert!(!requires_expansion("]", false)); + assert!(!requires_expansion("hello", false)); + assert!(!requires_expansion("@(a)", false)); + assert!(requires_expansion("@(a)", true)); + } + + /// Extracts the `Expanded` payload from a `PatternExpansionResult`, + /// failing the test via `anyhow::bail!` otherwise. Avoids `panic!` which + /// is forbidden by the workspace clippy config. + fn expect_expanded(result: PatternExpansionResult) -> Result> { + let PatternExpansionResult::Expanded(paths) = result else { + anyhow::bail!("expected Expanded, got {result:?}"); + }; + Ok(paths) + } + + /// Regression test for the Windows prefix-strip mismatch fix. + /// + /// On Windows (pre-fix), `expand` would build the `prefix_to_remove` + /// using the platform `MAIN_SEPARATOR` (`\`) while `push_path_for_pattern` + /// appended components with `/`. The resulting mismatch made the + /// `strip_prefix` call a no-op, so relative globs produced absolute + /// paths. This test exercises the expansion path and verifies results + /// are returned relative to the working directory. + /// + /// On Unix, the same code path is exercised (both builds go through the + /// shared `normalize_path_separators` helpers), so this test serves as a + /// regression guard on both platforms. + #[test] + fn test_relative_glob_returns_relative_paths() -> Result<()> { + let scratch = tempfile::tempdir()?; + let sub = scratch.path().join("sub"); + std::fs::create_dir_all(&sub)?; + std::fs::write(sub.join("a.txt"), "")?; + std::fs::write(sub.join("b.txt"), "")?; + + let pattern = Pattern::from("sub/*.txt").set_extended_globbing(false); + let result = pattern.expand:: bool>( + scratch.path(), + None, + &FilenameExpansionOptions::default(), + )?; + + let paths = expect_expanded(result)?; + + let mut sorted = paths.clone(); + sorted.sort(); + assert_eq!( + sorted, + vec!["sub/a.txt".to_string(), "sub/b.txt".to_string()] + ); + + // None of the results should contain the absolute scratch path. + let scratch_str: String = scratch.path().to_string_lossy().into_owned(); + for p in &paths { + assert!( + !p.contains(scratch_str.as_str()), + "result {p:?} still contains absolute working-dir prefix {scratch_str:?}" + ); + } + + Ok(()) + } + + /// Verifies absolute-pattern expansion still works after the prefix + /// handling changes. + #[test] + fn test_absolute_glob_returns_absolute_paths() -> Result<()> { + let scratch = tempfile::tempdir()?; + std::fs::write(scratch.path().join("one.log"), "")?; + std::fs::write(scratch.path().join("two.log"), "")?; + + let abs_pattern = format!("{}/*.log", scratch.path().to_string_lossy()); + // Normalize to forward slashes so the test works consistently across + // platforms; the expander's `pattern_path_root` handles both. + let abs_pattern = abs_pattern.replace('\\', "/"); + + let pattern = Pattern::from(abs_pattern.as_str()).set_extended_globbing(false); + let result = pattern.expand:: bool>( + Path::new("/"), + None, + &FilenameExpansionOptions::default(), + )?; + + let paths = expect_expanded(result)?; + + assert_eq!(paths.len(), 2, "unexpected results: {paths:?}"); + let scratch_normalized: String = scratch.path().to_string_lossy().replace('\\', "/"); + for p in &paths { + // Use a plain byte-level suffix check rather than `Path::extension` + // since the results are strings and clippy flags `ends_with(".log")` + // as potentially case-sensitive. We explicitly wrote lowercase files. + assert!(p.as_bytes().ends_with(b".log"), "unexpected result {p:?}"); + // Should still reference the scratch directory (i.e., absolute). + assert!( + p.contains(scratch_normalized.as_str()), + "absolute result {p:?} should contain {scratch_normalized:?}" + ); + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/processes.rs b/userland/brush/source/brush-core/src/processes.rs new file mode 100644 index 0000000..334c3a4 --- /dev/null +++ b/userland/brush/source/brush-core/src/processes.rs @@ -0,0 +1,190 @@ +//! Process management + +use std::{path::PathBuf, sync::PoisonError}; + +use futures::FutureExt; + +use crate::{ExecutionResult, error, openfiles::OpenFiles, sys}; + +/// A waitable future that will yield the results of a child process's execution. +pub(crate) type WaitableChildProcess = std::pin::Pin< + Box> + Send>, +>; + +/// Tracks a child process being awaited. +pub struct ChildProcess { + /// A waitable future that will yield the results of a child process's execution. + /// It's only ever reached through `&mut self` (with `get_mut`, which never locks); the + /// mutex just makes this `Sync` without the future having to be. + exec_future: std::sync::Mutex, + /// The process ID of the child, if it's a native process of the shell's. + pid: Option, + /// If available, the process group ID of the child. + pgid: Option, +} + +impl ChildProcess { + /// Wraps a future that yields the result of a process, for a + /// [`ProcessLauncher`](crate::extensions::ProcessLauncher) to return. + /// + /// # Arguments + /// + /// * `future` - The future that will yield the process's result. + pub fn from_future( + future: impl futures::Future> + Send + 'static, + ) -> Self { + Self { + exec_future: std::sync::Mutex::new(Box::pin(future)), + pid: None, + pgid: None, + } + } + + /// Marks the process as a native child of the shell's, with the given IDs: job + /// control signals it, and waiting for it notices when it stops. + /// + /// # Arguments + /// + /// * `pid` - The process ID, if known. + /// * `pgid` - The process group ID, if the process has one. + #[allow(dead_code, reason = "unused on platforms that can't start processes")] + #[must_use] + pub(crate) const fn native_child( + mut self, + pid: Option, + pgid: Option, + ) -> Self { + self.pid = pid; + self.pgid = pgid; + self + } + + /// Returns the process's ID, if it's a native process of the shell's. + pub const fn pid(&self) -> Option { + self.pid + } + + /// Returns the process's group ID. + pub const fn pgid(&self) -> Option { + self.pgid + } + + /// Waits for the process to exit. + pub async fn wait(&mut self) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigtstp = sys::signal::tstp_signal_listener()?; + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigchld = sys::signal::chld_signal_listener()?; + + // The child may have stopped before there were listeners to hear about it + // (e.g., straight after `fg` resumed it). + if let Some(pid) = self.pid + && sys::signal::poll_for_stopped_child(pid)? + { + return Ok(ProcessWaitResult::Stopped); + } + + #[allow(clippy::ignored_unit_patterns)] + loop { + tokio::select! { + output = self.exec_future.get_mut().unwrap_or_else(PoisonError::into_inner) => { + break Ok(ProcessWaitResult::Completed(output?)) + }, + // Only this child's stop counts: another wait may be under way at the same time + // (e.g., a background job's), for a child of its own, and the terminal's stop + // may reach the shell without stopping this child. + _ = sigtstp.recv() => { + if self.poll_for_stop()? { + break Ok(ProcessWaitResult::Stopped); + } + }, + _ = sigchld.recv() => { + if self.poll_for_stop()? { + break Ok(ProcessWaitResult::Stopped); + } + }, + _ = sys::signal::await_ctrl_c() => { + // SIGINT got thrown. Handle it and continue looping. The child should + // have received it as well, and either handled it or ended up getting + // terminated (in which case we'll see the child exit). + }, + } + } + } + + /// Returns whether the process has stopped, consuming its stop status. + fn poll_for_stop(&self) -> Result { + match self.pid { + Some(pid) => sys::signal::poll_for_stopped_children(pid), + None => Ok(false), + } + } + + pub(crate) fn poll(&mut self) -> Option> { + let checkable_future = self + .exec_future + .get_mut() + .unwrap_or_else(PoisonError::into_inner); + checkable_future + .now_or_never() + .map(|result| result.map_err(Into::into)) + } +} + +/// Represents the result of waiting for an executing process. +pub enum ProcessWaitResult { + /// The process completed. + Completed(ExecutionResult), + /// The process stopped and has not yet completed. + Stopped, +} + +/// A process for the shell to launch: a program the shell has already resolved +/// (builtins, functions, and the like never get this far), with everything it +/// should start with. +#[non_exhaustive] +pub struct LaunchRequest { + /// Path to the executable, as resolved by the shell; relative to + /// [`Self::working_dir`], if it's relative. + pub program: PathBuf, + /// The value the process should observe as `argv[0]`. + pub argv0: String, + /// Arguments to the process, not including `argv[0]`. + pub args: Vec, + /// The complete environment for the process. + pub env: Vec<(String, String)>, + /// The working directory the process should start in. + pub working_dir: PathBuf, + /// The process's file descriptors. A standard stream (0, 1, or 2) that isn't + /// among them is closed; a launcher that can't start a process with one closed + /// gives it the null device, as the default launcher does, never one of the + /// shell's own. The shell's own standard streams + /// ([`OpenFile::Stdin`](crate::openfiles::OpenFile::Stdin), and so on) are + /// shared with the process, under whichever descriptors hold them. + pub open_files: OpenFiles, + /// The process group the process should run in. + pub process_group: ProcessGroup, + /// Whether to kill the process if the shell drops the [`ChildProcess`] for it + /// before it exits. + pub kill_on_drop: bool, +} + +/// The process group a process should run in. Platforms and +/// [`ProcessLauncher`](crate::extensions::ProcessLauncher)s without process +/// groups ignore this. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProcessGroup { + /// The shell's process group. + Inherit, + /// An existing process group, with the given ID. + Join(sys::process::ProcessId), + /// A new process group, led by the process. + New { + /// Whether the new group should become the terminal's foreground group. + take_foreground: bool, + }, + /// A new session, led by the process, with the terminal as its controlling + /// terminal. + NewSession, +} diff --git a/userland/brush/source/brush-core/src/prompt.rs b/userland/brush/source/brush-core/src/prompt.rs new file mode 100644 index 0000000..d7a4376 --- /dev/null +++ b/userland/brush/source/brush-core/src/prompt.rs @@ -0,0 +1,289 @@ +use crate::{ + ExecutionParameters, error, expansion, extensions, + shell::Shell, + sys::{self, users}, +}; +use std::path::Path; + +const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR"); +const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR"); +const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH"); + +pub(crate) async fn expand_prompt( + shell: &mut Shell, + params: &ExecutionParameters, + spec: &str, +) -> Result { + // Parse the prompt spec into its pieces. + let prompt_pieces = parse_prompt(spec)?; + + // Now, render each piece. + let mut formatted_prompt = String::new(); + for piece in prompt_pieces { + // Pieces that semantically represent a literal char (e.g. user wrote + // `\$` meaning a literal `$`, not input for pass-2 expansion). We + // prepend a `\` so pass 2 consumes it and leaves the char alone. + let semantically_literal = + matches!(piece, brush_parser::prompt::PromptPiece::DollarOrPound); + + let formatted_piece = format_prompt_piece(shell, piece)?; + + // Only useful when pass 2 actually consumes a `\` for that leading + // byte; otherwise the `\` would leak through. + if shell.options().expand_prompt_strings + && semantically_literal + && formatted_piece.starts_with(expansion::DOUBLE_QUOTED_ESCAPE_CHARS) + { + formatted_prompt.push('\\'); + } + + formatted_prompt.push_str(&formatted_piece); + } + + if shell.options().expand_prompt_strings { + // Now expand any remaining escape sequences, but without tilde-expansion. + // Use double-quote escape rules so that backslashes emitted in the + // previous step survive intact unless they precede a character that + // would also be escapable inside a double-quoted string. + let options = expansion::ExpanderOptions { + tilde_expand: false, + brace_expand: false, + unquoted_backslash_handling: expansion::UnquotedBackslashHandling::DoubleQuoted, + ..Default::default() + }; + formatted_prompt = + expansion::basic_expand_word_with_options(shell, params, &formatted_prompt, &options) + .await?; + } + + Ok(formatted_prompt) +} + +#[cached::macros::cached(max_size = 64, key = "String", convert = r#"{ spec.to_owned() }"#)] +fn parse_prompt( + spec: &str, +) -> Result, brush_parser::WordParseError> { + brush_parser::prompt::parse(spec) +} + +fn format_prompt_piece( + shell: &Shell, + piece: brush_parser::prompt::PromptPiece, +) -> Result { + let formatted = match piece { + brush_parser::prompt::PromptPiece::EscapedSequence(s) => s, + brush_parser::prompt::PromptPiece::Literal(l) => l, + brush_parser::prompt::PromptPiece::AsciiCharacter(c) => { + char::from_u32(c).map_or_else(String::new, |c| c.to_string()) + } + brush_parser::prompt::PromptPiece::Backslash => "\\".to_owned(), + brush_parser::prompt::PromptPiece::BellCharacter => "\x07".to_owned(), + brush_parser::prompt::PromptPiece::CarriageReturn => "\r".to_owned(), + brush_parser::prompt::PromptPiece::CurrentCommandNumber => { + shell.command_number().to_string() + } + brush_parser::prompt::PromptPiece::CurrentHistoryNumber => { + return error::unimp("prompt: current history number"); + } + brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?, + brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { + tilde_replaced, + basename, + } => format_current_working_directory(shell, tilde_replaced, basename), + brush_parser::prompt::PromptPiece::Date(format) => { + format_date(&chrono::Local::now(), &format) + } + brush_parser::prompt::PromptPiece::DollarOrPound => { + if users::is_root() { + "#".to_owned() + } else { + "$".to_owned() + } + } + // NOTE: We mimic bash and convert \[ into \001, a.k.a. RL_PROMPT_START_IGNORE. + // It will need to get removed before it's actually displayed. While present it + // also has the important (compatible) side effect of ensuring the text on either + // side of it is not concatenated together, potentially resulting in incompatible + // variable expansions. Also, we *only* do this if the shell is interactive. + brush_parser::prompt::PromptPiece::EndNonPrintingSequence => { + if shell.options().interactive { + "\x02".to_owned() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::EscapeCharacter => "\x1b".to_owned(), + brush_parser::prompt::PromptPiece::Hostname { + only_up_to_first_dot, + } => { + let hn = sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if only_up_to_first_dot && let Some((first, _)) = hn.split_once('.') { + return Ok(first.to_owned()); + } + hn + } + brush_parser::prompt::PromptPiece::Newline => "\n".to_owned(), + brush_parser::prompt::PromptPiece::NumberOfManagedJobs => { + shell.jobs().jobs.len().to_string() + } + brush_parser::prompt::PromptPiece::ShellBaseName => { + if let Some(shell_name) = shell.current_shell_name() { + Path::new(shell_name.as_ref()) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::ShellRelease => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_PATCH}") + } + brush_parser::prompt::PromptPiece::ShellVersion => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}") + } + // NOTE: See above note for EndNonPrintingSequence + brush_parser::prompt::PromptPiece::StartNonPrintingSequence => { + if shell.options().interactive { + "\x01".to_owned() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::TerminalDeviceBaseName => { + sys::terminal::try_get_terminal_device_path() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default() + } + brush_parser::prompt::PromptPiece::Time(time_fmt) => { + format_time(&chrono::Local::now(), &time_fmt) + } + }; + + Ok(formatted) +} + +fn format_current_working_directory( + shell: &Shell, + tilde_replaced: bool, + basename: bool, +) -> String { + let mut working_dir_str = shell.working_dir().to_string_lossy().to_string(); + + if tilde_replaced { + working_dir_str = shell.tilde_shorten(working_dir_str); + } + + if basename && let Some(filename) = Path::new(&working_dir_str).file_name() { + working_dir_str = filename.to_string_lossy().to_string(); + } + + // ast-grep-ignore: platform-cfg-outside-sys + if cfg!(windows) { + working_dir_str = working_dir_str.replace('\\', "/"); + } + + working_dir_str +} + +fn format_time( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptTimeFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + let formatted = match format { + brush_parser::prompt::PromptTimeFormat::TwelveHourAM => datetime.format("%I:%M %p"), + brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS => datetime.format("%I:%M:%S"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMM => datetime.format("%H:%M"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS => datetime.format("%H:%M:%S"), + }; + + formatted.to_string() +} + +fn format_date( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptDateFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + match format { + brush_parser::prompt::PromptDateFormat::WeekdayMonthDate => { + datetime.format("%a %b %d").to_string() + } + brush_parser::prompt::PromptDateFormat::Custom(fmt) => { + let fmt_items = chrono::format::StrftimeItems::new(fmt); + datetime.format_with_items(fmt_items).to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_time() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T13:34:56.789Z").unwrap(); + + assert_eq!( + format_time(&dt, &brush_parser::prompt::PromptTimeFormat::TwelveHourAM), + "01:34 PM" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS + ), + "13:34:56" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS + ), + "01:34:56" + ); + } + + #[test] + fn test_format_date() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T12:34:56.789Z").unwrap(); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::WeekdayMonthDate + ), + "Wed Dec 25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from("%Y-%m-%d")) + ), + "2024-12-25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from( + "%Y-%m-%d %H:%M:%S.%f" + )) + ), + "2024-12-25 12:34:56.789000000" + ); + } +} diff --git a/userland/brush/source/brush-core/src/regex.rs b/userland/brush/source/brush-core/src/regex.rs new file mode 100644 index 0000000..d19685b --- /dev/null +++ b/userland/brush/source/brush-core/src/regex.rs @@ -0,0 +1,277 @@ +#![allow(clippy::needless_pass_by_value)] + +use std::borrow::Cow; +use std::cell::RefCell; + +use crate::error; +use cached::Cached; + +/// Cache mapping a (pattern, case-insensitive, multiline) key to a compiled regex. +type RegexCache = cached::LruCache<(String, bool, bool), fancy_regex::Regex>; + +thread_local! { + // Wrapped in `Option` so that if cache construction ever fails we gracefully + // degrade to compiling regexes uncached rather than panicking. (With a fixed + // positive `max_size` this always succeeds, but `build()` is fallible.) + static REGEX_CACHE: RefCell> = + RefCell::new(cached::LruCache::builder().max_size(64).build().ok()); +} + +/// Represents a piece of a regular expression. +#[derive(Clone, Debug)] +pub(crate) enum RegexPiece { + /// A pattern that should be interpreted as a regular expression. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl RegexPiece { + fn to_regex_str(&self) -> Cow<'_, str> { + match self { + Self::Pattern(s) => Cow::Borrowed(s.as_str()), + Self::Literal(s) => escape_literal_regex_piece(s.as_str()), + } + } +} + +type RegexWord = Vec; + +/// Encapsulates a regular expression usable in the shell. +#[derive(Clone, Debug)] +pub struct Regex { + pieces: RegexWord, + case_insensitive: bool, + multiline: bool, +} + +impl From for Regex { + fn from(pieces: RegexWord) -> Self { + Self { + pieces, + case_insensitive: false, + multiline: false, + } + } +} + +impl Regex { + /// Sets the regular expression's case sensitivity. + /// + /// # Arguments + /// + /// * `value` - The new case sensitivity value. + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Enables (or disables) multiline support for this pattern: `.` then also matches + /// newline characters. `^` and `$` always anchor to the whole string, as in POSIX + /// regular expressions, which have no line anchors. + /// + /// # Arguments + /// + /// * `value` - The new multiline value. + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Computes if the regular expression matches the given string. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn matches(&self, value: &str) -> Result>>, error::Error> { + let regex_pattern: String = self + .pieces + .iter() + .map(|piece| piece.to_regex_str()) + .collect(); + + let re = compile_regex(regex_pattern, self.case_insensitive, self.multiline)?; + + Ok(re.captures(value)?.map(|captures| { + captures + .iter() + .map(|c| c.map(|m| m.as_str().to_owned())) + .collect() + })) + } +} + +pub(crate) fn compile_regex( + regex_str: String, + case_insensitive: bool, + multiline: bool, +) -> Result { + // Move regex_str into the key to avoid cloning on cache-hit path. + let key = (regex_str, case_insensitive, multiline); + + let cached_regex = REGEX_CACHE.with(|cache| { + cache + .borrow_mut() + .as_mut() + .and_then(|c| c.cache_get(&key).cloned()) + }); + if let Some(re) = cached_regex { + return Ok(re); + } + + // Handle identified cases where a shell-supported regex isn't supported directly by + // `fancy_regex` -- specifically, adding missing escape characters. + let mut regex_str = add_missing_escape_chars_to_regex(key.0.as_str()); + + // Handle multiline enablement. + if multiline { + // The fancy_regex crate internally seems to have flags that can be used + // to enable multiline support, but they're not exposed via its + // RegexBuilder. We instead just prefix with the right flags. + let updated_str = std::format!("(?s){regex_str}"); + regex_str = updated_str.into(); + } + + let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_ref()); + builder.case_insensitive(case_insensitive); + + let re = match builder.build() { + Ok(re) => re, + Err(e) => return Err(error::ErrorKind::InvalidRegexError(e, regex_str.to_string()).into()), + }; + + // Release borrow on key.0 before moving key into cache_set. + drop(regex_str); + + REGEX_CACHE.with(|cache| { + if let Some(c) = cache.borrow_mut().as_mut() { + c.cache_set(key, re.clone()); + } + }); + + Ok(re) +} + +fn add_missing_escape_chars_to_regex(s: &str) -> Cow<'_, str> { + // We may see a character class with an unescaped '[' (open bracket) character. We need + // to escape that character. + let mut in_escape = false; + let mut in_brackets = false; + // A ']' in the first member position of a bracket expression (immediately after the + // opening '[' or after the leading '^' that negates it) is a member of the + // expression, not its terminator. + let mut at_first_member = false; + let mut insertion_positions = vec![]; + + let mut peekable = s.char_indices().peekable(); + while let Some((byte_offset, c)) = peekable.next() { + let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':'); + let was_at_first_member = at_first_member; + at_first_member = false; + + match c { + '[' if !in_escape && !in_brackets => { + in_brackets = true; + // A '^' here negates the expression; it isn't a member itself, so the + // first member position is the one after it. Any later '^' is an + // ordinary member. + let _ = peekable.next_if(|(_, c)| *c == '^'); + at_first_member = true; + } + '[' if !in_escape && in_brackets && !next_is_colon => { + // Need to escape. + insertion_positions.push(byte_offset); + } + ']' if !in_escape && in_brackets && was_at_first_member => { + // `fancy_regex` doesn't implement the rule that this ']' is a member; + // escape it so it's a member there too, and so it can be the low end + // of a range (e.g. `[]-a]`). + insertion_positions.push(byte_offset); + } + ']' if !in_escape && in_brackets => { + in_brackets = false; + } + _ => (), + } + + in_escape = !in_escape && c == '\\'; + } + + if insertion_positions.is_empty() { + return s.into(); + } + + let mut updated = s.to_owned(); + for pos in insertion_positions.iter().rev() { + updated.insert(*pos, '\\'); + } + + updated.into() +} + +fn escape_literal_regex_piece(s: &str) -> Cow<'_, str> { + let mut result = String::new(); + + for c in s.chars() { + match c { + c if regex_char_is_special(c) => { + result.push('\\'); + result.push(c); + } + c => result.push(c), + } + } + + result.into() +} + +pub(crate) const fn regex_char_is_special(c: char) -> bool { + matches!( + c, + '\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_missing_escape_chars_to_regex() { + // Negative cases -- where we don't need to escape. + assert_eq!(add_missing_escape_chars_to_regex("a[b]"), "a[b]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a\[b\]"), r"a\[b\]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[b\[]"), r"a[b\[]"); + + // Positive case -- where we need to escape. + assert_eq!(add_missing_escape_chars_to_regex(r"a[b[]"), r"a[b\[]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[[]"), r"a[\[]"); + } + + #[test] + fn test_leading_close_bracket_in_bracket_expression() { + // A ']' in the first member position (after any '^') is a member of the bracket + // expression, not its terminator, so the expression is still open after it. + // It's escaped as well, since `fancy_regex` doesn't implement that rule; that + // also makes it the low end of a range in `[]-a]`, as it is in a shell pattern. + assert_eq!(add_missing_escape_chars_to_regex("[]]"), r"[\]]"); + assert_eq!(add_missing_escape_chars_to_regex("[^]]"), r"[^\]]"); + assert_eq!(add_missing_escape_chars_to_regex("[][]"), r"[\]\[]"); + assert_eq!(add_missing_escape_chars_to_regex("[^][]"), r"[^\]\[]"); + assert_eq!(add_missing_escape_chars_to_regex("[]a[]"), r"[\]a\[]"); + assert_eq!(add_missing_escape_chars_to_regex("[]-a]"), r"[\]-a]"); + + // The rule only applies in the first member position: the ']' in `[a]` and the + // already-escaped one in `[\]]` both close their expressions. + assert_eq!(add_missing_escape_chars_to_regex("[a][]x]"), r"[a][\]x]"); + assert_eq!(add_missing_escape_chars_to_regex(r"[\]][]x]"), r"[\]][\]x]"); + + // Only the '^' that negates the expression is skipped over; a later one is an + // ordinary member, so the ']' after it terminates the expression. + assert_eq!(add_missing_escape_chars_to_regex("[^^][ab]"), "[^^][ab]"); + assert_eq!(add_missing_escape_chars_to_regex("[^^][b[]"), r"[^^][b\[]"); + + // A '^' outside a bracket expression doesn't open one. + assert_eq!(add_missing_escape_chars_to_regex("^[a[]"), r"^[a\[]"); + } +} diff --git a/userland/brush/source/brush-core/src/results.rs b/userland/brush/source/brush-core/src/results.rs new file mode 100644 index 0000000..e3af51f --- /dev/null +++ b/userland/brush/source/brush-core/src/results.rs @@ -0,0 +1,311 @@ +//! Encapsulation of execution results. + +#[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys +use std::os::unix::process::ExitStatusExt; + +use crate::{error, processes, tasks}; + +/// Represents the result of executing a command or similar item. +#[derive(Default)] +#[must_use] +pub struct ExecutionResult { + /// The control flow transition to apply after execution. + pub next_control_flow: ExecutionControlFlow, + /// The exit code resulting from execution. + pub exit_code: ExecutionExitCode, +} + +impl ExecutionResult { + /// Returns a new `ExecutionResult` with the given exit code. + /// + /// # Arguments + /// + /// * `exit_code` - The exit code of the command. + pub fn new(exit_code: u8) -> Self { + Self { + exit_code: exit_code.into(), + ..Self::default() + } + } + + /// Returns a new `ExecutionResult` reflecting a process that was stopped. + pub fn stopped() -> Self { + // TODO(jobs): Decide how to sort this out in a platform-independent way. + const SIGTSTP: std::os::raw::c_int = 20; + + #[expect(clippy::cast_possible_truncation)] + Self::new(128 + SIGTSTP as u8) + } + + /// Returns a new `ExecutionResult` with an exit code of 0. + pub const fn success() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::Success, + } + } + + /// Returns a new `ExecutionResult` with a general error exit code. + pub const fn general_error() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::GeneralError, + } + } + + /// Returns whether the command was successful. + pub const fn is_success(&self) -> bool { + self.exit_code.is_success() + } + + /// Returns whether the execution result indicates normal control flow. + /// Returns `false` if there is any control flow transition requested. + pub const fn is_normal_flow(&self) -> bool { + matches!(self.next_control_flow, ExecutionControlFlow::Normal) + } + + /// Returns whether the execution result indicates a loop break. + pub const fn is_break(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::BreakLoop { .. } + ) + } + + /// Returns whether the execution result indicates a loop continue. + pub const fn is_continue(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ContinueLoop { .. } + ) + } + + /// Returns whether the execution result indicates an early return + /// from a function or script, or an exit from the shell. Returns `false` + /// otherwise, including loop breaks or continues. + pub const fn is_return_or_exit(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript | ExecutionControlFlow::ExitShell + ) + } + + /// Returns whether the execution result indicates an exit from the shell. + pub const fn is_exit(&self) -> bool { + matches!(self.next_control_flow, ExecutionControlFlow::ExitShell) + } +} + +impl From for ExecutionResult { + fn from(exit_code: ExecutionExitCode) -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code, + } + } +} + +impl From for ExecutionResult { + fn from(wait_result: ExecutionWaitResult) -> Self { + match wait_result { + ExecutionWaitResult::Completed(result) => result, + // TODO(jobs): We need to job-manage the stopped process. + ExecutionWaitResult::Stopped(..) => Self::stopped(), + } + } +} + +impl From for ExecutionResult { + fn from(output: std::process::Output) -> Self { + if let Some(code) = output.status.code() { + #[expect(clippy::cast_sign_loss)] + return Self::new((code & 0xFF) as u8); + } + + #[cfg(unix)] // ast-grep-ignore: platform-cfg-outside-sys + if let Some(signal) = output.status.signal() { + #[expect(clippy::cast_sign_loss)] + return Self::new((signal & 0xFF) as u8 + 128); + } + + tracing::error!("unhandled process exit"); + Self::new(127) + } +} + +/// Represents an exit code from execution. +#[derive(Clone, Copy, Default)] +pub enum ExecutionExitCode { + /// Indicates successful execution. + #[default] + Success, + /// Indicates a general error. + GeneralError, + /// Indicates invalid usage. + InvalidUsage, + /// Cannot execute the command. + CannotExecute, + /// Indicates a command or similar item was not found. + NotFound, + /// Indicates execution was interrupted. + Interrupted, + /// Indicates a broken pipe (SIGPIPE) was encountered. + BrokenPipe, + /// Indicates unimplemented functionality was encountered. + Unimplemented, + /// A custom exit code. + Custom(u8), +} + +impl ExecutionExitCode { + /// Returns whether the exit code indicates success. + pub const fn is_success(&self) -> bool { + matches!(self, Self::Success) + } +} + +impl From for ExecutionExitCode { + fn from(code: u8) -> Self { + match code { + 0 => Self::Success, + 1 => Self::GeneralError, + 2 => Self::InvalidUsage, + 99 => Self::Unimplemented, + 126 => Self::CannotExecute, + 127 => Self::NotFound, + 130 => Self::Interrupted, + 141 => Self::BrokenPipe, + code => Self::Custom(code), + } + } +} + +impl From for u8 { + fn from(code: ExecutionExitCode) -> Self { + Self::from(&code) + } +} + +impl From<&ExecutionExitCode> for u8 { + fn from(code: &ExecutionExitCode) -> Self { + match code { + ExecutionExitCode::Success => 0, + ExecutionExitCode::GeneralError => 1, + ExecutionExitCode::InvalidUsage => 2, + ExecutionExitCode::Unimplemented => 99, + ExecutionExitCode::CannotExecute => 126, + ExecutionExitCode::NotFound => 127, + ExecutionExitCode::Interrupted => 130, + ExecutionExitCode::BrokenPipe => 141, + ExecutionExitCode::Custom(code) => *code, + } + } +} + +/// Represents a control flow transition to apply. +#[derive(Clone, Copy, Default)] +pub enum ExecutionControlFlow { + /// Continue normal execution. + #[default] + Normal, + /// Break out of an enclosing loop. + BreakLoop { + /// Identifies which level of nested loops to break out of. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Continue to the next iteration of an enclosing loop. + ContinueLoop { + /// Identifies which level of nested loops to continue. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Return from the current function or script. + ReturnFromFunctionOrScript, + /// Exit the shell. + ExitShell, +} + +impl ExecutionControlFlow { + /// Attempts to decrement the loop levels for `BreakLoop` or `ContinueLoop`. + /// If the levels reach zero, transitions to `Normal`. If the control flow is not + /// a loop break or continue, no changes are made. + #[must_use] + pub const fn try_decrement_loop_levels(&self) -> Self { + match self { + Self::BreakLoop { levels: 0 } | Self::ContinueLoop { levels: 0 } => Self::Normal, + Self::BreakLoop { levels } => Self::BreakLoop { + levels: *levels - 1, + }, + Self::ContinueLoop { levels } => Self::ContinueLoop { + levels: *levels - 1, + }, + control_flow => *control_flow, + } + } +} + +/// Represents the result of spawning an execution; captures both execution +/// that immediately returns as well as execution that starts a process +/// asynchronously. +pub enum ExecutionSpawnResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that a process was started and had not yet completed. + StartedProcess(processes::ChildProcess), + /// Indicates that a task was started to handle the execution asynchronously. + StartedTask(tasks::TaskHandle>), +} + +impl From for ExecutionSpawnResult { + fn from(result: ExecutionResult) -> Self { + Self::Completed(result) + } +} + +impl ExecutionSpawnResult { + /// Waits for the command to complete. + pub async fn wait(self) -> Result { + let result = match self { + Self::StartedProcess(mut child) => { + // Wait for the process to exit or for a relevant signal, whichever happens + // first. + match child.wait().await? { + processes::ProcessWaitResult::Completed(result) => { + ExecutionWaitResult::Completed(result) + } + processes::ProcessWaitResult::Stopped => ExecutionWaitResult::Stopped(child), + } + } + Self::Completed(result) => ExecutionWaitResult::Completed(result), + Self::StartedTask(join_handle) => { + let result = join_handle.await?; + ExecutionWaitResult::Completed(result?) + } + }; + + Ok(result) + } + + pub(crate) async fn poll(self) -> Result { + let result = match self { + Self::StartedProcess(child) => ExecutionWaitResult::Stopped(child), + Self::Completed(result) => ExecutionWaitResult::Completed(result), + Self::StartedTask(join_handle) => { + // TODO(jobs): This isn't right. + let result = join_handle.await?; + ExecutionWaitResult::Completed(result?) + } + }; + + Ok(result) + } +} + +/// Represents the result of waiting for an execution to complete. +pub enum ExecutionWaitResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that the execution was stopped. + Stopped(processes::ChildProcess), +} diff --git a/userland/brush/source/brush-core/src/shell.rs b/userland/brush/source/brush-core/src/shell.rs new file mode 100644 index 0000000..80f7514 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell.rs @@ -0,0 +1,699 @@ +//! Module defining the core shell structure and behavior. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::{ + ExecutionControlFlow, ExecutionResult, builtins, env::ShellEnvironment, error, extensions, + functions, interfaces, jobs, keywords, openfiles, options::RuntimeOptions, pathcache, + wellknownvars, +}; + +/// Type for storing a key bindings helper. +pub type KeyBindingsHelper = Arc>; + +/// Type alias for shell file descriptors. +pub type ShellFd = i32; + +// NOTE: The submodule files below (e.g., `shell/traps.rs`, `shell/callstack.rs`) contain +// `impl Shell` blocks that provide methods coordinating with types defined in the +// corresponding top-level modules (e.g., `traps.rs`, `callstack.rs`). This is an intentional +// layered architecture: top-level modules define domain types and data structures, while +// shell/ submodules implement Shell methods that operate on those types. + +mod builder; +mod builtin_registry; +mod callstack; +mod completion; +mod env; +mod execution; +mod expansion; +mod fs; +mod funcs; +mod history; +mod initscripts; +mod io; +mod job_control; +mod parsing; +mod prompts; +mod readline; +mod state; +mod traps; + +pub use builder::{CreateOptions, ShellBuilder, ShellBuilderState}; +pub use initscripts::{ProfileLoadBehavior, RcLoadBehavior}; +pub use state::ShellState; + +/// Represents an instance of a shell. +/// +/// # Type Parameters +/// +/// * `SE` - The shell extensions implementation to use. These extensions are statically injected +/// into the shell at compile time to provide custom behavior. When unspecified, defaults to +/// `DefaultShellExtensions`, which provide standard behavior. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Shell { + /// Injected error behavior. + #[cfg_attr(feature = "serde", serde(skip, default = "default_error_formatter"))] + error_formatter: SE::ErrorFormatter, + + /// Injected process launcher. + #[cfg_attr(feature = "serde", serde(skip, default = "default_process_launcher"))] + process_launcher: SE::ProcessLauncher, + + /// Injected task spawner. + #[cfg_attr(feature = "serde", serde(skip, default = "default_task_spawner"))] + task_spawner: SE::TaskSpawner, + + /// The builtins' state; see [`crate::ExecutionContext::state_mut`]. + #[cfg_attr(feature = "serde", serde(skip, default))] + builtin_states: builtins::BuiltinStates, + + /// Trap handler configuration for the shell. + traps: crate::traps::TrapHandlerConfig, + + /// Manages files opened and accessible via redirection operators. + open_files: openfiles::OpenFiles, + + /// The current working directory. + working_dir: PathBuf, + + /// The shell environment, containing shell variables. + env: ShellEnvironment, + + /// Shell function definitions. + funcs: functions::FunctionEnv, + + /// Runtime shell options. + options: RuntimeOptions, + + /// State of managed jobs. + /// TODO(serde): Need to warn somehow that jobs cannot be serialized. + #[cfg_attr(feature = "serde", serde(skip))] + jobs: jobs::JobManager, + + /// Shell aliases. + aliases: HashMap, + + /// The status of the last completed command. + last_exit_status: u8, + + /// Tracks changes to `last_exit_status`. Assignment-only commands observe it + /// to tell whether an expansion set a status (see `interp.rs`), so code that + /// rolls back `$?` must roll this back too. + last_exit_status_change_count: usize, + + /// The status of each of the commands in the last pipeline. + last_pipeline_statuses: Vec, + + /// Clone depth from the original ancestor shell. + depth: usize, + + /// The number the `\#` prompt escape shows: 1, plus one for each command the + /// interactive loop has run. + command_number: usize, + + /// Shell name + name: Option, + + /// Positional shell arguments (not including shell name). + args: Vec, + + /// Shell version + version: Option, + + /// Detailed display string for the shell + product_display_str: Option, + + /// Function/script call stack. + call_stack: crate::callstack::CallStack, + + /// Directory stack used by pushd et al. + directory_stack: Vec, + + /// Programmable completion state. + completion: crate::completion::State, + + /// Shell built-in commands. + #[cfg_attr(feature = "serde", serde(skip))] + builtins: builtins::Registry, + + /// Shell program location cache. + program_location_cache: pathcache::PathCache, + + /// Last "SECONDS" captured time. + last_stopwatch_time: std::time::SystemTime, + + /// Last "SECONDS" offset requested. + last_stopwatch_offset: u32, + + /// Parser implementation to use. + #[cfg_attr(feature = "serde", serde(skip))] + parser_impl: crate::parser::ParserImpl, + + /// Key bindings for the shell, optionally implemented by an interactive shell. + #[cfg_attr(feature = "serde", serde(skip))] + key_bindings: Option, + + /// History of commands executed in the shell. + history: Option, +} + +impl Clone for Shell { + fn clone(&self) -> Self { + Self { + error_formatter: self.error_formatter.clone(), + process_launcher: self.process_launcher.clone(), + task_spawner: self.task_spawner.clone(), + builtin_states: self.builtin_states.clone(), + traps: self.traps.clone(), + open_files: self.open_files.clone(), + working_dir: self.working_dir.clone(), + env: self.env.clone(), + funcs: self.funcs.clone(), + options: self.options.clone(), + jobs: jobs::JobManager::new(), + aliases: self.aliases.clone(), + last_exit_status: self.last_exit_status, + last_exit_status_change_count: self.last_exit_status_change_count, + last_pipeline_statuses: self.last_pipeline_statuses.clone(), + name: self.name.clone(), + args: self.args.clone(), + version: self.version.clone(), + product_display_str: self.product_display_str.clone(), + call_stack: { + // Subshells must not inherit the parent's "currently handling signal X" + // state; otherwise a trap handler that spawns a subshell would see itself + // as already inside that handler and skip re-entrant delivery. + let mut cs = self.call_stack.clone(); + cs.clear_active_trap_signals(); + cs + }, + directory_stack: self.directory_stack.clone(), + // A subshell of a completion function (e.g. `$(compgen ...)`) is in that + // completion too. Like a forked subshell in bash, it changes only its own copy + // (e.g. with `compopt`), which its parent doesn't see. + completion: self.completion.clone(), + builtins: self.builtins.clone(), + program_location_cache: self.program_location_cache.clone(), + last_stopwatch_time: self.last_stopwatch_time, + last_stopwatch_offset: self.last_stopwatch_offset, + parser_impl: self.parser_impl, + key_bindings: self.key_bindings.clone(), + history: self.history.clone(), + depth: self.depth + 1, + command_number: self.command_number, + } + } +} + +impl AsRef for Shell { + fn as_ref(&self) -> &Self { + self + } +} + +impl AsMut for Shell { + fn as_mut(&mut self) -> &mut Self { + self + } +} + +impl Shell { + /// Returns a new shell instance created with the given options. + /// Does *not* load any configuration files (e.g., bashrc). + /// + /// # Arguments + /// + /// * `options` - The options to use when creating the shell. + pub(crate) fn new(options: CreateOptions) -> Result { + // Compute runtime options before moving fields out of `options`. + let runtime_options = RuntimeOptions::defaults_from(&options); + + // Instantiate the shell with some defaults. + let mut shell = Self { + error_formatter: options.error_formatter, + process_launcher: options.process_launcher, + task_spawner: options.task_spawner, + builtin_states: builtins::BuiltinStates::default(), + open_files: openfiles::OpenFiles::new(), + options: runtime_options, + name: options.shell_name, + args: options.shell_args.unwrap_or_default(), + version: options.shell_version, + product_display_str: options.shell_product_display_str, + working_dir: options.working_dir.map_or_else(std::env::current_dir, Ok)?, + builtins: options.builtins, + parser_impl: options.parser, + key_bindings: options.key_bindings, + ..Self::default() + }; + + // Add in any open files provided. + shell.open_files.update_from(options.fds.into_iter()); + + // TODO(patterns): Without this a script that sets extglob will fail because we + // parse the entire script with the same settings. + shell.options.extended_globbing = true; + + // If requested, seed parameters from environment. + if !options.do_not_inherit_env { + wellknownvars::inherit_env_vars(&mut shell)?; + } + + // If requested, set well-known variables. + if !options.skip_well_known_vars { + wellknownvars::init_well_known_vars(&mut shell)?; + } + + // Set any provided variables. + for (var_name, var_value) in options.vars { + shell.env.set_global(var_name, var_value)?; + } + + // Set up history, if relevant. The history file itself is loaded later, once startup + // files have run, since they may change HISTFILE. + if shell.options.enable_command_history { + shell.history = Some(crate::history::History::default()); + } + + Ok(shell) + } +} + +impl Shell { + /// Increments the interactive line offset in the shell by the indicated number + /// of lines. The interactive loop calls this once after each command it runs, so + /// it also advances the command number that the `\#` prompt escape shows. + /// + /// # Arguments + /// + /// * `delta` - The number of lines to increment the current line offset by. + pub fn increment_interactive_line_offset(&mut self, delta: usize) { + self.call_stack.increment_current_line_offset(delta); + // shortcut: counts every line the loop runs, which bash doesn't for blank, comment-only + // or unparsable ones or for a bound key; count where the reader loop parses, once it's + // in core. + self.command_number += 1; + } + + /// Updates the currently executing command in the shell. + pub fn set_current_cmd(&mut self, cmd: &impl brush_parser::ast::Node) { + self.call_stack + .set_current_pos(cmd.location().map(|span| span.start)); + } + + /// Updates the `$_` shell variable (last-argument of the previous simple + /// command). + /// + /// Passes `Some(last_arg)` to record the last argument of the just-executed + /// command, or `None` to clear `$_` (used for assignment-only statements, + /// which bash treats as having no "last argument"). + /// + /// The update is applied in-place so that attributes on `_` (notably + /// `readonly`) are preserved: attempting to update a readonly `_` is a + /// silent no-op, matching bash's observable stdout behavior. + pub(crate) fn update_last_arg_variable(&mut self, last_arg: Option) { + // Bash refuses to update a readonly `_`, emitting an error to stderr + // on each attempt. We silently skip the update here — the observable + // stdout effect ($_ stays unchanged) matches bash; the missing stderr + // diagnostics are harmless. + if self + .env + .get_using_policy("_", crate::env::EnvironmentLookup::Anywhere) + .is_some_and(|v| v.is_readonly()) + { + return; + } + + // Replace the variable entirely (fresh, non-exported). This matches + // bash, which never exports `_` — even under `set -a` — and always + // clears any previously-set attributes (except readonly, handled + // above). + let value = last_arg.unwrap_or_default(); + let _ = self + .env + .set_global("_", crate::variables::ShellVariable::new(value)); + } + + /// Captures the state the last command left behind; see [`SavedCommandStatus`]. + pub fn save_command_status(&self) -> SavedCommandStatus { + SavedCommandStatus { + exit_status: self.last_exit_status, + exit_status_change_count: self.last_exit_status_change_count, + pipeline_statuses: self.last_pipeline_statuses.clone(), + last_arg: self.env_str("_").map(|value| value.into_owned()), + } + } + + /// Reapplies a snapshot, exit-status change counter included. Consumes it; `clone` it to + /// put the same one back more than once, e.g. between successive hook functions. + /// + /// # Arguments + /// + /// * `saved` - The snapshot to restore. + pub fn restore_command_status(&mut self, saved: SavedCommandStatus) { + self.last_pipeline_statuses = saved.pipeline_statuses; + // Assigned directly rather than through `set_last_exit_status`, which would bump + // the change counter we're about to put back. + self.last_exit_status = saved.exit_status; + self.last_exit_status_change_count = saved.exit_status_change_count; + match saved.last_arg { + Some(last_arg) => self.update_last_arg_variable(Some(last_arg)), + // `_` was unset when the snapshot was taken, so put it back that way. Unsetting + // a readonly `_` fails; ignore that, as `update_last_arg_variable` does. + None => _ = self.env.unset("_"), + } + } + + /// Applies errexit semantics to a result if enabled and appropriate. + /// This should be called at "statement boundaries" where errexit should be checked. + /// + /// # Arguments + /// + /// * `result` - The execution result to potentially modify. + pub const fn apply_errexit_if_enabled(&self, result: &mut ExecutionResult) { + if self.options.exit_on_nonzero_command_exit + && !result.is_success() + && result.is_normal_flow() + { + result.next_control_flow = ExecutionControlFlow::ExitShell; + } + } + + /// Returns the keywords that are reserved by the shell, in the order bash lists them. + pub(crate) fn get_keywords(&self) -> impl IntoIterator { + keywords::keywords(self.options.sh_mode) + } + + /// Returns the launcher that starts the processes the shell runs. + pub const fn process_launcher(&self) -> &SE::ProcessLauncher { + &self.process_launcher + } + + /// Returns the spawner that starts the tasks the shell runs concurrently with + /// itself. + pub const fn task_spawner(&self) -> &SE::TaskSpawner { + &self.task_spawner + } + + /// Checks if the given string is a keyword reserved in this shell. + /// + /// # Arguments + /// + /// * `s` - The string to check. + pub fn is_keyword(&self, s: &str) -> bool { + keywords::keywords(self.options.sh_mode).any(|keyword| keyword == s) + } + + pub(crate) const fn last_exit_status_change_count(&self) -> usize { + self.last_exit_status_change_count + } + + pub(crate) const fn command_number(&self) -> usize { + self.command_number + } +} + +/// Snapshot of the state the last command left behind: `$?`, `PIPESTATUS`, and `$_`. +/// +/// Take one with [`Shell::save_command_status`] and put it back with +/// [`Shell::restore_command_status`] around anything the user didn't type -- a shell hook, +/// `PROMPT_COMMAND`, prompt expansion -- so it stays invisible to the next command. +#[derive(Clone, Debug)] +pub struct SavedCommandStatus { + exit_status: u8, + exit_status_change_count: usize, + pipeline_statuses: Vec, + last_arg: Option, +} + +#[inherent::inherent] +impl ShellState for Shell { + /// Returns whether or not this shell is a subshell. + pub fn is_subshell(&self) -> bool { + self.depth > 0 + } + + /// Returns the last "SECONDS" captured time. + pub fn last_stopwatch_time(&self) -> std::time::SystemTime { + self.last_stopwatch_time + } + + /// Returns the last "SECONDS" offset requested. + pub fn last_stopwatch_offset(&self) -> u32 { + self.last_stopwatch_offset + } + + /// Returns the shell environment containing variables. + pub fn env(&self) -> &ShellEnvironment { + &self.env + } + + /// Returns a mutable reference to the shell environment. + pub fn env_mut(&mut self) -> &mut ShellEnvironment { + &mut self.env + } + + /// Returns the shell's runtime options. + pub fn options(&self) -> &RuntimeOptions { + &self.options + } + + /// Returns a mutable reference to the shell's runtime options. + pub fn options_mut(&mut self) -> &mut RuntimeOptions { + &mut self.options + } + + /// Returns the shell's aliases. + pub fn aliases(&self) -> &HashMap { + &self.aliases + } + + /// Returns a mutable reference to the shell's aliases. + pub fn aliases_mut(&mut self) -> &mut HashMap { + &mut self.aliases + } + + /// Returns the shell's job manager. + pub fn jobs(&self) -> &jobs::JobManager { + &self.jobs + } + + /// Returns a mutable reference to the shell's job manager. + pub fn jobs_mut(&mut self) -> &mut jobs::JobManager { + &mut self.jobs + } + + /// Returns the shell's trap handler configuration. + pub fn traps(&self) -> &crate::traps::TrapHandlerConfig { + &self.traps + } + + /// Returns a mutable reference to the shell's trap handler configuration. + pub fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig { + &mut self.traps + } + + /// Returns the shell's directory stack. + pub fn directory_stack(&self) -> &[PathBuf] { + &self.directory_stack + } + + /// Returns a mutable reference to the shell's directory stack. + pub fn directory_stack_mut(&mut self) -> &mut Vec { + &mut self.directory_stack + } + + /// Returns the statuses of commands in the last pipeline. + pub fn last_pipeline_statuses(&self) -> &[u8] { + &self.last_pipeline_statuses + } + + /// Returns a mutable reference to the statuses of commands in the last pipeline. + pub fn last_pipeline_statuses_mut(&mut self) -> &mut Vec { + &mut self.last_pipeline_statuses + } + + /// Returns the shell's program location cache. + pub fn program_location_cache(&self) -> &pathcache::PathCache { + &self.program_location_cache + } + + /// Returns a mutable reference to the shell's program location cache. + pub fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache { + &mut self.program_location_cache + } + + /// Returns the shell's completion configuration. + pub fn completion_config(&self) -> &crate::completion::Config { + &self.completion.config + } + + /// Returns a mutable reference to the shell's completion configuration. + pub fn completion_config_mut(&mut self) -> &mut crate::completion::Config { + &mut self.completion.config + } + + /// Returns the shell's open files. + pub fn open_files(&self) -> &openfiles::OpenFiles { + &self.open_files + } + + /// Returns a mutable reference to the shell's open files. + pub fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles { + &mut self.open_files + } + + /// Returns the *current* name of the shell ($0). + /// Influenced by the current call stack. + pub fn current_shell_name(&self) -> Option> { + for frame in self.call_stack.iter() { + // Executed scripts shadow the shell name. + if frame.frame_type.is_run_script() { + return Some(frame.frame_type.name()); + } + } + + self.name.as_deref().map(|name| name.into()) + } + + /// Returns the current subshell depth; 0 is returned if this shell is not a subshell. + pub fn depth(&self) -> usize { + self.depth + } + + /// Returns the call stack for the shell. + pub fn call_stack(&self) -> &crate::callstack::CallStack { + &self.call_stack + } + + /// Returns the shell's history, if it exists. + pub fn history(&self) -> Option<&crate::history::History> { + self.history.as_ref() + } + + /// Returns a mutable reference to the shell's history, if it exists. + pub fn history_mut(&mut self) -> Option<&mut crate::history::History> { + self.history.as_mut() + } + + /// Returns the shell's official version string (if available). + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Returns the exit status of the last command executed in this shell. + pub fn last_exit_status(&self) -> u8 { + self.last_exit_status + } + + /// Updates the last exit status, bumping `last_exit_status_change_count`. To *restore* a + /// status rather than set one, use [`Self::restore_command_status`], which puts the + /// counter back too. + pub fn set_last_exit_status(&mut self, status: u8) { + self.last_exit_status = status; + self.last_exit_status_change_count += 1; + } + + /// Returns the key bindings helper for the shell. + pub fn key_bindings(&self) -> Option<&KeyBindingsHelper> { + self.key_bindings.as_ref() + } + + /// Sets the key bindings helper for the shell. + pub fn set_key_bindings(&mut self, key_bindings: Option) { + self.key_bindings = key_bindings; + } + + /// Returns the shell's current working directory. + pub fn working_dir(&self) -> &Path { + &self.working_dir + } + + /// Returns a mutable reference to the shell's current working directory. + /// This is only accessible within the crate. + pub(crate) fn working_dir_mut(&mut self) -> &mut PathBuf { + &mut self.working_dir + } + + /// Returns the product display name for this shell. + pub fn product_display_str(&self) -> Option<&str> { + self.product_display_str.as_deref() + } +} + +#[cfg(feature = "serde")] +fn default_error_formatter() -> EF { + EF::default() +} + +#[cfg(feature = "serde")] +fn default_process_launcher() -> PL { + PL::default() +} + +#[cfg(feature = "serde")] +fn default_task_spawner() -> TS { + TS::default() +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + + /// `$_` round-trips through a snapshot, unset included, so an embedder that saves before + /// running commands of its own gets back exactly what was there -- not an empty string. + /// (The interactive loop can't reach the unset case: every command resets `$_`.) + #[tokio::test] + async fn saved_command_status_round_trips_last_arg() -> Result<(), error::Error> { + let mut shell = Shell::builder() + .profile(ProfileLoadBehavior::Skip) + .rc(RcLoadBehavior::Skip) + .build() + .await?; + + shell.update_last_arg_variable(Some(String::from("saved"))); + let with_value = shell.save_command_status(); + + shell.env.unset("_")?; + let while_unset = shell.save_command_status(); + + shell.restore_command_status(with_value); + assert_eq!(shell.env_str("_").as_deref(), Some("saved")); + + shell.restore_command_status(while_unset); + assert_eq!(shell.env_str("_"), None); + + Ok(()) + } + + #[tokio::test] + async fn unset_env_str_contract_is_independent_of_ifs_default() -> Result<(), error::Error> { + let mut shell = Shell::builder() + .profile(ProfileLoadBehavior::Skip) + .rc(RcLoadBehavior::Skip) + .build() + .await?; + + shell.env.set_global( + "IFS", + crate::ShellVariable::new(crate::ShellValue::Unset( + crate::variables::ShellValueUnsetType::Untyped, + )), + )?; + assert_eq!(shell.env_str("IFS").as_deref(), Some("")); + assert_eq!(shell.ifs(), " \t\n"); + + shell.env.set_global("IFS", crate::ShellVariable::new(""))?; + assert_eq!(shell.env_str("IFS").as_deref(), Some("")); + assert_eq!(shell.ifs(), ""); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/builder.rs b/userland/brush/source/brush-core/src/shell/builder.rs new file mode 100644 index 0000000..6b3bcdc --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/builder.rs @@ -0,0 +1,299 @@ +//! Module defining the builder for creating shell instances. + +use std::{collections::HashMap, path::PathBuf}; + +pub use shell_builder::State as ShellBuilderState; + +use super::Shell; +use crate::{ + ProfileLoadBehavior, RcLoadBehavior, ShellFd, ShellVariable, builtins, callstack, completion, + env, error, extensions, functions, jobs, openfiles, options, pathcache, + shell::KeyBindingsHelper, traps, +}; + +impl ShellBuilder { + /// Returns a new shell instance created with the options provided. Runs any + /// configuration loading as well. + pub async fn build(self) -> Result, error::Error> { + let mut options = self.build_settings(); + + let profile = std::mem::take(&mut options.profile); + let rc = std::mem::take(&mut options.rc); + + // Construct the shell. + let mut shell = Shell::new(options)?; + + // Load profiles/configuration (and then history), unless skipped. Callers that skip + // both are expected to call `load_config` themselves before needing history. + if !profile.skip() || !rc.skip() { + shell.load_config(&profile, &rc).await?; + } + + Ok(shell) + } +} + +impl ShellBuilder { + /// Add a disabled option + pub fn disable_option(mut self, option: impl Into) -> Self { + self.disabled_options.push(option.into()); + self + } + + /// Add an enabled option + pub fn enable_option(mut self, option: impl Into) -> Self { + self.enabled_options.push(option.into()); + self + } + + /// Add many disabled options + pub fn disable_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled options + pub fn enable_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add a disabled shopt option + pub fn disable_shopt_option(mut self, option: impl Into) -> Self { + self.disabled_shopt_options.push(option.into()); + self + } + + /// Add an enabled shopt option + pub fn enable_shopt_option(mut self, option: impl Into) -> Self { + self.enabled_shopt_options.push(option.into()); + self + } + + /// Add many disabled shopt options + pub fn disable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled shopt options + pub fn enable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Registers a builtin under `name`, replacing any registered under it + /// (see [`builtins::Registry::register`]). + pub fn builtin(mut self, name: impl Into, reg: builtins::Registration) -> Self { + self.builtins.register(name, reg); + self + } + + /// Registers each builtin under its name, in turn, replacing any registered + /// under it (see [`builtins::Registry::register`]). + pub fn builtins( + mut self, + builtins: impl IntoIterator)>, + ) -> Self { + for (name, reg) in builtins { + self.builtins.register(name, reg); + } + self + } + + /// Adds a single variable to be initialized in the shell. + pub fn var(mut self, name: impl Into, variable: ShellVariable) -> Self { + self.vars.insert(name.into(), variable); + self + } +} + +/// Options for creating a new shell. +#[derive(Default, bon::Builder)] +#[builder( + builder_type( + name = ShellBuilder, + doc { + /// Builder for [Shell] + }), + finish_fn( + name = build_settings, + vis = "pub(self)", + ), + start_fn( + vis = "pub(self)" + ) +)] +pub struct CreateOptions { + /// Disabled options. + #[builder(field)] + pub disabled_options: Vec, + /// Enabled options. + #[builder(field)] + pub enabled_options: Vec, + /// Disabled shopt options. + #[builder(field)] + pub disabled_shopt_options: Vec, + /// Enabled shopt options. + #[builder(field)] + pub enabled_shopt_options: Vec, + /// The builtins the shell starts with; it keeps them as its own + /// ([`Shell::builtins`]). + #[builder(field)] + pub builtins: builtins::Registry, + /// Provides a set of variables to be initialized in the shell. If present, they + /// are assigned *after* inherited or well-known variables are set (when applicable). + #[builder(field)] + pub vars: HashMap, + /// Error behavior implementation. + #[builder(default)] + pub error_formatter: SE::ErrorFormatter, + /// Launches the processes the shell runs. + #[builder(default)] + pub process_launcher: SE::ProcessLauncher, + /// Starts the tasks the shell runs concurrently with itself. + #[builder(default)] + pub task_spawner: SE::TaskSpawner, + /// Disallow overwriting regular files via output redirection. + #[builder(default)] + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// Do not execute commands. + #[builder(default)] + pub do_not_execute_commands: bool, + /// Exit after one command. + #[builder(default)] + pub exit_after_one_command: bool, + /// Whether the shell is interactive. + #[builder(default)] + pub interactive: bool, + /// Whether the shell is a login shell. + #[builder(default)] + pub login: bool, + /// Whether to skip using a readline-like interface for input. + #[builder(default)] + pub no_editing: bool, + /// System profile loading behavior. + #[builder(default)] + pub profile: ProfileLoadBehavior, + /// Rc file loading behavior. + #[builder(default)] + pub rc: RcLoadBehavior, + /// Whether to skip inheriting environment variables from the calling process. + #[builder(default)] + pub do_not_inherit_env: bool, + /// Whether to skip initializing well-known variables. + #[builder(default)] + pub skip_well_known_vars: bool, + /// Provides a set of initial open files to be tracked by the shell. + #[builder(default)] + pub fds: HashMap, + /// Whether to launch external commands as session leaders. + #[builder(default)] + pub external_cmd_leads_session: bool, + /// Whether externally spawned commands should be killed when their spawning + /// shell is dropped. Defaults to `false` (children outlive the shell, as a + /// real shell requires); enable for embedded shells, where a surviving child + /// leaks and can hold the shell's output pipe open past teardown. + #[builder(default)] + pub kill_external_commands_on_drop: bool, + /// Initial working dir for the shell. If left unspecified, will be populated from + /// the host environment. + pub working_dir: Option, + /// Whether the shell is in POSIX compliance mode. + #[builder(default)] + pub posix: bool, + /// Whether to print commands and arguments as they are read. + #[builder(default)] + pub print_commands_and_arguments: bool, + /// Whether commands are being read from stdin. + #[builder(default)] + pub read_commands_from_stdin: bool, + /// The name of the shell. + pub shell_name: Option, + /// Base positional arguments for the shell (not including the shell name). + pub shell_args: Option>, + /// Optionally provides a display string describing the version and variant of the shell. + pub shell_product_display_str: Option, + /// Whether to run in maximal POSIX sh compatibility mode. + #[builder(default)] + pub sh_mode: bool, + /// Whether to treat expansion of unset variables as an error. + #[builder(default)] + pub treat_unset_variables_as_error: bool, + /// Whether to enable error-on-exit behavior. + #[builder(default)] + pub exit_on_nonzero_command_exit: bool, + /// Whether to disable pathname expansion. + #[builder(default)] + pub disable_pathname_expansion: bool, + /// Whether to print verbose output. + #[builder(default)] + pub verbose: bool, + /// Parser implementation to use. + #[builder(default)] + pub parser: crate::parser::ParserImpl, + /// Whether the shell is in command string mode (-c). + #[builder(default)] + pub command_string_mode: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, + /// Key bindings helper for the shell to use. + pub key_bindings: Option, + /// Brush implementation version. + pub shell_version: Option, +} + +impl Default for Shell { + fn default() -> Self { + Self { + error_formatter: SE::ErrorFormatter::default(), + process_launcher: SE::ProcessLauncher::default(), + task_spawner: SE::TaskSpawner::default(), + builtin_states: builtins::BuiltinStates::default(), + traps: traps::TrapHandlerConfig::default(), + open_files: openfiles::OpenFiles::default(), + working_dir: PathBuf::default(), + env: env::ShellEnvironment::default(), + funcs: functions::FunctionEnv::default(), + options: options::RuntimeOptions::default(), + jobs: jobs::JobManager::default(), + aliases: HashMap::default(), + last_exit_status: 0, + last_exit_status_change_count: 0, + last_pipeline_statuses: vec![0], + depth: 0, + command_number: 1, + name: None, + args: vec![], + version: None, + product_display_str: None, + call_stack: callstack::CallStack::new(), + directory_stack: vec![], + completion: completion::State::default(), + builtins: builtins::Registry::default(), + program_location_cache: pathcache::PathCache::default(), + last_stopwatch_time: std::time::SystemTime::now(), + last_stopwatch_offset: 0, + parser_impl: crate::parser::ParserImpl::default(), + key_bindings: None, + history: None, + } + } +} + +impl Shell { + /// Create an instance of [Shell] using the builder syntax + pub fn builder() -> ShellBuilder { + CreateOptions::builder() + } + + /// Create an instance of [Shell] using the builder syntax, with custom extensions. + pub fn builder_with_extensions() + -> ShellBuilder { + CreateOptions::builder() + } +} diff --git a/userland/brush/source/brush-core/src/shell/builtin_registry.rs b/userland/brush/source/brush-core/src/shell/builtin_registry.rs new file mode 100644 index 0000000..a1f5df5 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/builtin_registry.rs @@ -0,0 +1,35 @@ +//! Builtin command management for shell instances. + +use crate::{builtins, extensions}; + +impl crate::Shell { + /// Returns the builtins registered with the shell. + pub const fn builtins(&self) -> &builtins::Registry { + &self.builtins + } + + /// Returns the builtins registered with the shell, to register, enable or + /// disable them. + pub const fn builtins_mut(&mut self) -> &mut builtins::Registry { + &mut self.builtins + } + + /// Returns the builtin state of type `S` (see + /// [`crate::ExecutionContext::state_mut`]), if a builtin has used it. + pub fn builtin_state(&self) -> Option<&S> { + self.builtin_states.get() + } + + /// Sets the builtin state of type `S` (see + /// [`crate::ExecutionContext::state_mut`]), replacing any there was. A + /// builtin then starts from this value rather than from `S::default()`, so + /// the program embedding the shell can hand its builtins a value of its + /// own: a handle it keeps a clone of, or settings it read elsewhere. + pub fn set_builtin_state(&mut self, state: S) { + self.builtin_states.set(state); + } + + pub(crate) const fn builtin_states_mut(&mut self) -> &mut builtins::BuiltinStates { + &mut self.builtin_states + } +} diff --git a/userland/brush/source/brush-core/src/shell/callstack.rs b/userland/brush/source/brush-core/src/shell/callstack.rs new file mode 100644 index 0000000..1eb1a50 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/callstack.rs @@ -0,0 +1,367 @@ +//! Call stack management for the shell. + +use crate::{callstack, env, error, functions, trace_categories}; + +impl crate::Shell { + /// Returns whether or not the shell is actively executing in a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.call_stack.in_sourced_script() + } + + /// Returns whether or not the shell is actively executing in a shell function. + pub fn in_function(&self) -> bool { + self.call_stack.in_function() + } + + /// Updates the shell's internal tracking state to reflect that a new interactive + /// session is being started. + pub fn start_interactive_session(&mut self) -> Result<(), error::Error> { + self.call_stack.push_interactive_session(); + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that the current + /// interactive session is ending. + pub fn end_interactive_session(&mut self) -> Result<(), error::Error> { + if self + .call_stack + .current_frame() + .is_none_or(|frame| !frame.frame_type.is_interactive_session()) + { + return Err(error::ErrorKind::NotInInteractiveSession.into()); + } + + self.call_stack.pop(); + + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that command + /// string mode is being started. + pub fn start_command_string_mode(&mut self) { + self.call_stack.push_command_string(); + } + + /// Updates the shell's internal tracking state to reflect that command + /// string mode is ending. + pub fn end_command_string_mode(&mut self) -> Result<(), error::Error> { + if self + .call_stack + .current_frame() + .is_none_or(|frame| !frame.frame_type.is_command_string()) + { + return Err(error::ErrorKind::NotExecutingCommandString.into()); + } + + self.call_stack.pop(); + + Ok(()) + } + + /// Enters a trap handler for `signal` on the shell, until the returned guard is dropped. + /// Leaving it restores `$?`, so the handler doesn't change the status that triggered it. + pub(crate) fn enter_trap_handler( + &mut self, + signal: crate::traps::TrapSignal, + handler: Option<&crate::traps::TrapHandler>, + ) -> ShellGuard<'_, SE> { + let last_exit_status = self.last_exit_status; + self.call_stack.push_trap_handler(signal, handler); + let frames = self.call_stack.depth(); + ShellGuard::new( + self, + Leave::TrapHandler { + frames, + last_exit_status, + }, + ) + } + + /// Enters a script on the shell, until the returned guard is dropped. + pub(crate) fn enter_script( + &mut self, + call_type: callstack::ScriptCallType, + source_info: &crate::SourceInfo, + args: impl IntoIterator, + ) -> ShellGuard<'_, SE> { + self.call_stack.push_script(call_type, source_info, args); + let frames = self.call_stack.depth(); + ShellGuard::new(self, Leave::Script { frames }) + } + + /// Enters a call to the shell function `name` on the shell, with its own scope for local + /// variables, until the returned guard is dropped. + /// + /// # Arguments + /// + /// * `name` - The name of the function being entered. + /// * `function` - The function being entered. + /// * `args` - The arguments being passed to the function. + pub(crate) fn enter_function( + &mut self, + name: &str, + function: &functions::Registration, + args: impl IntoIterator, + ) -> Result, error::Error> { + if let Some(max_call_depth) = self.options.max_function_call_depth + && self.call_stack.function_call_depth() >= max_call_depth + { + return Err(error::ErrorKind::MaxFunctionCallDepthExceeded.into()); + } + + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.call_stack.function_call_depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Entering func [depth={depth}]: {prefix}{name}"); + } + + self.call_stack.push_function(name, function, args); + self.env.push_scope(env::EnvironmentScope::Local); + let leave = Leave::Function { + frames: self.call_stack.depth(), + scopes: self.env.scope_depth(), + }; + + Ok(ShellGuard::new(self, leave)) + } + + /// Enters a new variable scope on the shell, until the returned guard is dropped. + pub(crate) fn enter_scope(&mut self, scope: env::EnvironmentScope) -> ShellGuard<'_, SE> { + self.env.push_scope(scope); + let scopes = self.env.scope_depth(); + ShellGuard::new(self, Leave::Scope { scopes, scope }) + } + + /// Blocks the delivery of traps until the returned guard is dropped. Blocks nest: traps + /// are delivered again only once every block is dropped. + pub(crate) const fn block_trap_delivery(&mut self) -> ShellGuard<'_, SE> { + self.call_stack.acquire_trap_delivery_block(); + ShellGuard::new(self, Leave::TrapBlock) + } + + /// Returns the *current* positional arguments for the shell ($1 and beyond). + /// Influenced by the current call stack. + pub fn current_shell_args(&self) -> &[String] { + for frame in self.call_stack.iter() { + match frame.frame_type { + // Function calls always shadow positional parameters. + crate::callstack::FrameType::Function(..) => return &frame.args, + // Executed scripts always shadow positional parameters. + _ if frame.frame_type.is_run_script() => return &frame.args, + // Sourced scripts shadow positional parameters if they have arguments. + _ if frame.frame_type.is_sourced_script() && !frame.args.is_empty() => { + return &frame.args; + } + _ => (), + } + } + + self.args.as_slice() + } + + /// Returns a mutable reference to *current* positional parameters for the shell + /// ($1 and beyond). + pub fn current_shell_args_mut(&mut self) -> &mut Vec { + for frame in self.call_stack.iter_mut() { + match frame.frame_type { + // Function calls always shadow positional parameters. + crate::callstack::FrameType::Function(..) => return &mut frame.args, + // Executed scripts always shadow positional parameters. + _ if frame.frame_type.is_run_script() => return &mut frame.args, + // Sourced scripts shadow positional parameters if they have arguments. + _ if frame.frame_type.is_sourced_script() && !frame.args.is_empty() => { + return &mut frame.args; + } + _ => (), + } + } + + &mut self.args + } +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} + +/// Guards something entered on a shell -- a function call, a script, a trap handler, a +/// variable scope, or a block on trap delivery -- and leaves it when dropped: when +/// whatever runs in it returns, fails, or is cancelled (its future dropped, as when the +/// user hits Ctrl-C during completion). +/// +/// It dereferences to the shell, for running whatever's entered. +pub(crate) struct ShellGuard<'a, SE: crate::extensions::ShellExtensions> { + shell: &'a mut crate::Shell, + leave: Leave, +} + +/// What a [`ShellGuard`] leaves when it's dropped, with how deep the call stack (`frames`) +/// and variable scopes (`scopes`) were once it was entered. +#[derive(Clone, Copy, Debug)] +enum Leave { + Function { + frames: usize, + scopes: usize, + }, + Script { + frames: usize, + }, + TrapHandler { + frames: usize, + last_exit_status: u8, + }, + Scope { + scopes: usize, + scope: env::EnvironmentScope, + }, + TrapBlock, +} + +impl<'a, SE: crate::extensions::ShellExtensions> ShellGuard<'a, SE> { + const fn new(shell: &'a mut crate::Shell, leave: Leave) -> Self { + Self { shell, leave } + } +} + +impl std::ops::Deref for ShellGuard<'_, SE> { + type Target = crate::Shell; + + fn deref(&self) -> &Self::Target { + self.shell + } +} + +impl std::ops::DerefMut for ShellGuard<'_, SE> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.shell + } +} + +impl ShellGuard<'_, SE> { + /// Returns whether this guard's frame is on top of the call stack, `depth` frames deep. + fn frame_on_top(&self, depth: usize, is_expected: fn(&callstack::FrameType) -> bool) -> bool { + let stack = &self.shell.call_stack; + stack.depth() == depth + && stack + .current_frame() + .is_some_and(|frame| is_expected(&frame.frame_type)) + } + + /// Pops this guard's frame if it's on top, `depth` frames deep; returns whether it was. + fn pop_frame(&mut self, depth: usize, is_expected: fn(&callstack::FrameType) -> bool) -> bool { + let on_top = self.frame_on_top(depth, is_expected); + if on_top { + self.shell.call_stack.pop(); + } + on_top + } + + /// Pops this guard's variable scope if it's on top, `depth` scopes deep; returns whether + /// it was. + fn pop_scope(&mut self, depth: usize, scope_type: env::EnvironmentScope) -> bool { + self.shell.env.scope_depth() == depth && self.shell.env.pop_scope(scope_type).is_ok() + } + + /// Reports that what ran in this guard didn't leave everything it entered. + fn report_imbalance(&self) { + tracing::error!( + "shell state out of balance when leaving {:?}: call stack {} deep, {} variable scopes", + self.leave, + self.shell.call_stack.depth(), + self.shell.env.scope_depth() + ); + } +} + +impl Drop for ShellGuard<'_, SE> { + fn drop(&mut self) { + // Whatever was entered on top of this has been left already, since its guard + // borrowed this one; so unless something left the shell out of balance, what this + // entered is on top. If it isn't, report that and pop nothing, rather than risk + // popping something else. + let left = match self.leave { + Leave::Function { frames, scopes } => { + // Pop the frame and its scope together, or neither. + let left = self.frame_on_top(frames, callstack::FrameType::is_function) + && self.pop_scope(scopes, env::EnvironmentScope::Local); + if left + && let Some(frame) = self.shell.call_stack.pop() + && let callstack::FrameType::Function(call) = frame.frame_type + && tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) + { + let depth = self.shell.call_stack.function_call_depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Exiting func [depth={depth}]: {prefix}{}", call.function_name); + } + left + } + Leave::Script { frames } => self.pop_frame(frames, callstack::FrameType::is_script), + Leave::TrapHandler { + frames, + last_exit_status, + } => { + self.shell.last_exit_status = last_exit_status; + self.pop_frame(frames, callstack::FrameType::is_trap_handler) + } + Leave::Scope { scopes, scope } => self.pop_scope(scopes, scope), + Leave::TrapBlock => { + let blocked = self.shell.call_stack.is_trap_delivery_suppressed(); + self.shell.call_stack.release_trap_delivery_block(); + blocked + } + }; + + if !left { + self.report_imbalance(); + } + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + + /// Guards pop nothing -- not even part of what they entered -- if what ran in them left + /// the call stack or variable scopes out of balance (as a buggy builtin could). + #[tokio::test] + async fn guards_pop_nothing_when_out_of_balance() -> Result<(), error::Error> { + let mut shell = crate::Shell::builder() + .profile(crate::ProfileLoadBehavior::Skip) + .rc(crate::RcLoadBehavior::Skip) + .build() + .await?; + let params = shell.default_exec_params(); + let result = shell + .run_string("f() { :; }", &crate::SourceInfo::default(), ¶ms) + .await?; + assert!(result.is_success()); + let f = shell + .funcs() + .get("f") + .cloned() + .ok_or_else(|| error::ErrorKind::FunctionNotFound("f".into()))?; + let frames = shell.call_stack.depth(); + let scopes = shell.env.scope_depth(); + + // A function whose scope has another left on top of it. + let mut function = shell.enter_function("f", &f, std::iter::empty())?; + function.env_mut().push_scope(env::EnvironmentScope::Local); + drop(function); + assert_eq!(shell.call_stack.depth(), frames + 1); + assert_eq!(shell.env.scope_depth(), scopes + 2); + + // A script whose frame was replaced by another. + let mut script = shell.enter_script( + callstack::ScriptCallType::Source, + &crate::SourceInfo::default(), + std::iter::empty(), + ); + script.call_stack.pop(); + script.start_command_string_mode(); + drop(script); + assert_eq!(shell.call_stack.depth(), frames + 2); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/completion.rs b/userland/brush/source/brush-core/src/shell/completion.rs new file mode 100644 index 0000000..76c4046 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/completion.rs @@ -0,0 +1,48 @@ +//! Command completion support for shell instances. + +use crate::{completion, error, extensions}; + +impl crate::Shell { + /// Completes the word at the cursor in a line being edited, with programmable + /// completion (see the [`completion`] module). + /// + /// # Arguments + /// + /// * `input` - The line being completed. + /// * `cursor` - The cursor's byte offset in the line. + /// + /// # Errors + /// + /// Returns an error if `cursor` isn't a char boundary in `input`. + pub async fn complete( + &mut self, + input: &str, + cursor: usize, + ) -> Result { + completion::complete(self, input, cursor).await + } + + /// Returns the programmable completion in progress, if any (e.g. while its completion + /// function runs), whose options the `compopt` builtin shows. + pub const fn in_progress_completion(&self) -> Option<&completion::InProgressCompletion> { + self.completion.in_progress.as_ref() + } + + /// Returns the options of the programmable completion in progress, if any (e.g. + /// while its completion function runs), which the `compopt` builtin changes. + pub fn in_progress_completion_options_mut( + &mut self, + ) -> Option<&mut completion::GenerationOptions> { + self.completion + .in_progress + .as_mut() + .map(|in_progress| &mut in_progress.options) + } + + /// Returns a mutable reference to the programmable completion in progress, if any. + pub(crate) const fn in_progress_completion_mut( + &mut self, + ) -> &mut Option { + &mut self.completion.in_progress + } +} diff --git a/userland/brush/source/brush-core/src/shell/env.rs b/userland/brush/source/brush-core/src/shell/env.rs new file mode 100644 index 0000000..b4e0962 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/env.rs @@ -0,0 +1,36 @@ +//! Environment support for shell. + +use std::borrow::Cow; + +use crate::{ShellVariable, error}; + +impl crate::Shell { + /// Tries to retrieve a variable from the shell's environment, converting it into its + /// string form. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_str(&self, name: &str) -> Option> { + self.env.get_str(name, self) + } + + /// Tries to retrieve a variable from the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_var(&self, name: &str) -> Option<&ShellVariable> { + self.env.get(name).map(|(_, var)| var) + } + + /// Tries to set a global variable in the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to add. + /// * `var` - The variable contents to add. + pub fn set_env_global(&mut self, name: &str, var: ShellVariable) -> Result<(), error::Error> { + self.env.set_global(name, var) + } +} diff --git a/userland/brush/source/brush-core/src/shell/execution.rs b/userland/brush/source/brush-core/src/shell/execution.rs new file mode 100644 index 0000000..3fe0154 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/execution.rs @@ -0,0 +1,319 @@ +//! Execution support for shell. + +use std::path::Path; + +use crate::{ + ExecutionControlFlow, ExecutionParameters, ExecutionResult, ProcessGroupPolicy, SourceInfo, + arithmetic::Evaluatable as _, callstack, error, interp::Execute as _, openfiles, +}; + +impl crate::Shell { + /// Returns the default execution parameters for this shell. + pub fn default_exec_params(&self) -> ExecutionParameters { + let mut params = ExecutionParameters::default(); + + params.process_group_policy = if self.options.enable_job_control { + ProcessGroupPolicy::NewProcessGroup + } else { + ProcessGroupPolicy::SameProcessGroup + }; + + params + } + + pub(super) async fn source_if_exists( + &mut self, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + let path = path.as_ref(); + if path.exists() { + // TODO(startup): an `exit` in a startup file should end the shell, but + // `load_config` has no way to tell its caller to stop; until it does, the + // file's result is dropped. Tracked by the compat case "exit in an rc file + // ends the shell". + let _ = self + .source_script(path, std::iter::empty::(), params) + .await?; + Ok(true) + } else { + tracing::debug!("skipping non-existent file: {}", path.display()); + Ok(false) + } + } + + /// Source the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + pub async fn source_script, P: AsRef, I: Iterator>( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + ) -> Result { + self.parse_and_execute_script_file( + path.as_ref(), + args, + params, + callstack::ScriptCallType::Source, + ) + .await + } + + /// Source an already parsed program, as [`Self::source_script`] sources a file: in a + /// script's call frame, with `args` as its positional parameters. A library sourced by + /// many scripts then needs parsing only once. + /// + /// Parsing is the caller's job. A parse error never reaches this function, so it is not + /// reported as fatal the way [`Self::source_script`] reports one. The program also keeps + /// the [`Self::parser_options`] it was parsed with (e.g., extended globbing), so a cache + /// of parsed programs should be keyed on them. + /// + /// # Arguments + /// + /// * `program` - The parsed program to run. + /// * `source_info` - Where the program came from, for its call frame and errors. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + pub async fn source_program, I: Iterator>( + &mut self, + program: &brush_parser::ast::Program, + source_info: &crate::SourceInfo, + args: I, + params: &ExecutionParameters, + ) -> Result { + self.run_in_script_frame( + callstack::ScriptCallType::Source, + source_info, + args, + Ok(program), + params, + ) + .await + } + + /// Parse and execute the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + /// * `call_type` - The type of script call being made. + async fn parse_and_execute_script_file< + S: Into, + P: AsRef, + I: Iterator, + >( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + call_type: callstack::ScriptCallType, + ) -> Result { + let path = path.as_ref(); + tracing::debug!("sourcing: {}", path.display()); + + let mut options = std::fs::File::options(); + options.read(true); + + let opened_file: openfiles::OpenFile = self + .open_file(&options, path, params) + .map_err(|e| error::ErrorKind::FailedSourcingFile(path.to_owned(), e))?; + + if opened_file.is_dir() { + return Err(error::ErrorKind::FailedSourcingFile( + path.to_owned(), + std::io::Error::from(std::io::ErrorKind::IsADirectory), + ) + .into()); + } + + let source_info = crate::SourceInfo::from(path.to_owned()); + let parse_result = self.parse(opened_file); + + self.run_in_script_frame(call_type, &source_info, args, parse_result, params) + .await + } + + /// Runs a script's program in a new call frame for the script, as every way of running + /// a script file or a parsed program as a script does: enters the frame, runs the + /// program (or reports why it failed to parse), reports any error, and ends the + /// script's `return` at the script's boundary. + /// + /// # Arguments + /// + /// * `call_type` - The type of script call being made. + /// * `source_info` - Where the script came from, for its call frame and errors. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `parse_result` - The script's parsed program, or the error parsing it failed with. + /// * `params` - Execution parameters. + async fn run_in_script_frame< + S: Into, + I: Iterator, + P: std::borrow::Borrow, + >( + &mut self, + call_type: callstack::ScriptCallType, + source_info: &crate::SourceInfo, + args: I, + parse_result: Result, + params: &ExecutionParameters, + ) -> Result { + let mut result = self + .enter_script(call_type, source_info, args.map(Into::into)) + .run_parsed_result(parse_result, source_info, params) + .await?; + + // A `return` that ended the script has been used up at the script's boundary. All + // other control flow is preserved. + if matches!( + result.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript + ) { + result.next_control_flow = ExecutionControlFlow::Normal; + } + + Ok(result) + } + + /// Executes the given string as a shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `command` - The command to execute. + /// * `source_info` - Information about the source of the command text. + /// * `params` - Execution parameters. + pub async fn run_string>( + &mut self, + command: S, + source_info: &crate::SourceInfo, + params: &ExecutionParameters, + ) -> Result { + let parse_result = self.parse_string(command); + self.run_parsed_result(parse_result, source_info, params) + .await + } + + /// Executes the given command, provided to a shell executable on the command + /// line (i.e., via `-c`). + /// + /// It is expected that the shell will not be used for any further execution + /// after this command; this function will perform any necessary shell exit + /// handling. + /// + /// # Arguments + /// + /// * `command` - The command to execute. + pub async fn run_dash_c_command>( + &mut self, + command: S, + ) -> Result { + self.start_command_string_mode(); + + // Execute the command string. + let params = self.default_exec_params(); + let source_info = SourceInfo::from("-c"); + let mut result = self.run_string(command, &source_info, ¶ms).await?; + + self.end_command_string_mode()?; + + // Give the shell a chance to run on-exit tasks. An `exit` there sets the shell's + // status; a failure there doesn't change the command's outcome. + if let Ok(Some(status)) = self.run_exit_trap().await { + result.exit_code = status.into(); + } + + Ok(result) + } + + /// Executes the given script file, returning the resulting exit status. + /// + /// It is expected that the shell will not be used for any further execution + /// after this command; this function will perform any necessary shell exit + /// handling. + /// + /// # Arguments + /// + /// * `script_path` - The path to the script file to execute. + /// * `args` - The arguments to pass to the script as positional parameters. + pub async fn run_script, P: AsRef, I: Iterator>( + &mut self, + script_path: P, + args: I, + ) -> Result { + let params = self.default_exec_params(); + let mut result = self + .parse_and_execute_script_file( + script_path.as_ref(), + args, + ¶ms, + callstack::ScriptCallType::Run, + ) + .await?; + + // Give the shell a chance to run on-exit tasks. An `exit` there sets the shell's + // status; a failure there doesn't change the script's outcome. + if let Ok(Some(status)) = self.run_exit_trap().await { + result.exit_code = status.into(); + } + + Ok(result) + } + + pub(crate) async fn run_parsed_result>( + &mut self, + parse_result: Result, + source_info: &crate::SourceInfo, + params: &ExecutionParameters, + ) -> Result { + // If parsing succeeded, run the program. If there's a parse error, it's fatal (per spec). + let result = match parse_result { + Ok(prog) => prog.borrow().execute(self, params).await, + Err(parse_err) => Err(error::Error::from(error::ErrorKind::ParseError( + parse_err, + source_info.clone(), + )) + .into_fatal()), + }; + + // Report any errors. + match result { + Ok(result) => Ok(result), + Err(err) => { + let _ = self.display_error(&mut params.stderr(self), &err); + + let result = err.into_result(self); + self.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } + } + } + + /// Executes the given parsed shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `program` - The program to execute. + /// * `params` - Execution parameters. + pub async fn run_program( + &mut self, + program: brush_parser::ast::Program, + params: &ExecutionParameters, + ) -> Result { + program.execute(self, params).await + } + + /// Evaluate the given arithmetic expression, returning the result. + pub fn eval_arithmetic( + &mut self, + expr: &brush_parser::ast::ArithmeticExpr, + ) -> Result { + Ok(expr.eval(self)?) + } +} diff --git a/userland/brush/source/brush-core/src/shell/expansion.rs b/userland/brush/source/brush-core/src/shell/expansion.rs new file mode 100644 index 0000000..e705fe7 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/expansion.rs @@ -0,0 +1,52 @@ +//! Expansion support for shell instances. + +use std::borrow::Cow; + +use crate::{error, expansion, extensions, interp::ExecutionParameters}; + +impl crate::Shell { + /// Returns the current value of the IFS variable, or the default value if it is not set. + pub fn ifs(&self) -> Cow<'_, str> { + self.env_var("IFS") + .filter(|var| var.value().is_set()) + .map_or_else(|| " \t\n".into(), |var| var.value().to_cow_str(self)) + } + + /// Returns the separator that joins the fields of `$*` and `${arr[*]}`: the first + /// character of IFS, or nothing at all when IFS is empty (an unset IFS is a space). + pub(crate) fn ifs_joiner(&self) -> String { + self.ifs() + .chars() + .next() + .map_or_else(String::new, String::from) + } + + /// Applies basic shell expansion to the provided string. + /// + /// # Arguments + /// + /// * `s` - The string to expand. + pub async fn basic_expand_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result { + let result = expansion::basic_expand_word(self, params, s.as_ref()).await?; + Ok(result) + } + + /// Applies full shell expansion and field splitting to the provided string; returns + /// a sequence of fields. + /// + /// # Arguments + /// + /// * `s` - The string to expand and split. + pub async fn full_expand_and_split_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result, error::Error> { + let result = expansion::full_expand_and_split_word(self, params, s.as_ref()).await?; + Ok(result) + } +} diff --git a/userland/brush/source/brush-core/src/shell/fs.rs b/userland/brush/source/brush-core/src/shell/fs.rs new file mode 100644 index 0000000..2ffda86 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/fs.rs @@ -0,0 +1,297 @@ +//! Filesystem interaction in the shell. + +use std::path::{Path, PathBuf}; + +use normalize_path::NormalizePath as _; + +use crate::{ + ExecutionParameters, ShellFd, + env::{EnvironmentLookup, EnvironmentScope}, + error, openfiles, pathsearch, + sys::users, + variables, +}; + +impl crate::Shell { + /// Sets the shell's current working directory to the given path. + /// + /// # Arguments + /// + /// * `target_dir` - The path to set as the working directory. + pub fn set_working_dir(&mut self, target_dir: impl AsRef) -> Result<(), error::Error> { + let abs_path = self.absolute_path(target_dir.as_ref()); + + match std::fs::metadata(&abs_path) { + Ok(m) => { + if !m.is_dir() { + return Err(error::ErrorKind::NotADirectory(abs_path).into()); + } + } + Err(e) => { + return Err(e.into()); + } + } + + // Normalize the path (but don't canonicalize it). + let cleaned_path = abs_path.normalize(); + + let pwd = cleaned_path.to_string_lossy().to_string(); + + self.env.update_or_add( + "PWD", + variables::ShellValueLiteral::Scalar(pwd), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + let oldpwd = std::mem::replace(self.working_dir_mut(), cleaned_path); + + self.env.update_or_add( + "OLDPWD", + variables::ShellValueLiteral::Scalar(oldpwd.to_string_lossy().to_string()), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + Ok(()) + } + + /// Tilde-shortens the given string, replacing the user's home directory with a tilde. + /// + /// # Arguments + /// + /// * `s` - The string to shorten. + pub fn tilde_shorten(&self, s: String) -> String { + if let Some(home_dir) = self.home_dir() + && let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref()) + { + return format!("~{stripped}"); + } + s + } + + /// Returns the shell's current home directory, if available. + pub(crate) fn home_dir(&self) -> Option { + if let Some(home) = self.env.get_str("HOME", self) { + Some(PathBuf::from(home.to_string())) + } else { + // HOME isn't set, so let's sort it out ourselves. + users::get_current_user_home_dir() + } + } + + /// Finds executables with the given name in the shell's current PATH, yielding each match + /// in search order. + /// + /// # Arguments + /// + /// * `filename` - The name of the executable to look for. + pub fn find_executables_in_path<'a>( + &'a self, + filename: &'a str, + ) -> impl Iterator + 'a { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = crate::sys::fs::split_paths(path_var.as_ref()); + + pathsearch::search_for_executable(paths, filename) + } + + /// Finds executables in the shell's current default PATH, with filenames matching the + /// given prefix. + /// + /// # Arguments + /// + /// * `filename_prefix` - The prefix to match against executable filenames. + pub fn find_executables_in_path_with_prefix( + &self, + filename_prefix: &str, + case_insensitive: bool, + ) -> impl Iterator { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = crate::sys::fs::split_paths(path_var.as_ref()); + + pathsearch::search_for_executable_with_prefix(paths, filename_prefix, case_insensitive) + } + + /// Determines whether the given filename is the name of an executable in one of the + /// directories in the shell's current PATH. If found, returns the path. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path>( + &self, + candidate_name: S, + ) -> Option { + self.find_executables_in_path(candidate_name.as_ref()) + .next() + } + + /// Uses the shell's hash-based path cache to check whether the given filename is the name + /// of an executable in one of the directories in the shell's current PATH. If found, + /// ensures the path is in the cache and returns it. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path_using_cache>( + &mut self, + candidate_name: S, + ) -> Option + where + String: From, + { + if let Some(cached_path) = self.program_location_cache.get(&candidate_name) { + Some(cached_path) + } else if let Some(found_path) = self.find_first_executable_in_path(&candidate_name) { + self.program_location_cache + .set(candidate_name, found_path.clone()); + Some(found_path) + } else { + None + } + } + + /// Resolves a command name by searching the shell's current PATH. + /// + /// Unlike [`Self::find_first_executable_in_path`], a non-executable entry in the PATH + /// resolves as the command; the shell reports it as the command and then fails to run it. + /// See [`pathsearch::resolve_command`]. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the command to resolve. + pub fn resolve_command_in_path>(&self, candidate_name: S) -> Option { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = crate::sys::fs::split_paths(path_var.as_ref()); + pathsearch::resolve_command(paths, candidate_name.as_ref()) + } + + /// Like [`Self::resolve_command_in_path`], but consults the shell's hash-based path cache + /// first and caches whatever a search turns up. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the command to resolve. + pub fn resolve_command_in_path_using_cache>( + &mut self, + candidate_name: S, + ) -> Option + where + String: From, + { + if let Some(cached_path) = self.program_location_cache.get(&candidate_name) { + return Some(cached_path); + } + + let found_path = self.resolve_command_in_path(candidate_name.as_ref())?; + self.program_location_cache + .set(candidate_name, found_path.clone()); + + Some(found_path) + } + + /// Resolves the name of an external command to the program to run, as running + /// the command would: a name with a path separator is a path, relative to the + /// working directory; any other is looked up in `PATH`, as + /// [`Self::resolve_command_in_path_using_cache`] does. + /// + /// # Arguments + /// + /// * `name` - The name of the command. + pub fn resolve_external_command(&mut self, name: &str) -> Option { + if crate::sys::fs::contains_path_separator(name) { + Some(crate::sys::fs::resolve_executable_path( + PathBuf::from(name), + self.working_dir(), + )) + } else { + self.resolve_command_in_path_using_cache(name) + } + } + + /// Gets the absolute form of the given path. + /// + /// # Arguments + /// + /// * `path` - The path to get the absolute form of. + pub fn absolute_path(&self, path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_absolute() { + path.to_owned() + } else { + self.working_dir().join(path) + } + } + + /// Opens the given file, using the context of this shell and the provided execution parameters. + /// + /// # Arguments + /// + /// * `options` - The options to use opening the file. + /// * `path` - The path to the file to open; may be relative to the shell's working directory. + /// * `params` - Execution parameters. + pub(crate) fn open_file( + &self, + options: &std::fs::OpenOptions, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + // Give platform-specific code a chance to handle special files + // (e.g. /dev/null on Windows, which needs to open NUL instead). + // This is checked before absolute_path so that paths like /dev/null + // are intercepted on platforms where they aren't valid native paths. + if let Some(result) = crate::sys::fs::try_open_special_file(path.as_ref()) { + return result.map(openfiles::OpenFile::from); + } + + let path_to_open = self.absolute_path(path.as_ref()); + + // See if this is a reference to a file descriptor. These paths should + // reflect the shell's current execution fds, which can differ from the + // host process fds after redirections like here-docs. + if let Some(fd_num) = shell_fd_path_to_fd(&path_to_open) + && let Some(open_file) = params.try_fd(self, fd_num) + { + return Ok(open_file); + } + + Ok(options.open(path_to_open)?.into()) + } + + /// Replaces the shell's currently configured open files with the given set. + /// Typically only used by exec-like builtins. + /// + /// # Arguments + /// + /// * `open_files` - The new set of open files to use. + pub fn replace_open_files( + &mut self, + open_fds: impl Iterator, + ) { + self.open_files = openfiles::OpenFiles::from(open_fds); + } + + pub(crate) const fn persistent_open_files(&self) -> &openfiles::OpenFiles { + &self.open_files + } +} + +fn shell_fd_path_to_fd(path: &Path) -> Option { + match path.to_str()? { + "/dev/stdin" => return Some(openfiles::OpenFiles::STDIN_FD), + "/dev/stdout" => return Some(openfiles::OpenFiles::STDOUT_FD), + "/dev/stderr" => return Some(openfiles::OpenFiles::STDERR_FD), + _ => {} + } + + if let Some(parent) = path.parent() + && parent == Path::new("/dev/fd") + && let Some(filename) = path.file_name() + { + filename.to_string_lossy().parse::().ok() + } else { + None + } +} diff --git a/userland/brush/source/brush-core/src/shell/funcs.rs b/userland/brush/source/brush-core/src/shell/funcs.rs new file mode 100644 index 0000000..7c2a094 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/funcs.rs @@ -0,0 +1,126 @@ +//! Function support for shells. + +use crate::{ + ExecutionParameters, ExecutionResult, commands, error, extensions, functions, + results::ExecutionWaitResult, +}; + +impl crate::Shell { + /// Returns the function definition environment for this shell. + pub const fn funcs(&self) -> &functions::FunctionEnv { + &self.funcs + } + + /// Returns a mutable reference to the function definition environment for this shell. + pub const fn funcs_mut(&mut self) -> &mut functions::FunctionEnv { + &mut self.funcs + } + + /// Tries to undefine a function in the shell's environment. Returns whether or + /// not a definition was removed. + /// + /// # Arguments + /// + /// * `name` - The name of the function to undefine. + pub fn undefine_func(&mut self, name: &str) -> bool { + self.funcs.remove(name).is_some() + } + + /// Defines a function in the shell's environment. If a function already exists + /// with the given name, it is replaced with the new definition. + /// + /// # Arguments + /// + /// * `name` - The name of the function to define. + /// * `definition` - The function's definition. + /// * `source_info` - Source information for the function definition. + pub fn define_func( + &mut self, + name: impl Into, + definition: brush_parser::ast::FunctionDefinition, + source_info: &crate::SourceInfo, + ) { + let reg = functions::Registration::new(definition, source_info); + self.funcs.update(name.into(), reg); + } + + /// Tries to return a mutable reference to the registration for a named function. + /// Returns `None` if no such function was found. + /// + /// # Arguments + /// + /// * `name` - The name of the function to lookup + pub fn func_mut(&mut self, name: &str) -> Option<&mut functions::Registration> { + self.funcs.get_mut(name) + } + + /// Tries to define a function in the shell's environment using the given + /// string as its body. + /// + /// # Arguments + /// + /// * `name` - The name of the function + /// * `body_text` - The body of the function, expected to start with "()". + pub fn define_func_from_str( + &mut self, + name: impl Into, + body_text: &str, + ) -> Result<(), error::Error> { + let name = name.into(); + + let mut parser = + super::parsing::create_parser(body_text.as_bytes(), &self.parser_options()); + let func_body = parser.parse_function_parens_and_body().map_err(|e| { + error::Error::from(error::ErrorKind::FunctionParseError(name.clone(), e)) + })?; + + let def = brush_parser::ast::FunctionDefinition { + fname: name.clone().into(), + body: func_body, + }; + + self.define_func(name, def, &crate::SourceInfo::default()); + + Ok(()) + } + + /// Invokes a function defined in this shell, returning its execution result. + /// + /// # Arguments + /// + /// * `name` - The name of the function to invoke. + /// * `args` - The arguments to pass to the function. + /// * `params` - Execution parameters to use for the invocation. + pub async fn invoke_function, I: IntoIterator, A: AsRef>( + &mut self, + name: N, + args: I, + params: ExecutionParameters, + ) -> Result { + let name = name.as_ref(); + let command_name = String::from(name); + + let func_registration = self + .funcs + .get(name) + .ok_or_else(|| error::ErrorKind::FunctionNotFound(name.to_owned()))? + .to_owned(); + + let context = commands::ExecutionContext::new(self, command_name, params); + + let command_args = args + .into_iter() + .map(|s| commands::CommandArg::String(String::from(s.as_ref()))) + .collect::>(); + + let result = + commands::invoke_shell_function(func_registration, context, &command_args).await?; + + match result.wait().await? { + ExecutionWaitResult::Completed(result) => Ok(result), + ExecutionWaitResult::Stopped(..) => { + error::unimp("stopped child from function invocation") + } + } + } +} diff --git a/userland/brush/source/brush-core/src/shell/history.rs b/userland/brush/source/brush-core/src/shell/history.rs new file mode 100644 index 0000000..eb61e1b --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/history.rs @@ -0,0 +1,126 @@ +//! History management for shells. + +use std::{collections::BTreeSet, path::PathBuf}; + +use crate::{error, history::HistoryControl, openfiles}; + +impl crate::Shell { + pub(super) fn load_history(&self) -> Result, error::Error> { + const MAX_FILE_SIZE_FOR_HISTORY_IMPORT: u64 = 1024 * 1024 * 1024; // 1 GiB + + let Some(history_path) = self.history_file_path() else { + return Ok(None); + }; + + let mut options = std::fs::File::options(); + options.read(true); + + let mut history_file = + self.open_file(&options, history_path, &self.default_exec_params())?; + + // Check on the file's size. + if let openfiles::OpenFile::File(file) = &mut history_file { + let file_metadata = file.metadata()?; + let file_size = file_metadata.len(); + + // If the file is empty, no reason to try reading it. Note that this will also + // end up excluding non-regular files that report a 0 file size but appear + // to have contents when read. + if file_size == 0 { + return Ok(None); + } + + // Bail if the file is unrealistically large. For now we just refuse to import it. + if file_size > MAX_FILE_SIZE_FOR_HISTORY_IMPORT { + return Err(error::ErrorKind::HistoryFileTooLargeToImport.into()); + } + } + + let history = crate::history::History::import(history_file)?; + Ok(Some(history)) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_file_path(&self) -> Option { + self.env_str("HISTFILE") + .filter(|s| !s.is_empty()) + .map(|s| PathBuf::from(s.into_owned())) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_time_format(&self) -> Option { + self.env_str("HISTTIMEFORMAT").map(|s| s.into_owned()) + } + + /// Saves history back to any backing storage. + pub fn save_history(&mut self) -> Result<(), error::Error> { + if let Some(history_file_path) = self.history_file_path() + && let Some(history) = &mut self.history + { + // See if there's *any* time format configured. That triggers writing out + // timestamps. + let write_timestamps = self.env.is_set("HISTTIMEFORMAT"); + + // TODO(history): Observe options.append_to_history_file + history.flush( + history_file_path, + true, /* append? */ + true, /* unsaved items only? */ + write_timestamps, + )?; + } + + Ok(()) + } + + /// Adds a command to history. Like bash, an interactive shell leaves it out or makes room + /// for it as the colon-separated values of `HISTCONTROL` say: + /// + /// - `ignorespace`: leave it out if it starts with a space. + /// - `ignoredups`: leave it out if it's the same as the last entry. + /// - `ignoreboth`: both of those. + /// - `erasedups`: first remove any earlier entries the same as it. + pub fn add_to_history(&mut self, command: &str) -> Result<(), error::Error> { + let controls = if self.options.interactive { + self.env_str("HISTCONTROL") + .map(|value| HistoryControl::parse_list(&value)) + .unwrap_or_default() + } else { + BTreeSet::new() + }; + + let Some(history) = &mut self.history else { + return Ok(()); + }; + + if controls.contains(&HistoryControl::IgnoreSpace) && command.starts_with(' ') { + return Ok(()); + } + + // Like bash, save the command as typed, surrounding whitespace and all, but leave out + // blank ones. + if command.trim().is_empty() { + return Ok(()); + } + + if controls.contains(&HistoryControl::IgnoreDups) + && history + .last() + .is_some_and(|last| last.command_line == command) + { + return Ok(()); + } + if controls.contains(&HistoryControl::EraseDups) { + history.retain(|item| item.command_line != command); + } + + history.add(crate::history::Item { + id: 0, + command_line: command.to_owned(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + })?; + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/initscripts.rs b/userland/brush/source/brush-core/src/shell/initscripts.rs new file mode 100644 index 0000000..5a65d5c --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/initscripts.rs @@ -0,0 +1,167 @@ +//! Init script support for shells. + +use std::path::PathBuf; + +use crate::{Shell, error, extensions, interp}; + +/// Behavior for loading profile files. +#[derive(Default)] +pub enum ProfileLoadBehavior { + /// Load the default profile files. + #[default] + LoadDefault, + /// Skip loading profile files. + Skip, +} + +impl ProfileLoadBehavior { + /// Returns whether profile loading should be skipped. + pub const fn skip(&self) -> bool { + matches!(self, Self::Skip) + } +} + +/// Behavior for loading rc files. +#[derive(Default)] +pub enum RcLoadBehavior { + /// Load the default rc files. + #[default] + LoadDefault, + /// Load a custom rc file; do not load defaults. + LoadCustom(PathBuf), + /// Skip loading rc files. + Skip, +} + +impl RcLoadBehavior { + /// Returns whether rc loading should be skipped. + pub const fn skip(&self) -> bool { + matches!(self, Self::Skip) + } +} + +impl Shell { + /// Loads and executes standard shell configuration files (i.e., rc and profile), and then + /// loads the history file (if command history is enabled). History is loaded last since + /// the configuration files may change `HISTFILE`. + /// + /// # Arguments + /// + /// * `profile_behavior` - Behavior for loading profile files. + /// * `rc_behavior` - Behavior for loading rc files. + pub async fn load_config( + &mut self, + profile_behavior: &ProfileLoadBehavior, + rc_behavior: &RcLoadBehavior, + ) -> Result<(), error::Error> { + self.load_config_files(profile_behavior, rc_behavior) + .await?; + + // As bash does, skip the file if startup files already added entries (e.g. via + // `history -s`). Do NOT fail if we can't load history. + if self.options.enable_command_history + && self + .history + .as_ref() + .is_none_or(crate::history::History::is_empty) + && let Ok(Some(history)) = self.load_history() + { + self.history = Some(history); + } + + Ok(()) + } + + async fn load_config_files( + &mut self, + profile_behavior: &ProfileLoadBehavior, + rc_behavior: &RcLoadBehavior, + ) -> Result<(), error::Error> { + let mut params = self.default_exec_params(); + params.process_group_policy = interp::ProcessGroupPolicy::SameProcessGroup; + + if self.options.login_shell { + // --noprofile means skip this. + if matches!(profile_behavior, ProfileLoadBehavior::Skip) { + return Ok(()); + } + + // + // Source the system profile if it exists. + // + // Next source the first of these that exists and is readable (if any): + // * ~/.bash_profile + // * ~/.bash_login + // * ~/.profile + // + if let Some(system_profile) = crate::sys::fs::get_system_profile_path() { + self.source_if_exists(system_profile, ¶ms).await?; + } + if let Some(home_path) = self.home_dir() { + if self.options.sh_mode { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } else { + if !self + .source_if_exists(home_path.join(".bash_profile").as_path(), ¶ms) + .await? + { + if !self + .source_if_exists(home_path.join(".bash_login").as_path(), ¶ms) + .await? + { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } + } + } + } + } else { + if self.options.interactive { + match rc_behavior { + _ if self.options.sh_mode => (), + RcLoadBehavior::Skip => (), + RcLoadBehavior::LoadCustom(rc_file) => { + // If an explicit rc file is provided, source it. + self.source_if_exists(rc_file, ¶ms).await?; + } + RcLoadBehavior::LoadDefault => { + // + // Otherwise, for non-login interactive shells, load in this order: + // + // system rc file (e.g. /etc/bash.bashrc on Unix) + // ~/.bashrc + // + if let Some(system_rc) = crate::sys::fs::get_system_rc_path() { + self.source_if_exists(system_rc, ¶ms).await?; + } + if let Some(home_path) = self.home_dir() { + self.source_if_exists(home_path.join(".bashrc").as_path(), ¶ms) + .await?; + self.source_if_exists(home_path.join(".brushrc").as_path(), ¶ms) + .await?; + } + } + } + } else { + let env_var_name = if self.options.sh_mode { + "ENV" + } else { + "BASH_ENV" + }; + + if self.env.is_set(env_var_name) { + // + // TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that + // file exists + // + return error::unimp( + "load config from $ENV/BASH_ENV for non-interactive, non-login shell", + ); + } + } + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/io.rs b/userland/brush/source/brush-core/src/shell/io.rs new file mode 100644 index 0000000..9e2b9bf --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/io.rs @@ -0,0 +1,112 @@ +//! I/O support for shell instances. + +use std::io::Write; + +use crate::{error, extensions, ioutils}; + +impl crate::Shell { + /// Returns a value that can be used to write to the shell's currently configured + /// standard output stream using `write!` et al. + pub fn stdout(&self) -> impl std::io::Write + 'static { + self.open_files.try_stdout().cloned().unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard output not available").into() + }) + } + + /// Returns a value that can be used to write to the shell's currently configured + /// standard error stream using `write!` et al. + pub fn stderr(&self) -> impl std::io::Write + 'static { + self.open_files.try_stderr().cloned().unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard error not available").into() + }) + } + + /// Outputs `set -x` style trace output for a command. Intentionally does not return + /// a result or error to avoid risk that a caller treats an error as fatal. Tracing + /// failure should generally always be ignored to avoid interfering with execution + /// flows. + /// + /// # Arguments + /// + /// * `command` - The command to trace. + pub(crate) async fn trace_command>( + &mut self, + params: &crate::interp::ExecutionParameters, + command: S, + ) { + // Expand the PS4 prompt variable to get our prefix. + let mut prefix = self + .as_mut() + .expand_prompt_var("PS4", "") + .await + .unwrap_or_default(); + + // Add additional depth-based prefixes using the first character of PS4. + let additional_depth = self.call_stack.script_source_depth() + self.depth; + if let Some(c) = prefix.chars().next() { + for _ in 0..additional_depth { + prefix.insert(0, c); + } + } + + // Resolve which file descriptor to use for tracing. We default to stderr, + // but if BASH_XTRACEFD is set and refers to a valid file descriptor, use that instead. + let trace_file = if let Some((_, xtracefd_var)) = self.env.get("BASH_XTRACEFD") + && let Ok(fd) = xtracefd_var + .value() + .to_cow_str(self) + .parse::() + && let Some(file) = self.open_files.try_fd(fd) + { + Some(file.clone()) + } else { + params.try_stderr(self) + }; + + // If we have a valid trace file, write to it. + if let Some(mut trace_file) = trace_file { + let _ = writeln!(trace_file, "{prefix}{}", command.as_ref()); + } + } + + /// Displays the given error to the user, using the shell's error display mechanisms. + /// + /// # Arguments + /// + /// * `file_table` - The open file table to use for any file descriptor references. + /// * `err` - The error to display. + pub fn display_error( + &self, + file: &mut impl std::io::Write, + err: &error::Error, + ) -> Result<(), error::Error> { + use crate::extensions::ErrorFormatter as _; + let str = self.error_formatter.format_error(err, self); + write!(file, "{str}")?; + + Ok(()) + } + + /// Ends a subshell (a `( … )` command, or a pipeline stage that runs in a + /// shell of its own) with its body's result, returning what leaves it: + /// its exit status, and nothing else. An error is reported on the + /// standard error `stderr` returns, if it's open, and its exit code is the + /// status. Whatever else the result would do to a shell (exit it, return + /// from a function, break a loop) ends the subshell alone. + pub(crate) fn end_subshell( + &self, + result: Result, + stderr: impl FnOnce() -> Option, + ) -> crate::ExecutionResult { + let exit_code = match result { + Ok(result) => result.exit_code, + Err(error) => { + if let Some(mut stderr) = stderr() { + let _ = self.display_error(&mut stderr, &error); + } + crate::ExecutionExitCode::from(&error) + } + }; + crate::ExecutionResult::from(exit_code) + } +} diff --git a/userland/brush/source/brush-core/src/shell/job_control.rs b/userland/brush/source/brush-core/src/shell/job_control.rs new file mode 100644 index 0000000..2ddb350 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/job_control.rs @@ -0,0 +1,20 @@ +//! Job management for shell instances. + +use std::io::Write; + +use crate::{error, extensions}; + +impl crate::Shell { + /// Checks for completed jobs in the shell, reporting any changes found. + pub fn check_for_completed_jobs(&mut self) -> Result<(), error::Error> { + let results = self.jobs.poll()?; + + if self.options.enable_job_control { + for (job, _result) in results { + writeln!(self.stderr(), "{job}")?; + } + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/parsing.rs b/userland/brush/source/brush-core/src/shell/parsing.rs new file mode 100644 index 0000000..963c648 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/parsing.rs @@ -0,0 +1,69 @@ +//! Parsing for shell instances. + +use std::io::Read; + +use crate::{Shell, extensions, trace_categories}; + +impl Shell { + /// Parses the given reader as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + pub fn parse( + &self, + reader: R, + ) -> Result { + let mut parser = create_parser(reader, &self.parser_options()); + + tracing::debug!(target: trace_categories::PARSE, "Parsing reader as program..."); + parser.parse_program() + } + + /// Parses the given string as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + /// + /// # Arguments + /// + /// * `s` - The string to parse as a program. + pub fn parse_string>( + &self, + s: S, + ) -> Result { + let s: String = s.into(); + parse_string_impl(&s, &self.parser_options()) + } + + /// Returns the options that should be used for parsing shell programs; reflects + /// the current configuration state of the shell and may change over time. + pub const fn parser_options(&self) -> brush_parser::ParserOptions { + brush_parser::ParserOptions { + enable_extended_globbing: self.options.extended_globbing, + posix_mode: self.options.posix_mode, + sh_mode: self.options.sh_mode, + tilde_expansion_at_word_start: true, + tilde_expansion_after_colon: false, + parser_impl: self.parser_impl, + } + } +} + +#[cached::macros::cached( + max_size = 64, + key = "(String, brush_parser::ParserOptions)", + convert = r#"{ (s.to_owned(), parser_options.to_owned()) }"# +)] +fn parse_string_impl( + s: &str, + parser_options: &brush_parser::ParserOptions, +) -> Result { + let mut parser = create_parser(s.as_bytes(), parser_options); + + tracing::debug!(target: trace_categories::PARSE, "Parsing string as program..."); + parser.parse_program() +} + +pub(super) fn create_parser( + r: R, + parser_options: &brush_parser::ParserOptions, +) -> brush_parser::Parser> { + let reader = std::io::BufReader::new(r); + brush_parser::Parser::new(reader, parser_options) +} diff --git a/userland/brush/source/brush-core/src/shell/prompts.rs b/userland/brush/source/brush-core/src/shell/prompts.rs new file mode 100644 index 0000000..2fc2a7f --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/prompts.rs @@ -0,0 +1,77 @@ +//! Prompt handling for shell instances. + +use std::borrow::Cow; + +use crate::{Shell, error, extensions, prompt}; + +impl Shell { + /// Returns the default prompt string for the shell. + const fn default_prompt(&self) -> &'static str { + if self.options.sh_mode { + "$ " + } else { + "brush$ " + } + } + + /// Composes the shell's post-input, pre-command prompt, applying all appropriate expansions. + pub async fn compose_precmd_prompt(&mut self) -> Result { + self.expand_prompt_var("PS0", "").await + } + + /// Composes the shell's prompt, applying all appropriate expansions. + pub async fn compose_prompt(&mut self) -> Result { + self.expand_prompt_var("PS1", self.default_prompt()).await + } + + /// Composes the shell's alternate-side prompt, applying all appropriate expansions. + pub async fn compose_alt_side_prompt(&mut self) -> Result { + // This is a brush extension. + self.expand_prompt_var("BRUSH_PS_ALT", "").await + } + + /// Composes the shell's continuation prompt. + pub async fn compose_continuation_prompt(&mut self) -> Result { + self.expand_prompt_var("PS2", "> ").await + } + + pub(super) async fn expand_prompt_var( + &mut self, + var_name: &str, + default: &str, + ) -> Result { + // + // TODO(prompt): bash appears to do this in a subshell; we need to investigate + // if that's required. + // + + // Retrieve the spec. + let prompt_spec = self.parameter_or_default(var_name, default); + if prompt_spec.is_empty() { + return Ok(String::new()); + } + + // Save (and later restore) the state the last command left behind, so that + // expanding here is invisible to it. + let saved_status = self.save_command_status(); + + // Expand it. We must own the spec here: it borrows `self` (via the + // returned `Cow`), and `expand_prompt` needs `&mut self`. + let prompt_spec = prompt_spec.into_owned(); + let params = self.default_exec_params(); + let result = prompt::expand_prompt(self, ¶ms, &prompt_spec).await; + + self.restore_command_status(saved_status); + + // Strip out special characters that readline would typically drop: + // \001 and \002 (start and end of non-printing sequences). + let mut expanded = result?; + expanded.retain(|c| c != '\x01' && c != '\x02'); + + Ok(expanded) + } + + fn parameter_or_default<'a>(&'a self, name: &str, default: &'a str) -> Cow<'a, str> { + self.env_str(name).unwrap_or_else(|| default.into()) + } +} diff --git a/userland/brush/source/brush-core/src/shell/readline.rs b/userland/brush/source/brush-core/src/shell/readline.rs new file mode 100644 index 0000000..aaefb56 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/readline.rs @@ -0,0 +1,42 @@ +//! Readline edit buffer support for shell instances. + +use crate::{error, extensions, variables::ShellVariable}; + +impl crate::Shell { + /// Updates the shell state to reflect the given edit buffer contents. + /// + /// # Arguments + /// + /// * `contents` - The contents of the edit buffer. + /// * `cursor` - The cursor position in the edit buffer. + pub fn set_edit_buffer(&mut self, contents: String, cursor: usize) -> Result<(), error::Error> { + self.env + .set_global("READLINE_LINE", ShellVariable::new(contents))?; + + self.env + .set_global("READLINE_POINT", ShellVariable::new(cursor.to_string()))?; + + Ok(()) + } + + /// Returns the contents of the shell's edit buffer, if any. The buffer + /// state is cleared from the shell. + pub fn pop_edit_buffer(&mut self) -> Result, error::Error> { + let line = self + .env + .unset("READLINE_LINE")? + .map(|line| line.value().to_cow_str(self).to_string()); + + let point = self + .env + .unset("READLINE_POINT")? + .and_then(|point| point.value().to_cow_str(self).parse::().ok()) + .unwrap_or(0); + + if let Some(line) = line { + Ok(Some((line, point))) + } else { + Ok(None) + } + } +} diff --git a/userland/brush/source/brush-core/src/shell/state.rs b/userland/brush/source/brush-core/src/shell/state.rs new file mode 100644 index 0000000..fb5ee5c --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/state.rs @@ -0,0 +1,124 @@ +//! Defines state traits for the shell. + +use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, +}; + +use crate::{ + completion, env::ShellEnvironment, jobs, openfiles, options::RuntimeOptions, pathcache, + shell::KeyBindingsHelper, +}; + +/// A dyn-safe trait for constrained access to shell state. +pub trait ShellState { + /// Returns whether or not this shell is a subshell. + fn is_subshell(&self) -> bool; + + /// Returns the last "SECONDS" captured time. + fn last_stopwatch_time(&self) -> std::time::SystemTime; + + /// Returns the last "SECONDS" offset requested. + fn last_stopwatch_offset(&self) -> u32; + + /// Returns the shell environment containing variables. + fn env(&self) -> &ShellEnvironment; + + /// Returns a mutable reference to the shell environment. + fn env_mut(&mut self) -> &mut ShellEnvironment; + + /// Returns the shell's runtime options. + fn options(&self) -> &RuntimeOptions; + + /// Returns a mutable reference to the shell's runtime options. + fn options_mut(&mut self) -> &mut RuntimeOptions; + + /// Returns the shell's aliases. + fn aliases(&self) -> &HashMap; + + /// Returns a mutable reference to the shell's aliases. + fn aliases_mut(&mut self) -> &mut HashMap; + + /// Returns the shell's job manager. + fn jobs(&self) -> &jobs::JobManager; + + /// Returns a mutable reference to the shell's job manager. + fn jobs_mut(&mut self) -> &mut jobs::JobManager; + + /// Returns the shell's trap handler configuration. + fn traps(&self) -> &crate::traps::TrapHandlerConfig; + + /// Returns a mutable reference to the shell's trap handler configuration. + fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig; + + /// Returns the shell's directory stack. + fn directory_stack(&self) -> &[PathBuf]; + + /// Returns a mutable reference to the shell's directory stack. + fn directory_stack_mut(&mut self) -> &mut Vec; + + /// Returns the statuses of commands in the last pipeline. + fn last_pipeline_statuses(&self) -> &[u8]; + + /// Returns a mutable reference to the statuses of commands in the last pipeline. + fn last_pipeline_statuses_mut(&mut self) -> &mut Vec; + + /// Returns the shell's program location cache. + fn program_location_cache(&self) -> &pathcache::PathCache; + + /// Returns a mutable reference to the shell's program location cache. + fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache; + + /// Returns the shell's completion configuration. + fn completion_config(&self) -> &completion::Config; + + /// Returns a mutable reference to the shell's completion configuration. + fn completion_config_mut(&mut self) -> &mut completion::Config; + + /// Returns the shell's open files. + fn open_files(&self) -> &openfiles::OpenFiles; + + /// Returns a mutable reference to the shell's open files. + fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles; + + /// Returns the *current* name of the shell ($0). + fn current_shell_name(&self) -> Option>; + + /// Returns the current subshell depth; 0 is returned if this shell is not a subshell. + fn depth(&self) -> usize; + + /// Returns the call stack for the shell. + fn call_stack(&self) -> &crate::callstack::CallStack; + + /// Returns the shell's history, if it exists. + fn history(&self) -> Option<&crate::history::History>; + + /// Returns a mutable reference to the shell's history, if it exists. + fn history_mut(&mut self) -> Option<&mut crate::history::History>; + + /// Returns the shell's official version string (if available). + fn version(&self) -> Option<&str>; + + /// Returns the exit status of the last command executed in this shell. + fn last_exit_status(&self) -> u8; + + /// Updates the last exit status. + fn set_last_exit_status(&mut self, status: u8); + + /// Returns the key bindings helper for the shell. + fn key_bindings(&self) -> Option<&KeyBindingsHelper>; + + /// Sets the key bindings helper for the shell. + fn set_key_bindings(&mut self, key_bindings: Option); + + /// Returns the shell's current working directory. + fn working_dir(&self) -> &Path; + + /// Returns a mutable reference to the shell's current working directory. + /// This is only accessible within the crate. + fn working_dir_mut(&mut self) -> &mut PathBuf; + + /// Returns the product display name for this shell. + fn product_display_str(&self) -> Option<&str>; +} diff --git a/userland/brush/source/brush-core/src/shell/traps.rs b/userland/brush/source/brush-core/src/shell/traps.rs new file mode 100644 index 0000000..54ee0a5 --- /dev/null +++ b/userland/brush/source/brush-core/src/shell/traps.rs @@ -0,0 +1,108 @@ +//! Trap handling for the shell. + +use crate::{ExecutionParameters, ExecutionResult, ProcessGroupPolicy, error, traps::TrapSignal}; + +impl crate::Shell { + /// Runs any exit steps for the shell. + /// + /// This currently includes invoking the `EXIT` trap handler, if any. If the handler + /// exits the shell, its exit status becomes the shell's last exit status. + pub async fn on_exit(&mut self) -> Result<(), error::Error> { + self.run_exit_trap().await?; + Ok(()) + } + + /// Invokes the `EXIT` trap handler, if any. If the handler exits the shell, returns + /// the status it exits with, having made it the shell's last exit status. + pub(crate) async fn run_exit_trap(&mut self) -> Result, error::Error> { + if !self.traps.handles(TrapSignal::Exit) { + return Ok(None); + } + + let result = self + .invoke_trap_handler(TrapSignal::Exit, &self.default_exec_params()) + .await?; + + if result.is_exit() { + let status = result.exit_code.into(); + self.set_last_exit_status(status); + Ok(Some(status)) + } else { + Ok(None) + } + } + + /// Invokes the handler registered for `signal`, if any. + /// + /// Behavior varies by signal type: + /// + /// * **Per-signal recursion guard** — each trap guards against its own self-recursion, but + /// different traps *can* fire from within each other's handlers (matching bash semantics). + /// + /// * **Inheritance** — in functions and subshells, some traps are only inherited when the + /// corresponding shell option is enabled (e.g. `errtrace` / `set -E` for `ERR`, `functrace` / + /// `set -T` for `DEBUG`/`RETURN`). + /// + /// * **`$?` preservation** — `last_exit_status` is saved before and restored after the handler + /// runs so the trap does not clobber the status that triggered it. + /// + /// # Arguments + /// + /// * `signal`: Signal to run handler for. + /// + /// * `params`: Execution parameters to use for handler. + pub(crate) async fn invoke_trap_handler( + &mut self, + signal: TrapSignal, + params: &ExecutionParameters, + ) -> Result { + // Per-signal self-recursion guard: don't re-enter a trap that is + // already being handled. Different traps *can* fire from each + // other's handlers (e.g. ERR inside EXIT, EXIT inside ERR). + if self.call_stack().is_trap_signal_active(signal) { + return Ok(ExecutionResult::success()); + } + + // Don't fire traps that have been explicitly suppressed (e.g. DEBUG + // during programmable completion). + if self.call_stack().is_trap_delivery_suppressed() { + return Ok(ExecutionResult::success()); + } + + // In functions and subshells, some traps are only inherited when the + // corresponding option is enabled. + if (self.in_function() || self.is_subshell()) + && !self.is_trap_inherited_in_current_scope(signal) + { + return Ok(ExecutionResult::success()); + } + + let Some(handler) = self.traps.get_handler(signal).cloned() else { + return Ok(ExecutionResult::success()); + }; + + let mut params = params.clone(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // The guard leaves the handler, restoring `$?`, once this finishes -- or is cancelled. + self.enter_trap_handler(signal, Some(&handler)) + .run_string(&handler.command, &handler.source_info, ¶ms) + .await + } + + /// Returns whether the given trap signal is inherited in the current + /// function or subshell scope. + fn is_trap_inherited_in_current_scope(&self, signal: TrapSignal) -> bool { + match signal { + TrapSignal::Err => self.options().shell_functions_inherit_err_trap, + TrapSignal::Debug | TrapSignal::Return => { + self.options() + .shell_functions_inherit_debug_and_return_traps + } + // EXIT and system signals are always inherited — i.e. their visibility is + // not gated by errtrace/functrace options. (The actual trap *state* for + // subshells is managed separately via `Shell::clone`.) + TrapSignal::Exit | TrapSignal::Signal(_) => true, + } + } +} diff --git a/userland/brush/source/brush-core/src/sourceinfo.rs b/userland/brush/source/brush-core/src/sourceinfo.rs new file mode 100644 index 0000000..909b718 --- /dev/null +++ b/userland/brush/source/brush-core/src/sourceinfo.rs @@ -0,0 +1,44 @@ +//! Source info. + +use std::{path::PathBuf, sync::Arc}; + +/// Source context. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SourceInfo { + /// The name of the source. + pub source: String, + /// Optionally indicates a starting location after the beginning of the source. + /// If `None`, the start is the beginning of the source. + pub start: Option>, +} + +impl From<&str> for SourceInfo { + fn from(source: &str) -> Self { + Self { + source: source.to_owned(), + start: None, + } + } +} + +impl From for SourceInfo { + fn from(path: PathBuf) -> Self { + Self { + source: path.to_string_lossy().to_string(), + start: None, + } + } +} + +impl std::fmt::Display for SourceInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.source)?; + + if let Some(pos) = &self.start { + write!(f, ":{},{}", pos.line, pos.column)?; + } + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/sys.rs b/userland/brush/source/brush-core/src/sys.rs new file mode 100644 index 0000000..399000c --- /dev/null +++ b/userland/brush/source/brush-core/src/sys.rs @@ -0,0 +1,50 @@ +//! Platform abstraction facilities + +#![allow(unused)] + +#[cfg(unix)] +pub(crate) mod unix; +#[cfg(unix)] +pub(crate) use unix as platform; + +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +pub(crate) use windows as platform; + +#[cfg(target_family = "wasm")] +pub(crate) mod wasm; +#[cfg(target_family = "wasm")] +pub(crate) use wasm as platform; + +#[cfg(not(unix))] +pub(crate) mod stubs; + +#[cfg(any(unix, windows))] +pub(crate) mod hostname; +#[cfg(any(unix, windows))] +pub(crate) mod os_pipes; +#[cfg(any(unix, windows))] +pub mod tokio_process; +#[cfg(any(unix, windows))] +pub(crate) mod tokio_tasks; + +pub mod fs; + +pub use platform::async_pipe; +#[cfg(any(unix, windows))] +pub(crate) use platform::commands; +pub(crate) use platform::env; +pub use platform::fd; +pub use platform::input; +pub(crate) use platform::network; +pub(crate) use platform::pipes; +pub use platform::poll; +pub use platform::process; +pub use platform::resource; +pub use platform::signal; +pub(crate) use platform::tasks; +pub use platform::terminal; +pub(crate) use platform::users; + +pub use platform::PlatformError; diff --git a/userland/brush/source/brush-core/src/sys/fs.rs b/userland/brush/source/brush-core/src/sys/fs.rs new file mode 100644 index 0000000..a5dabfa --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/fs.rs @@ -0,0 +1,37 @@ +//! Filesystem utilities + +pub use super::platform::fs::*; + +/// Extension trait for path-related filesystem operations. +pub trait PathExt { + /// Returns true if the path exists and is readable by the current user. + fn readable(&self) -> bool; + /// Returns true if the path exists and is writable by the current user. + fn writable(&self) -> bool; + /// Returns true if the path exists and is executable by the current user. + /// + /// On Windows, this returns true if *either* the path itself is a file with + /// a `PATHEXT` extension *or* appending some `PATHEXT` extension resolves + /// to an existing file. To recover the actual on-disk path in the + /// latter case, use [`resolve_executable`] which takes ownership + /// and avoids copies on platforms where no resolution is needed. + fn executable(&self) -> bool; + + /// Returns true if the path exists and is a block device. + fn exists_and_is_block_device(&self) -> bool; + /// Returns true if the path exists and is a character device. + fn exists_and_is_char_device(&self) -> bool; + /// Returns true if the path exists and is a FIFO (named pipe). + fn exists_and_is_fifo(&self) -> bool; + /// Returns true if the path exists and is a socket. + fn exists_and_is_socket(&self) -> bool; + /// Returns true if the path exists and has the setgid bit set. + fn exists_and_is_setgid(&self) -> bool; + /// Returns true if the path exists and has the setuid bit set. + fn exists_and_is_setuid(&self) -> bool; + /// Returns true if the path exists and has the sticky bit set. + fn exists_and_is_sticky_bit(&self) -> bool; + + /// Returns the device ID and inode number for the path. + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error>; +} diff --git a/userland/brush/source/brush-core/src/sys/hostname.rs b/userland/brush/source/brush-core/src/sys/hostname.rs new file mode 100644 index 0000000..422b880 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/hostname.rs @@ -0,0 +1,3 @@ +pub(crate) fn get() -> std::io::Result { + hostname::get() +} diff --git a/userland/brush/source/brush-core/src/sys/os_pipes.rs b/userland/brush/source/brush-core/src/sys/os_pipes.rs new file mode 100644 index 0000000..00023db --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/os_pipes.rs @@ -0,0 +1,3 @@ +//! Anonymous pipes backed by the operating system. + +pub(crate) use std::io::{PipeReader, PipeWriter, pipe}; diff --git a/userland/brush/source/brush-core/src/sys/stubs.rs b/userland/brush/source/brush-core/src/sys/stubs.rs new file mode 100644 index 0000000..d1e9289 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs.rs @@ -0,0 +1,28 @@ +#![allow(dead_code)] +#![allow(clippy::missing_const_for_fn)] +#![allow(clippy::needless_pass_by_ref_mut)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::unnecessary_wraps)] +// These stubs stand in for real platform functionality on targets that lack it (chiefly wasm). +// They must present exactly the same signatures as the `unix`/`windows` implementations they +// substitute for -- including `async` on functions that are genuinely async elsewhere, e.g. +// `process::Child::wait`, which resolves to `tokio::process::Child::wait` on other targets. A stub +// body that never awaits is therefore the cfg-fork contract being honored, not a defect. +#![allow(clippy::unused_async)] +#![allow( + clippy::unused_async_trait_impl, + reason = "stubs must match the async signatures of the platform impls they stand in for" +)] +#![allow(clippy::unused_self)] + +pub mod async_pipe; +pub mod commands; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) mod network; +pub mod poll; +pub mod resource; +pub mod signal; +pub mod terminal; +pub(crate) mod users; diff --git a/userland/brush/source/brush-core/src/sys/stubs/async_pipe.rs b/userland/brush/source/brush-core/src/sys/stubs/async_pipe.rs new file mode 100644 index 0000000..6b6ca14 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/async_pipe.rs @@ -0,0 +1,30 @@ +//! Async pipe reading utilities for non-Unix platforms. +//! +//! Uses `spawn_blocking` internally for the I/O operation only, +//! not for the entire subshell execution. + +use std::io::{self, Read}; + +pub(crate) struct AsyncPipeReader { + inner: Option, +} + +impl AsyncPipeReader { + pub(crate) fn new(fd: std::io::PipeReader) -> io::Result { + Ok(Self { inner: Some(fd) }) + } + + pub(crate) async fn read_to_string(&mut self) -> io::Result { + let Some(reader) = self.inner.take() else { + return Ok(String::new()); + }; + + tokio::task::spawn_blocking(move || { + let mut s = String::new(); + { reader }.read_to_string(&mut s)?; + Ok(s) + }) + .await + .map_err(io::Error::other)? + } +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/commands.rs b/userland/brush/source/brush-core/src/sys/stubs/commands.rs new file mode 100644 index 0000000..f54d4f1 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/commands.rs @@ -0,0 +1,120 @@ +//! Command execution utilities. + +use std::ffi::OsStr; + +use crate::{ShellFd, error, openfiles}; + +/// Extension trait for Unix-like command extensions. +pub trait CommandExt { + /// Sets the zeroth argument (argv[0]) of the command. + /// + /// # Arguments + /// + /// * `arg` - The argument to set as argv[0]. + fn arg0(&mut self, arg: S) -> &mut Self + where + S: AsRef; + + /// Sets the process group ID of the command. + /// + /// # Arguments + /// + /// * `pgroup` - The process group ID to set. + fn process_group(&mut self, pgroup: i32) -> &mut Self; +} + +impl CommandExt for std::process::Command { + fn arg0(&mut self, _arg: S) -> &mut Self + where + S: AsRef, + { + // NOTE: no-op. + self + } + + fn process_group(&mut self, _pgroup: i32) -> &mut Self { + // NOTE: no-op. + self + } +} + +/// Extension trait for Unix-like exit status extensions. +pub trait ExitStatusExt { + /// Returns the signal that terminated the process, if any. + fn signal(&self) -> Option; +} + +impl ExitStatusExt for std::process::ExitStatus { + fn signal(&self) -> Option { + None + } +} + +/// Replaces the current process with `command`; not supported on this platform. +pub(crate) fn exec(_command: std::process::Command) -> std::io::Error { + std::io::ErrorKind::Unsupported.into() +} + +/// Returns the standard stream for a process to have `file` as. +pub(crate) fn stdio_for(file: openfiles::OpenFile) -> std::io::Result { + use std::process::Stdio; + + match file { + // The shell's own output streams go to whichever stream the process has them as; + // e.g., the shell's stderr as the process's stdout, for `>&2`. + // TODO(redirection): the shell's stdin as an output stream (e.g., `>&0`) is + // inherited from the stream it replaces instead. + openfiles::OpenFile::Stdin(_) => Ok(Stdio::inherit()), + openfiles::OpenFile::Stdout(s) => Ok(s.into()), + openfiles::OpenFile::Stderr(s) => Ok(s.into()), + openfiles::OpenFile::File(f) => Ok(f.try_clone()?.into()), + openfiles::OpenFile::PipeReader(r) => Ok(r.try_clone()?.into()), + openfiles::OpenFile::PipeWriter(w) => Ok(w.try_clone()?.into()), + // A custom stream has no handle to give a process. + openfiles::OpenFile::Stream(_) => Ok(Stdio::null()), + } +} + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + mut open_files: impl Iterator, + ) -> Result<(), error::Error> { + if open_files.next().is_some() { + return Err(error::ErrorKind::NotSupportedOnThisPlatform("fd redirections").into()); + } + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); + /// Arranges for the command to become a session leader when it is executed. + fn lead_session(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // NOTE: This is a no-op. + } + + fn lead_session(&mut self) { + // NOTE: This is a no-op. + } +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/fd.rs b/userland/brush/source/brush-core/src/sys/stubs/fd.rs new file mode 100644 index 0000000..88ad541 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/fd.rs @@ -0,0 +1,13 @@ +//! File descriptor utilities. + +use crate::{ShellFd, error, openfiles}; + +/// Stub implementation for platforms that do not support enumerating file descriptors. +pub fn try_iter_open_fds() -> impl Iterator { + std::iter::empty() +} + +/// Stub implementation for platforms that do not support opening file descriptors. +pub fn try_get_file_for_open_fd(_fd: ShellFd) -> Option { + None +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/fs.rs b/userland/brush/source/brush-core/src/sys/stubs/fs.rs new file mode 100644 index 0000000..77643fb --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/fs.rs @@ -0,0 +1,146 @@ +//! Filesystem utilities (stubs). + +use crate::error; + +pub(crate) trait MetadataExt { + fn gid(&self) -> u32 { + 0 + } + + fn uid(&self) -> u32 { + 0 + } +} + +impl MetadataExt for std::fs::Metadata {} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + vec![] +} + +/// Returns the default paths where standard Unix utilities are typically installed. +/// This is a stub implementation that returns an empty vector. +pub fn get_default_standard_utils_paths() -> Vec { + vec![] +} + +/// Opens a null file that will discard all I/O. +/// +/// This is a stub implementation that returns an error. +pub fn open_null_file() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("opening null file").into()) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +// +// This is a stub implementation that returns no result. +pub fn try_open_special_file( + _path: &std::path::Path, +) -> Option> { + None +} + +/// Returns the path to the system-wide shell profile script. +/// +/// Stub implementation that returns `None`. +pub fn get_system_profile_path() -> Option<&'static std::path::Path> { + None +} + +/// Returns the path to the system-wide shell rc script. +/// +/// Stub implementation that returns `None`. +pub fn get_system_rc_path() -> Option<&'static std::path::Path> { + None +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// In the stub implementation, this returns `false`. +pub const fn default_case_insensitive_path_expansion() -> bool { + false +} + +/// Returns true if the string contains a path separator character. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn contains_path_separator(s: &str) -> bool { + s.contains('/') +} + +/// Returns true if the string ends with a path separator character. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with('/') +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix('/').unwrap_or(s) +} + +/// Finds the byte index of the last path separator in the string. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind('/') +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// In the stub implementation, only `/` is used as a separator. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split('/') +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// In the stub implementation, an empty first component indicates an absolute path. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + Some(std::path::PathBuf::from("/")) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// In the stub implementation, this delegates directly to `PathBuf::push`. +pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) { + path.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// In the stub implementation, this is a no-op. +pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(s) +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// In the stub implementation, returns the path unchanged if it is +/// executable (per the stub `PathExt`, which considers every path +/// executable). +pub fn resolve_executable(path: std::path::PathBuf) -> Option { + use crate::sys::fs::PathExt; + if path.as_path().executable() { + Some(path) + } else { + None + } +} + +/// Resolves a path to a potential executable. +/// +/// Platforms without PATHEXT resolution leave the path unchanged. +pub(crate) fn resolve_executable_path( + path: std::path::PathBuf, + _working_dir: &std::path::Path, +) -> std::path::PathBuf { + path +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/input.rs b/userland/brush/source/brush-core/src/sys/stubs/input.rs new file mode 100644 index 0000000..a83795e --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/input.rs @@ -0,0 +1,16 @@ +//! Terminal input utilities + +use crate::{error, interfaces}; + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// This is a stub implementation that recognizes single-byte non-control +/// characters but does not support terminal-specific key sequences. +pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option { + if key_code.len() == 1 && !key_code[0].is_ascii_control() { + Some(interfaces::Key::Character(key_code[0] as char)) + } else { + None + } +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/network.rs b/userland/brush/source/brush-core/src/sys/stubs/network.rs new file mode 100644 index 0000000..73ed315 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + Ok("".into()) +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/poll.rs b/userland/brush/source/brush-core/src/sys/stubs/poll.rs new file mode 100644 index 0000000..4877d29 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/poll.rs @@ -0,0 +1,15 @@ +//! Stub file descriptor polling utilities for platforms without poll support. + +use std::time::Duration; + +use crate::openfiles::OpenFile; + +/// Stub implementation that always returns an unsupported error. +/// +/// Timeout-based reading is not supported on this platform. +pub fn poll_for_input(_file: &OpenFile, _timeout: Duration) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "poll-based timeout is not supported on this platform", + )) +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/resource.rs b/userland/brush/source/brush-core/src/sys/stubs/resource.rs new file mode 100644 index 0000000..756e023 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/resource.rs @@ -0,0 +1,19 @@ +//! Signal processing utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} + +/// Returns the user and system CPU time used by child processes. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/signal.rs b/userland/brush/source/brush-core/src/sys/stubs/signal.rs new file mode 100644 index 0000000..5bca96e --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/signal.rs @@ -0,0 +1,119 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +/// A stub enum representing system signals on unsupported platforms. +#[allow(unnameable_types)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Signal {} + +impl Signal { + /// Returns an iterator over all possible signals. + pub fn iterator() -> impl Iterator { + std::iter::empty() + } + + /// Converts the signal into its corresponding name as a `&'static str`. + pub const fn as_str(self) -> &'static str { + "" + } + + /// Creates a `Signal` from a string representation. + pub fn from_str(s: &str) -> Result { + Err(error::ErrorKind::InvalidSignal(s.into()).into()) + } +} + +impl TryFrom for Signal { + type Error = error::Error; + + fn try_from(value: i32) -> Result { + Err(error::ErrorKind::InvalidSignal(std::format!("{value}")).into()) + } +} + +pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into()) +} + +pub(crate) fn continue_process_group(_pgid: sys::process::ProcessId) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process group").into()) +} + +/// Checks whether a specific process exists and can be signaled. +/// +/// This is a stub implementation that returns an error. +pub fn check_signalable(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("checking process").into()) +} + +/// Sends a signal to a specific process. +/// +/// This is a stub implementation that returns an error. +pub fn kill_process( + _pid: sys::process::ProcessId, + _signal: traps::TrapSignal, +) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process").into()) +} + +pub(crate) fn check_process_group_signalable( + _pgid: sys::process::ProcessId, +) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("checking process group").into()) +} + +pub(crate) fn kill_process_group( + _pgid: sys::process::ProcessId, + _signal: traps::TrapSignal, +) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process group").into()) +} + +pub(crate) const fn continue_stopped_on(_signal: traps::TrapSignal) -> bool { + false +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) struct FakeSignal {} + +impl FakeSignal { + fn new() -> Self { + Self {} + } + + pub async fn recv(&self) { + futures::future::pending::<()>().await; + } +} + +pub(crate) fn tstp_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +pub(crate) fn chld_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +/// Waits for Ctrl-C; on this platform, it never arrives. +pub async fn await_ctrl_c() -> std::io::Result<()> { + FakeSignal::new().recv().await; + Ok(()) +} + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) fn poll_for_stopped_children( + _pid: sys::process::ProcessId, +) -> Result { + Ok(false) +} + +pub(crate) fn poll_for_stopped_child(_pid: sys::process::ProcessId) -> Result { + Ok(false) +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/terminal.rs b/userland/brush/source/brush-core/src/sys/stubs/terminal.rs new file mode 100644 index 0000000..328ee40 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/terminal.rs @@ -0,0 +1,86 @@ +//! Terminal utilities. + +use crate::{error, openfiles, sys, terminal}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config; + +#[allow(clippy::unused_self)] +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `_file` - A reference to the open terminal. + pub fn from_term(_file: &openfiles::OpenFile) -> Result { + Ok(Self) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `_file` - A reference to the open terminal. + pub fn apply_to_term(&self, _file: &openfiles::OpenFile) -> Result<(), error::Error> { + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `_settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, _settings: &terminal::Settings) {} +} + +/// Get the process ID of this process's parent. +/// +/// This is a stub implementation that returns `None`. +pub fn get_parent_process_id() -> Option { + None +} + +/// Get the process group ID for this process's process group. +/// +/// This is a stub implementation that returns `None`. +pub fn get_process_group_id() -> Option { + None +} + +/// Get the foreground process ID of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn get_foreground_pid() -> Option { + None +} + +/// Move the specified process to the foreground of the attached terminal. +/// +/// This is a stub implementation that takes no action. +pub fn move_to_foreground(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn move_self_to_foreground() -> Result<(), std::io::Error> { + Ok(()) +} + +/// Tries to get the path of the terminal device associated with the attached terminal. +/// +/// This is a stub implementation that always returns `None`. +pub fn try_get_terminal_device_path() -> Option { + None +} + +/// Returns the width, in columns, of the terminal associated with the given file. +/// +/// This is a stub implementation that always returns `None`. +pub const fn get_columns(_file: &openfiles::OpenFile) -> Option { + None +} diff --git a/userland/brush/source/brush-core/src/sys/stubs/users.rs b/userland/brush/source/brush-core/src/sys/stubs/users.rs new file mode 100644 index 0000000..3b2e47e --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/stubs/users.rs @@ -0,0 +1,50 @@ +use crate::error; +use std::path::PathBuf; + +pub(crate) fn get_user_home_dir(_username: &str) -> Option { + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + std::env::home_dir() +} + +pub(crate) fn get_current_user_default_shell() -> Option { + None +} + +pub(crate) fn is_root() -> bool { + false +} + +pub(crate) fn get_current_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into()) +} + +pub(crate) fn get_current_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into()) +} + +pub(crate) fn get_effective_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into()) +} + +pub(crate) fn get_effective_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into()) +} + +pub(crate) fn get_current_username() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current username").into()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_users() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_groups() -> Result, error::Error> { + Ok(vec![]) +} diff --git a/userland/brush/source/brush-core/src/sys/tokio_process.rs b/userland/brush/source/brush-core/src/sys/tokio_process.rs new file mode 100644 index 0000000..14730d6 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/tokio_process.rs @@ -0,0 +1,109 @@ +//! Process management utilities + +use std::process::Stdio; + +use futures::FutureExt as _; + +use crate::{ + ExecutionResult, ShellFd, + openfiles::OpenFiles, + processes::{ChildProcess, LaunchRequest, ProcessGroup}, + sys::commands::{self, CommandExt, CommandFdInjectionExt, CommandFgControlExt}, +}; + +pub(crate) type ProcessId = i32; + +/// Starts a native child process. +/// +/// # Arguments +/// +/// * `command` - The process to start. +pub(crate) fn spawn(command: LaunchRequest) -> std::io::Result { + let process_group = command.process_group; + // See `CreateOptions::kill_external_commands_on_drop` (false for ordinary shells). + let kill_on_drop = command.kill_on_drop; + + let mut command = tokio::process::Command::from(compose_std_command(command)?); + command.kill_on_drop(kill_on_drop); + let child = command.spawn()?; + + #[expect(clippy::cast_possible_wrap)] + let pid = child.id().map(|id| id as ProcessId); + if pid.is_none() { + tracing::warn!("could not retrieve pid for child process"); + } + + let pgid = match process_group { + ProcessGroup::Inherit => None, + ProcessGroup::Join(pgid) => Some(pgid), + ProcessGroup::New { .. } | ProcessGroup::NewSession => pid, + }; + + let exited = child + .wait_with_output() + .map(|output| output.map(ExecutionResult::from)); + Ok(ChildProcess::from_future(exited).native_child(pid, pgid)) +} + +/// Replaces the current process with a new one. Returns only if that fails, with +/// the reason why. +/// +/// # Arguments +/// +/// * `command` - The process to replace the current one with. +pub(crate) fn exec(command: LaunchRequest) -> std::io::Error { + match compose_std_command(command) { + Ok(command) => commands::exec(command), + Err(err) => err, + } +} + +/// Composes the `std::process::Command` that starts a process. +fn compose_std_command(command: LaunchRequest) -> std::io::Result { + let mut cmd = std::process::Command::new(command.program); + + // Override argv[0]. + // NOTE: Not supported on all platforms. + cmd.arg0(command.argv0); + cmd.args(command.args); + cmd.current_dir(command.working_dir); + cmd.env_clear(); + cmd.envs(command.env); + + // A closed standard stream gets the null device. + // TODO(redirection): close it instead, as bash does (e.g., with a `pre_exec` + // on Unix), so that the process fails to use it. + let mut open_files = command.open_files; + cmd.stdin(take_stdio(&mut open_files, OpenFiles::STDIN_FD)?); + cmd.stdout(take_stdio(&mut open_files, OpenFiles::STDOUT_FD)?); + cmd.stderr(take_stdio(&mut open_files, OpenFiles::STDERR_FD)?); + + cmd.inject_fds(open_files.iter_fds().map(|(fd, file)| (fd, file.clone()))) + .map_err(std::io::Error::other)?; + + match command.process_group { + ProcessGroup::Inherit => {} + ProcessGroup::Join(pgid) => { + cmd.process_group(pgid); + } + ProcessGroup::New { take_foreground } => { + cmd.process_group(0); + if take_foreground { + cmd.take_foreground(); + } + } + // setsid() makes a new process group too. + ProcessGroup::NewSession => cmd.lead_session(), + } + + Ok(cmd) +} + +/// Takes the file for standard stream `fd` out of `open_files`, for the process +/// to have as that stream: the null device if it's closed. +fn take_stdio(open_files: &mut OpenFiles, fd: ShellFd) -> std::io::Result { + match open_files.remove_fd(fd) { + Some(file) => commands::stdio_for(file), + None => Ok(Stdio::null()), + } +} diff --git a/userland/brush/source/brush-core/src/sys/tokio_tasks.rs b/userland/brush/source/brush-core/src/sys/tokio_tasks.rs new file mode 100644 index 0000000..42b852b --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/tokio_tasks.rs @@ -0,0 +1,106 @@ +//! Tasks run on tokio's runtime. + +use futures::FutureExt as _; + +use crate::{error, tasks::TaskHandle}; + +/// Starts running a future concurrently with the caller. +/// +/// # Arguments +/// +/// * `future` - The future to run. +pub(crate) fn spawn(future: F) -> TaskHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + TaskHandle::from_future(tokio::spawn(future).map(|r| r.map_err(join_error))) +} + +/// Starts running a future that may block the thread it runs on (e.g., with +/// synchronous I/O) on a thread of its own, so it doesn't hold up the caller. +/// The returned future yields the task's handle once the caller may go on, +/// which here is right away. +/// +/// This isn't an `async fn`, so the caller's future doesn't have to make room +/// for `future`. +/// +/// # Arguments +/// +/// * `future` - The future to run. +pub(crate) fn spawn_blocking(future: F) -> impl Future> +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + let handle = + tokio::task::spawn_blocking(move || tokio::runtime::Handle::current().block_on(future)); + let task = TaskHandle::from_future(handle.map(|r| r.map_err(join_error))); + std::future::ready(task) +} + +fn join_error(err: tokio::task::JoinError) -> error::Error { + error::ErrorKind::TaskError(Box::new(err)).into() +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use std::time::Duration; + + use super::*; + + /// How long a test task waits for the test to signal it. + const TIMEOUT: Duration = Duration::from_secs(10); + + /// Returns whether `result` is the error for a task that didn't finish. + fn is_task_error(result: &Result) -> bool { + result + .as_ref() + .is_err_and(|err| matches!(err.kind(), error::ErrorKind::TaskError(_))) + } + + /// Unwinds the way a panic does, without printing a panic message. + fn unwind() { + std::panic::resume_unwind(Box::new("task unwound")); + } + + #[tokio::test] + async fn spawn_yields_the_tasks_output() -> Result<(), error::Error> { + assert_eq!(spawn(async { 42 }).await?, 42); + Ok(()) + } + + #[tokio::test] + async fn spawn_yields_a_task_error_if_the_task_panics() { + assert!(is_task_error(&spawn(async { unwind() }).await)); + } + + #[tokio::test] + async fn spawn_blocking_yields_the_tasks_output() -> Result<(), error::Error> { + assert_eq!(spawn_blocking(async { 42 }).await.await?, 42); + Ok(()) + } + + #[tokio::test] + async fn spawn_blocking_yields_a_task_error_if_the_task_panics() { + assert!(is_task_error( + &spawn_blocking(async { unwind() }).await.await + )); + } + + /// A task that blocks its thread mustn't keep the caller from going on: the + /// task here blocks until the caller signals it. + #[tokio::test] + async fn spawn_blocking_does_not_hold_up_the_caller() -> Result<(), error::Error> { + let (sender, receiver) = std::sync::mpsc::channel(); + let task = spawn_blocking(async move { receiver.recv_timeout(TIMEOUT).is_ok() }).await; + + // If the task held up the caller, it has given up by now and dropped the + // receiver, so this fails and so does the assertion below. + let _ = sender.send(()); + assert!(task.await?, "the task should have been signaled"); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix.rs b/userland/brush/source/brush-core/src/sys/unix.rs new file mode 100644 index 0000000..e7e87b0 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix.rs @@ -0,0 +1,30 @@ +pub mod async_pipe; +pub(crate) mod commands; +pub(crate) mod env; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) use crate::sys::os_pipes as pipes; +pub(crate) mod network; +pub mod poll; +use crate::error; +pub use crate::sys::tokio_process as process; +pub mod resource; +pub mod signal; +pub(crate) use crate::sys::tokio_tasks as tasks; +pub mod terminal; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError { + /// A system error occurred. + #[error("system error: {0}")] + ErrnoError(#[from] nix::errno::Errno), +} + +impl From for error::ErrorKind { + fn from(err: nix::errno::Errno) -> Self { + PlatformError::ErrnoError(err).into() + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix/async_pipe.rs b/userland/brush/source/brush-core/src/sys/unix/async_pipe.rs new file mode 100644 index 0000000..f9511cf --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/async_pipe.rs @@ -0,0 +1,23 @@ +//! Async pipe reading utilities for Unix. + +use std::io; +use std::os::unix::io::OwnedFd; + +use tokio::net::unix::pipe; + +pub(crate) struct AsyncPipeReader(pipe::Receiver); + +impl AsyncPipeReader { + pub(crate) fn new(reader: std::io::PipeReader) -> io::Result { + Ok(Self(pipe::Receiver::from_file(std::fs::File::from( + OwnedFd::from(reader), + ))?)) + } + + pub(crate) async fn read_to_string(&mut self) -> io::Result { + use tokio::io::AsyncReadExt; + let mut s = String::new(); + self.0.read_to_string(&mut s).await?; + Ok(s) + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix/commands.rs b/userland/brush/source/brush-core/src/sys/unix/commands.rs new file mode 100644 index 0000000..18f0785 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/commands.rs @@ -0,0 +1,125 @@ +//! Command execution utilities. + +pub use std::os::unix::process::CommandExt; +pub use std::os::unix::process::ExitStatusExt; + +use command_fds::{CommandFdExt, FdMapping}; + +use crate::ShellFd; +use crate::error; +use crate::openfiles; + +/// Replaces the current process with `command`. Returns only if that fails, with +/// the reason why. +pub(crate) fn exec(mut command: std::process::Command) -> std::io::Error { + CommandExt::exec(&mut command) +} + +/// Returns the standard stream for a process to have `file` as: the shell's own +/// output streams as they are, and any other file as a duplicate of its descriptor, +/// so that the shell's stdin can be an output stream of the process's (e.g., for +/// `>&0`), and a custom stream the process's too. +pub(crate) fn stdio_for(file: openfiles::OpenFile) -> std::io::Result { + match file { + openfiles::OpenFile::Stdout(s) => Ok(s.into()), + openfiles::OpenFile::Stderr(s) => Ok(s.into()), + file => Ok(file + .try_clone_to_owned() + .map_err(std::io::Error::other)? + .into()), + } +} + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error> { + let fd_mappings: Vec = open_files + .map(|(child_fd, open_file)| -> Result { + let parent_fd = open_file.try_clone_to_owned()?; + Ok(FdMapping { + child_fd, + parent_fd, + }) + }) + .collect::, _>>()?; + + self.fd_mappings(fd_mappings) + .map_err(|_e| error::ErrorKind::ChildCreationFailure)?; + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); + /// Arranges for the command to become a session leader when it is executed. + fn lead_session(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the context of + // the forked process before it exec's the target command. In general, + // rust can't guarantee safety of code running in such a context. + unsafe { + self.pre_exec(pre_exec_take_foreground); + } + } + + fn lead_session(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the context of + // the forked process before it exec's the target command. In general, + // rust can't guarantee safety of code running in such a context. + unsafe { + self.pre_exec(pre_exec_lead_session); + } + } +} + +fn pre_exec_take_foreground() -> Result<(), std::io::Error> { + use crate::sys; + + sys::terminal::move_self_to_foreground()?; + Ok(()) +} + +fn pre_exec_lead_session() -> Result<(), std::io::Error> { + if let Err(e) = nix::unistd::setsid() { + return Err(std::io::Error::other(format!( + "failed to become session leader: {e}" + ))); + } + + #[cfg(not(target_os = "macos"))] + let control = libc::TIOCSCTTY; + #[cfg(target_os = "macos")] + let control: u64 = libc::TIOCSCTTY.into(); + + // SAFETY: + // This is calling a libc function to set the controlling terminal. + let result = unsafe { libc::ioctl(0, control, 0) }; + if result != 0 { + return Err(std::io::Error::other("failed to set controlling terminal")); + } + + Ok(()) +} diff --git a/userland/brush/source/brush-core/src/sys/unix/env.rs b/userland/brush/source/brush-core/src/sys/unix/env.rs new file mode 100644 index 0000000..33c8524 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/env.rs @@ -0,0 +1,8 @@ +//! Environment variable retrieval for Unix platforms. + +/// Retrieves environment variables from the host process. +/// +/// On Unix, this is a direct passthrough to [`std::env::vars()`]. +pub(crate) fn get_host_env_vars() -> impl Iterator { + std::env::vars() +} diff --git a/userland/brush/source/brush-core/src/sys/unix/fd.rs b/userland/brush/source/brush-core/src/sys/unix/fd.rs new file mode 100644 index 0000000..d53ebec --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/fd.rs @@ -0,0 +1,87 @@ +//! File descriptor utilities. + +use std::os::fd::RawFd; + +use crate::{ShellFd, error, openfiles}; + +cfg_if::cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "android"))] { + const FD_DIR_PATH: &str = "/proc/self/fd"; + } else if #[cfg(any( + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] { + const FD_DIR_PATH: &str = "/dev/fd"; + } else { + /// Returns an iterator over all open file descriptors for the shell. + pub fn iter_fds() + -> Result, error::Error> { + Ok(std::iter::empty()) + } + } +} + +/// Makes a best-effort attempt to iterate over all open file descriptors +/// for the current process. +/// +/// If the platform does not support enumerating file descriptors, an empty iterator +/// is returned. This function will skip any file descriptors that cannot be opened. +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" +))] +pub fn try_iter_open_fds() -> impl Iterator { + std::fs::read_dir(FD_DIR_PATH) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| { + let fd: RawFd = entry.file_name().to_str()?.parse().ok()?; + + // SAFETY: + // We are trying to open the file descriptor we found listed + // in the filesystem, but there's a risk that it's not the same one + // that we enumerated or that it's since been closed. For the purposes + // of this function, either of those outcomes are acceptable. We + // simply skip any fds that we can't open, and the function's purpose + // is to make a best-effort attempt to open all available fds. + let file = unsafe { open_file_by_fd(fd) }.ok()?; + + Some((fd, file)) + }) +} + +/// Attempts to retrieve an `OpenFile` representation for the given already-open file descriptor. +/// +/// If the file descriptor cannot be opened, `None` is returned. Note that there is no guarantee +/// that the returned file matches the original file descriptor, as the fd may have been closed +/// and potentially re-used in the meantime. +/// +/// # Arguments +/// +/// * `fd` - The file descriptor to open. +pub fn try_get_file_for_open_fd(fd: RawFd) -> Option { + // SAFETY: + // We are trying to open the file descriptor provided by the caller. There's a risk that the fd + // is invalid or has been closed since it was enumerated. For the purposes of this function, + // we simply return None if we can't open it. There's also a risk that the fd has been closed + // and re-used for a different file; again, for the purposes of this function, we accept that + // risk and document it as part of the function's contract. + unsafe { open_file_by_fd(fd).ok() } +} + +unsafe fn open_file_by_fd(fd: RawFd) -> Result { + // SAFETY: We are creating a BorrowedFd from a file descriptor. Callers typically + // enumerate available file descriptors from procfs, devfs, or similar, but there's + // still a risk that the fd has become invalid or closed since then -- or that this + // function gets used incorrectly. + let borrowed_fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }; + let owned_fd = borrowed_fd.try_clone_to_owned()?; + Ok(std::fs::File::from(owned_fd).into()) +} diff --git a/userland/brush/source/brush-core/src/sys/unix/fs.rs b/userland/brush/source/brush-core/src/sys/unix/fs.rs new file mode 100644 index 0000000..4aa2f6b --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/fs.rs @@ -0,0 +1,420 @@ +//! Filesystem utilities. + +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::FileTypeExt; +use std::path::{Path, PathBuf}; + +use crate::error; + +pub use std::os::unix::fs::MetadataExt; + +#[cfg(target_os = "android")] +// _PATH_DEFPATH in https://android.googlesource.com/platform/bionic/+/refs/heads/main/libc/include/paths.h +const ANDROID_DEFPATH: &str = "/product/bin:/apex/com.android.runtime/bin:/apex/com.android.art/bin:/apex/com.android.virt/bin:/system_ext/bin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin"; + +impl crate::sys::fs::PathExt for Path { + fn readable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok() + } + + fn writable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok() + } + + fn executable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok() + } + + fn exists_and_is_block_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_block_device()) + } + + fn exists_and_is_char_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_char_device()) + } + + fn exists_and_is_fifo(&self) -> bool { + try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo()) + } + + fn exists_and_is_socket(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_socket()) + } + + fn exists_and_is_setgid(&self) -> bool { + const S_ISGID: u32 = 0o2000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISGID != 0) + } + + fn exists_and_is_setuid(&self) -> bool { + const S_ISUID: u32 = 0o4000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISUID != 0) + } + + fn exists_and_is_sticky_bit(&self) -> bool { + const S_ISVTX: u32 = 0o1000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISVTX != 0) + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + let metadata = self.metadata()?; + Ok((metadata.dev(), metadata.ino())) + } +} + +fn try_get_file_type(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.file_type()).ok() +} + +fn try_get_file_mode(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.mode()).ok() +} + +/// Splits a platform-specific PATH-like value into individual paths. +/// +/// On Unix, this delegates to [`std::env::split_paths`]. +pub fn split_paths + ?Sized>(s: &T) -> std::env::SplitPaths<'_> { + std::env::split_paths(s) +} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + #[cfg(target_os = "android")] + { + std::env::split_paths(ANDROID_DEFPATH).collect() + } + #[cfg(not(target_os = "android"))] + { + // standard hard-coded defaults for executable search path + vec![ + "/usr/local/sbin".into(), + "/usr/local/bin".into(), + "/usr/sbin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/bin".into(), + ] + } +} + +/// Retrieves the platform-specific set of paths that should contain standard system +/// utilities. Used by `command -p`, for example. +pub fn get_default_standard_utils_paths() -> Vec { + // + // Try to call confstr(_CS_PATH). If that fails, can't find a string value, or + // finds an empty string, then we'll fall back to hard-coded defaults. + // + + if let Ok(Some(cs_path)) = confstr_cs_path() + && !cs_path.as_os_str().is_empty() + { + return split_paths(&cs_path).collect(); + } + + #[cfg(target_os = "android")] + { + std::env::split_paths(ANDROID_DEFPATH).collect() + } + #[cfg(not(target_os = "android"))] + { + // standard hard-coded defaults + vec![ + "/bin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/usr/sbin".into(), + "/etc".into(), + "/usr/etc".into(), + ] + } +} + +#[allow(clippy::unnecessary_wraps)] +fn confstr_cs_path() -> Result, std::io::Error> { + #[cfg(target_os = "android")] + { + Ok(Some(PathBuf::from(ANDROID_DEFPATH))) + } + #[cfg(not(target_os = "android"))] + { + let value = confstr(nix::libc::_CS_PATH)?; + + if let Some(value) = value { + let value_str = PathBuf::from(value); + Ok(Some(value_str)) + } else { + Ok(None) + } + } +} + +/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which +/// indicates where all the POSIX.2 standard utilities can be found. +/// +/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix +/// or similar. Until that exists, we accept the need to make the unsafe call directly. +#[cfg(not(target_os = "android"))] +fn confstr(name: nix::libc::c_int) -> Result, std::io::Error> { + // SAFETY: + // Calling `confstr` with a null pointer and size 0 is a documented way to query + // the required size of the buffer to hold the value associated with `name`. It + // should not end up causing any undefined behavior. + let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) }; + + // When confstr returns 0, it either means there's no value associated with _CS_PATH, or + // _CS_PATH is considered invalid (and not present) on this platform. In both cases, we + // treat it as a non-existent value and return None. + if required_size == 0 { + return Ok(None); + } + + let mut buffer = Vec::::with_capacity(required_size); + + // SAFETY: + // We are calling `confstr` with a valid pointer and size that we obtained from the + // allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into + // `Vec` is fine, as i8 and u8 have compatible representations, and Rust does + // not support platforms where `c_char` is not 8-bit wide. + let final_size = + unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) }; + + if final_size == 0 { + return Err(std::io::Error::last_os_error()); + } + + // Per the docs on `confstr`, it *may* return a size larger than the provided buffer. + // In our usage we wouldn't expect to see this, as we've first queried the required size. + // However, we defensively check for this case and return an error if it happens. + if final_size > buffer.capacity() { + return Err(std::io::Error::other( + "confstr needed more space than advertised", + )); + } + + // SAFETY: + // We are trusting `confstr` to have written exactly `final_size` bytes into the buffer. + // We have checked above that it didn't return a value *larger* than the capacity of + // the buffer, and also checked for known error cases. Note that the returned length + // should include the null terminator. + unsafe { buffer.set_len(final_size) }; + + // The last byte is a null terminator. We assert that it is. + if !matches!(buffer.pop(), Some(0)) { + return Err(std::io::Error::other( + "confstr did not null-terminate the returned string", + )); + } + + Ok(Some(std::ffi::OsString::from_vec(buffer))) +} + +/// Opens a null file that will discard all I/O. +pub fn open_null_file() -> Result { + let f = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/null")?; + + Ok(f) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +pub const fn try_open_special_file(_path: &Path) -> Option> { + None +} + +/// Returns the path to the system-wide shell profile script. +pub fn get_system_profile_path() -> Option<&'static Path> { + Some(Path::new("/etc/profile")) +} + +/// Returns the path to the system-wide shell rc script. +pub fn get_system_rc_path() -> Option<&'static Path> { + Some(Path::new("/etc/bash.bashrc")) +} + +/// Returns true if the string contains a path separator character. +/// +/// On Unix, only `/` is considered a path separator. +pub fn contains_path_separator(s: &str) -> bool { + s.contains('/') +} + +/// Returns true if the string ends with a path separator character. +/// +/// On Unix, only `/` is considered a path separator. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with('/') +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// On Unix, only `/` is considered a path separator. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix('/').unwrap_or(s) +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// On Unix, filesystems are typically case-sensitive, so this returns `false`. +pub const fn default_case_insensitive_path_expansion() -> bool { + false +} + +/// Finds the byte index of the last path separator in the string. +/// +/// On Unix, only `/` is considered a path separator. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind('/') +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// On Unix, only `/` is used as a separator. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split('/') +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// On Unix, an empty first component (from splitting a path like `/foo`) indicates +/// an absolute path rooted at `/`. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + Some(PathBuf::from("/")) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// On Unix, this delegates directly to `PathBuf::push`. +pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) { + path.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// On Unix, this is a no-op since paths already use `/`. +pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(s) +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// On Unix this is a straight passthrough: if the path is executable, the +/// path is returned unchanged (no clone). This keeps `pathsearch::next` +/// allocation-free on the happy path. +/// +/// On Windows this function may append a `PATHEXT` extension and return a +/// possibly-different `PathBuf`. +pub fn resolve_executable(path: PathBuf) -> Option { + use crate::sys::fs::PathExt; + if path.as_path().executable() { + Some(path) + } else { + None + } +} + +/// Resolves a path to a potential executable. +/// +/// Unix does not need PATHEXT resolution, so the path is left unchanged. +pub(crate) const fn resolve_executable_path(path: PathBuf, _working_dir: &Path) -> PathBuf { + path +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_separator_helpers() { + assert!(contains_path_separator("foo/bar")); + assert!(!contains_path_separator("foobar")); + // Backslashes are not separators on Unix. + assert!(!contains_path_separator(r"foo\bar")); + + assert!(ends_with_path_separator("foo/")); + assert!(!ends_with_path_separator("foo")); + assert!(!ends_with_path_separator(r"foo\")); + + assert_eq!(strip_path_separator_suffix("foo/"), "foo"); + assert_eq!(strip_path_separator_suffix("foo"), "foo"); + assert_eq!(strip_path_separator_suffix(r"foo\"), r"foo\"); + + assert_eq!(rfind_path_separator("a/b/c"), Some(3)); + assert_eq!(rfind_path_separator("abc"), None); + } + + #[test] + fn split_path_for_pattern_basic() { + let parts: Vec<_> = split_path_for_pattern("a/b/c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern("/a/b").collect(); + assert_eq!(parts, vec!["", "a", "b"]); + + // Backslashes are not split on Unix. + let parts: Vec<_> = split_path_for_pattern(r"a\b").collect(); + assert_eq!(parts, vec![r"a\b"]); + } + + #[test] + fn pattern_path_root_absolute() { + assert_eq!(pattern_path_root(""), Some(PathBuf::from("/"))); + } + + #[test] + fn pattern_path_root_relative() { + assert_eq!(pattern_path_root("foo"), None); + // Drive-letter syntax is not recognized on Unix. + assert_eq!(pattern_path_root("c:"), None); + } + + #[test] + fn push_path_for_pattern_appends_child() { + let mut p = PathBuf::from("/home/reuben"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("/home/reuben/foo")); + } + + #[test] + fn normalize_path_separators_is_noop() { + use std::borrow::Cow; + assert!(matches!( + normalize_path_separators("/foo/bar"), + Cow::Borrowed("/foo/bar") + )); + } + + #[test] + fn default_case_insensitive_is_false() { + assert!(!default_case_insensitive_path_expansion()); + } + + #[test] + fn resolve_executable_returns_input_unchanged() { + // /bin/sh exists and is executable on every supported Unix host. + let path = PathBuf::from("/bin/sh"); + let resolved = resolve_executable(path.clone()); + assert_eq!(resolved.as_deref(), Some(path.as_path())); + } + + #[test] + fn resolve_executable_returns_none_for_nonexistent() { + let path = PathBuf::from("/this/path/should/not/exist/brush-test"); + assert!(resolve_executable(path).is_none()); + } + + #[test] + fn resolve_executable_returns_none_for_non_executable() { + // /etc/hostname (or similar) is a regular file but not executable. + // Use /etc/passwd which is universally present and not executable. + let path = PathBuf::from("/etc/passwd"); + assert!(resolve_executable(path).is_none()); + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix/input.rs b/userland/brush/source/brush-core/src/sys/unix/input.rs new file mode 100644 index 0000000..d0dcfdc --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/input.rs @@ -0,0 +1,84 @@ +//! Terminal input utilities + +use std::collections::HashMap; +use std::sync::LazyLock; +use terminfo::capability as cap; + +use crate::{error, interfaces}; + +macro_rules! key { + ( $terminfo:expr , $our_key:expr, $terminfo_key:ty ) => {{ + ( + $our_key, + $terminfo + .get::<$terminfo_key>() + .map(|k| k.expand().to_vec()), + ) + }}; +} + +fn build_terminfo_key_map() -> HashMap, interfaces::Key> { + let mut map: HashMap, interfaces::Key> = HashMap::new(); + + if let Ok(ti) = terminfo::Database::from_env() { + // Iterate over key capabilities and populate the map + let key_capabilities = [ + key!(ti, interfaces::Key::F(1), cap::KeyF1<'_>), + key!(ti, interfaces::Key::F(2), cap::KeyF2<'_>), + key!(ti, interfaces::Key::F(3), cap::KeyF3<'_>), + key!(ti, interfaces::Key::F(4), cap::KeyF4<'_>), + key!(ti, interfaces::Key::F(5), cap::KeyF5<'_>), + key!(ti, interfaces::Key::F(6), cap::KeyF6<'_>), + key!(ti, interfaces::Key::F(7), cap::KeyF7<'_>), + key!(ti, interfaces::Key::F(8), cap::KeyF8<'_>), + key!(ti, interfaces::Key::F(9), cap::KeyF9<'_>), + key!(ti, interfaces::Key::F(10), cap::KeyF10<'_>), + key!(ti, interfaces::Key::F(11), cap::KeyF11<'_>), + key!(ti, interfaces::Key::F(12), cap::KeyF12<'_>), + key!(ti, interfaces::Key::Backspace, cap::KeyBackspace<'_>), + key!(ti, interfaces::Key::Enter, cap::KeyEnter<'_>), + key!(ti, interfaces::Key::Left, cap::KeyLeft<'_>), + key!(ti, interfaces::Key::Right, cap::KeyRight<'_>), + key!(ti, interfaces::Key::Up, cap::KeyUp<'_>), + key!(ti, interfaces::Key::Down, cap::KeyDown<'_>), + key!(ti, interfaces::Key::Home, cap::KeyHome<'_>), + key!(ti, interfaces::Key::End, cap::KeyEnd<'_>), + key!(ti, interfaces::Key::PageUp, cap::KeyPPage<'_>), + key!(ti, interfaces::Key::PageDown, cap::KeyNPage<'_>), + key!(ti, interfaces::Key::BackTab, cap::BackTab<'_>), + // It's not clear if these belong here, because they're not + // strictly "key" capabilities. + key!(ti, interfaces::Key::Up, cap::CursorUp<'_>), + key!(ti, interfaces::Key::Down, cap::CursorDown<'_>), + key!(ti, interfaces::Key::Left, cap::CursorLeft<'_>), + key!(ti, interfaces::Key::Right, cap::CursorRight<'_>), + ]; + + for (key, v) in key_capabilities { + if let Some(Ok(v)) = v { + map.insert(v.clone(), key.clone()); + } + } + } + + map +} + +pub(crate) static TERMINFO_KEY_MAP: LazyLock, interfaces::Key>> = + LazyLock::new(build_terminfo_key_map); + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// # Arguments +/// +/// * `key_code`: The byte sequence representing the key code. +pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option { + if let Some(key) = TERMINFO_KEY_MAP.get(key_code) { + Some(key.clone()) + } else if key_code.len() == 1 && !key_code[0].is_ascii_control() { + Some(interfaces::Key::Character(key_code[0] as char)) + } else { + None + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix/network.rs b/userland/brush/source/brush-core/src/sys/unix/network.rs new file mode 100644 index 0000000..b2ae829 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/userland/brush/source/brush-core/src/sys/unix/poll.rs b/userland/brush/source/brush-core/src/sys/unix/poll.rs new file mode 100644 index 0000000..54506f9 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/poll.rs @@ -0,0 +1,143 @@ +//! File descriptor polling utilities for timeout support. + +use std::os::fd::BorrowedFd; +use std::time::{Duration, Instant}; + +use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; + +use crate::openfiles::OpenFile; + +/// Polls an open file for input readability with a timeout. +/// +/// Returns `Ok(true)` if data is available for reading, `Ok(false)` if the timeout +/// elapsed without data becoming available. +/// +/// For regular files, always returns `Ok(true)` immediately since they're always +/// "ready" (matching bash behavior where `-t` has no effect on regular files). +/// +/// # Arguments +/// +/// * `file` - The open file to poll. +/// * `timeout` - Maximum time to wait. Use `Duration::ZERO` to check without blocking. +/// +/// # Errors +/// +/// Returns an error if polling fails or the file descriptor cannot be borrowed. +pub fn poll_for_input(file: &OpenFile, timeout: Duration) -> std::io::Result { + let fd = file + .try_borrow_as_fd() + .map_err(|e| std::io::Error::other(e.to_string()))?; + + // Regular files are always ready - timeout has no effect (bash behavior). + if is_regular_file(fd) { + return Ok(true); + } + + // Convert timeout to deadline for accurate time tracking across EINTR retries. + let deadline = if timeout.is_zero() { + // For zero timeout, use current instant so first check sees zero remaining. + Some(Instant::now()) + } else { + Some(Instant::now() + timeout) + }; + + poll_fd_for_input(fd, deadline) +} + +/// Polls a file descriptor for input readability with a deadline. +/// +/// Returns `Ok(true)` if data is available, `Ok(false)` if deadline passed. +/// +/// # Arguments +/// +/// * `fd` - File descriptor to poll +/// * `deadline` - Optional deadline; `None` indicates no deadline. +fn poll_fd_for_input(fd: BorrowedFd<'_>, deadline: Option) -> std::io::Result { + let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)]; + let mut first_iteration = true; + + loop { + // Calculate remaining time on each iteration to handle EINTR correctly. + let timeout_ms = match deadline { + Some(d) => { + let remaining = d.saturating_duration_since(Instant::now()); + // On first iteration, always do at least one poll even with zero timeout. + // This allows `-t 0` to check if input is immediately available. + if remaining.is_zero() && !first_iteration { + return Ok(false); // Deadline passed after initial poll. + } + i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX) + } + None => -1, // Block indefinitely. + }; + first_iteration = false; + let poll_timeout = PollTimeout::try_from(timeout_ms).unwrap_or(PollTimeout::MAX); + + match poll(&mut poll_fds, poll_timeout) { + Ok(0) => return Ok(false), // Timeout + Ok(_) => { + let revents = poll_fds[0].revents().unwrap_or(PollFlags::empty()); + // POLLIN means data available. POLLHUP/POLLERR without POLLIN means + // EOF/error - return true so caller reads and gets the proper result. + return Ok( + revents.intersects(PollFlags::POLLIN | PollFlags::POLLHUP | PollFlags::POLLERR) + ); + } + Err(nix::errno::Errno::EINTR) => (), // Retry on signal with recalculated timeout. + Err(e) => return Err(std::io::Error::from_raw_os_error(e as i32)), + } + } +} + +/// Checks if a file descriptor refers to a regular file. +/// +/// Regular files are always "ready" for reading (poll has no effect). +/// +/// # Arguments +/// +/// * `fd` - File descriptor to check +fn is_regular_file(fd: BorrowedFd<'_>) -> bool { + match nix::sys::stat::fstat(fd) { + Ok(stat) => { + use nix::sys::stat::{SFlag, mode_t}; + // By the whole file type: a socket's, for one, has the regular file's bit set. + mode_t::try_from(stat.st_mode).is_ok_and(|mode| { + (SFlag::from_bits_truncate(mode) & SFlag::S_IFMT) == SFlag::S_IFREG + }) + } + Err(_) => false, + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use std::os::fd::AsFd as _; + + use super::*; + + /// Only a regular file is one: a socket's file type has the regular file's bit set among + /// others, and a read from one must poll, not take the input to be ready. + #[test] + fn only_a_regular_file_is_regular() -> std::io::Result<()> { + let file = tempfile::tempfile()?; + assert!(is_regular_file(file.as_fd())); + + let (socket, _other_end) = std::os::unix::net::UnixStream::pair()?; + assert!(!is_regular_file(socket.as_fd())); + + let (reader, _writer) = std::io::pipe()?; + assert!(!is_regular_file(reader.as_fd())); + Ok(()) + } + + /// Polling a socket with nothing to read waits out the timeout, as `read -t` from one does. + #[test] + fn a_socket_without_input_times_out() -> std::io::Result<()> { + let (socket, _other_end) = std::os::unix::net::UnixStream::pair()?; + let socket: OpenFile = std::fs::File::from(std::os::fd::OwnedFd::from(socket)).into(); + + assert!(!poll_for_input(&socket, Duration::from_millis(50))?); + Ok(()) + } +} diff --git a/userland/brush/source/brush-core/src/sys/unix/resource.rs b/userland/brush/source/brush-core/src/sys/unix/resource.rs new file mode 100644 index 0000000..7b9b8a2 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/resource.rs @@ -0,0 +1,31 @@ +//! Resource utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process; +/// expressed as a tuple containing user time and system time, in that order. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_SELF)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +/// Returns the user and system CPU time used by child processes; expressed +/// as a tuple containing user time and system time, in that order. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_CHILDREN)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +const fn convert_rusage_time(time: nix::sys::time::TimeVal) -> std::time::Duration { + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + std::time::Duration::new(time.tv_sec() as u64, time.tv_usec() as u32 * 1000) +} diff --git a/userland/brush/source/brush-core/src/sys/unix/signal.rs b/userland/brush/source/brush-core/src/sys/unix/signal.rs new file mode 100644 index 0000000..7986df4 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/signal.rs @@ -0,0 +1,303 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +pub(crate) use nix::sys::signal::Signal; + +pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + Ok(()) +} + +/// Continues every process in a process group. +/// +/// # Arguments +/// * `pgid` - The process group's ID. +pub(crate) fn continue_process_group(pgid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pgid), nix::sys::signal::SIGCONT) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + Ok(()) +} + +/// Sends a signal to a specific process. +/// +/// # Arguments +/// * `pid` - The process ID to send the signal to +/// * `signal` - The signal to send (must be a real signal, not a trap signal) +pub fn kill_process( + pid: sys::process::ProcessId, + signal: traps::TrapSignal, +) -> Result<(), error::Error> { + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), real_signal(signal)?) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + + Ok(()) +} + +/// Sends a signal to every process in a process group. +/// +/// # Arguments +/// * `pgid` - The process group's ID. +/// * `signal` - The signal to send (must be a real signal, not a trap signal) +pub(crate) fn kill_process_group( + pgid: sys::process::ProcessId, + signal: traps::TrapSignal, +) -> Result<(), error::Error> { + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pgid), real_signal(signal)?) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + + Ok(()) +} + +/// Returns whether a stopped process sent the given signal must be continued for it to act on +/// it, as bash continues a stopped job sent SIGTERM or SIGHUP: some systems leave those pending +/// until then. +pub(crate) const fn continue_stopped_on(signal: traps::TrapSignal) -> bool { + matches!( + signal, + traps::TrapSignal::Signal(Signal::SIGTERM | Signal::SIGHUP) + ) +} + +fn real_signal(signal: traps::TrapSignal) -> Result { + match signal { + traps::TrapSignal::Signal(signal) => Ok(signal), + traps::TrapSignal::Debug + | traps::TrapSignal::Err + | traps::TrapSignal::Exit + | traps::TrapSignal::Return => { + Err(error::ErrorKind::InvalidSignal(signal.to_string()).into()) + } + } +} + +/// Checks whether a specific process exists and can be signaled. +/// +/// # Arguments +/// * `pid` - The process ID to signal-zero check. +pub fn check_signalable(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).map_err(signal_error)?; + Ok(()) +} + +/// Checks whether a process group exists and can be signaled. +/// +/// # Arguments +/// * `pgid` - The process group's ID. +pub(crate) fn check_process_group_signalable( + pgid: sys::process::ProcessId, +) -> Result<(), error::Error> { + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pgid), None).map_err(signal_error)?; + Ok(()) +} + +const fn signal_error(errno: nix::errno::Errno) -> error::ErrorKind { + match errno { + nix::errno::Errno::ESRCH => error::ErrorKind::NoSuchProcess, + nix::errno::Errno::EPERM => error::ErrorKind::PermissionDenied, + _ => error::ErrorKind::FailedToSendSignal, + } +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))?; + Ok(()) +} + +pub(crate) fn tstp_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw( + nix::libc::SIGTSTP, + ))?; + Ok(signal) +} + +pub(crate) fn chld_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child())?; + Ok(signal) +} + +pub use tokio::signal::ctrl_c as await_ctrl_c; + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + let ignore = nix::sys::signal::SigAction::new( + nix::sys::signal::SigHandler::SigIgn, + nix::sys::signal::SaFlags::empty(), + nix::sys::signal::SigSet::empty(), + ); + + // SAFETY: + // Setting the signal action should be safe here. The unsafe concerns + // for calling `sigaction` are primarily around ensuring that any provided + // signal handler functions are only performing operations that are + // safe to do in a signal handler context. Here we are not providing + // a custom handler, just asking the OS to ignore the signal. + unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGTTOU, &ignore) }?; + + Ok(()) +} + +/// Returns whether the given child process has stopped, consuming its stop status, and leaving +/// other children's stops to whoever waits on them. On NetBSD and OpenBSD, where one child +/// can't be looked at (see `waitid_one`), any child's stop counts, as before. +/// +/// # Arguments +/// +/// * `pid` - The child's process ID. +pub(crate) fn poll_for_stopped_children( + pid: sys::process::ProcessId, +) -> Result { + let mut found_stopped = false; + + loop { + let wait_status = waitid_for( + pid, + nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG, + ); + match wait_status { + Ok(nix::sys::wait::WaitStatus::Stopped(_stopped_pid, _signal)) => { + found_stopped = true; + } + Ok(_) => break, + Err(nix::errno::Errno::ECHILD) => break, + Err(e) => return Err(e.into()), + } + } + + Ok(found_stopped) +} + +/// Returns whether the given child process has stopped since that was last +/// reported. Doesn't reap the child if it has exited. +/// +/// # Arguments +/// +/// * `pid` - The child's process ID. +pub(crate) fn poll_for_stopped_child(pid: sys::process::ProcessId) -> Result { + let flags = nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG; + match waitid_one(pid, flags) { + Ok(nix::sys::wait::WaitStatus::Stopped(..)) => Ok(true), + Ok(_) | Err(nix::errno::Errno::ECHILD) => Ok(false), + Err(e) => Err(e.into()), + } +} + +#[cfg(not(any(target_os = "netbsd", target_os = "openbsd")))] +fn waitid_for( + pid: sys::process::ProcessId, + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + waitid_one(pid, flags) +} + +// There's no looking at one child on NetBSD/OpenBSD (see `waitid_one`), so look at any +// child, as before. +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +fn waitid_for( + _pid: sys::process::ProcessId, + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + waitid_all(flags) +} + +#[cfg(not(any(target_os = "macos", target_os = "netbsd", target_os = "openbsd")))] +fn waitid_one( + pid: sys::process::ProcessId, + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + nix::sys::wait::waitid( + nix::sys::wait::Id::Pid(nix::unistd::Pid::from_raw(pid)), + flags, + ) +} + +// nix does not expose `waitid` on NetBSD/OpenBSD, and `waitpid` would reap a child +// that has exited, so don't look. +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +#[allow(clippy::unnecessary_wraps, reason = "matches other platforms")] +const fn waitid_one( + _pid: sys::process::ProcessId, + _flags: nix::sys::wait::WaitPidFlag, +) -> Result { + Ok(nix::sys::wait::WaitStatus::StillAlive) +} + +// nix does not expose `waitid` on NetBSD/OpenBSD; `waitpid` for any child is +// equivalent for the flags used here. +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + nix::sys::wait::waitpid(None, Some(flags)) +} + +// +// N.B. These functions were mostly copied from nix::sys::wait (https://github.com/nix-rust/nix, MIT license) +// to enable use of the `waitid` call on macOS. Ideally nix would expose it on macOS and we would +// remove this code. +// + +#[cfg(target_os = "macos")] +fn waitid_one( + pid: sys::process::ProcessId, + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + #[expect(clippy::cast_sign_loss, reason = "process IDs are positive")] + waitid_macos(nix::libc::P_PID, pid as nix::libc::id_t, flags) +} + +#[cfg(target_os = "macos")] +fn waitid_macos( + idtype: nix::libc::idtype_t, + id: nix::libc::id_t, + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + // The siginfo structure is valid when filled with zeroes. Memory is zeroed + // rather than uninitialized, as not all platforms initialize the memory in + // the StillAlive case. + let mut siginfo: nix::libc::siginfo_t = unsafe { std::mem::zeroed() }; + + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + nix::errno::Errno::result(unsafe { + nix::libc::waitid(idtype, id, &raw mut siginfo, flags.bits()) + })?; + + siginfo_to_wait_status(siginfo) +} + +#[cfg(target_os = "macos")] +fn siginfo_to_wait_status( + siginfo: nix::libc::siginfo_t, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_pid = unsafe { siginfo.si_pid() }; + if si_pid == 0 { + return Ok(nix::sys::wait::WaitStatus::StillAlive); + } + + let pid = nix::unistd::Pid::from_raw(si_pid); + + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_status = unsafe { siginfo.si_status() }; + + let status = match siginfo.si_code { + nix::libc::CLD_EXITED => nix::sys::wait::WaitStatus::Exited(pid, si_status), + nix::libc::CLD_KILLED | nix::libc::CLD_DUMPED => nix::sys::wait::WaitStatus::Signaled( + pid, + nix::sys::signal::Signal::try_from(si_status)?, + siginfo.si_code == nix::libc::CLD_DUMPED, + ), + nix::libc::CLD_STOPPED => { + nix::sys::wait::WaitStatus::Stopped(pid, nix::sys::signal::Signal::try_from(si_status)?) + } + nix::libc::CLD_CONTINUED => nix::sys::wait::WaitStatus::Continued(pid), + _ => return Err(nix::errno::Errno::EINVAL), + }; + + Ok(status) +} diff --git a/userland/brush/source/brush-core/src/sys/unix/terminal.rs b/userland/brush/source/brush-core/src/sys/unix/terminal.rs new file mode 100644 index 0000000..887e136 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/terminal.rs @@ -0,0 +1,135 @@ +//! Terminal utilities. + +use crate::{error, openfiles, sys, terminal}; +use std::{io::IsTerminal, os::fd::AsFd, path::PathBuf}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config { + termios: nix::sys::termios::Termios, +} + +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `file` - A reference to the open terminal. + pub fn from_term(file: &openfiles::OpenFile) -> Result { + let fd = file.try_borrow_as_fd()?; + let termios = nix::sys::termios::tcgetattr(fd)?; + Ok(Self { termios }) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `file` - A reference to the open terminal. + pub fn apply_to_term(&self, file: &openfiles::OpenFile) -> Result<(), error::Error> { + let fd = file.try_borrow_as_fd()?; + nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.termios)?; + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, settings: &terminal::Settings) { + if settings.raw_input { + nix::sys::termios::cfmakeraw(&mut self.termios); + } + + if let Some(echo_input) = &settings.echo_input { + if *echo_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ECHO; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ECHO; + } + } + + if let Some(line_input) = &settings.line_input { + if *line_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ICANON; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ICANON; + } + } + + if let Some(interrupt_signals) = &settings.interrupt_signals { + if *interrupt_signals { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ISIG; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ISIG; + } + } + + if let Some(output_nl_as_nlcr) = &settings.output_nl_as_nlcr { + if *output_nl_as_nlcr { + self.termios.output_flags |= + nix::sys::termios::OutputFlags::OPOST | nix::sys::termios::OutputFlags::ONLCR; + } else { + self.termios.output_flags -= nix::sys::termios::OutputFlags::ONLCR; + } + } + } +} + +/// Get the process ID of this process's parent. +pub fn get_parent_process_id() -> Option { + Some(nix::unistd::getppid().as_raw()) +} + +/// Get the process group ID for this process's process group. +pub fn get_process_group_id() -> Option { + Some(nix::unistd::getpgrp().as_raw()) +} + +/// Get the foreground process ID of the attached terminal. +pub fn get_foreground_pid() -> Option { + nix::unistd::tcgetpgrp(std::io::stdin()) + .ok() + .map(|pgid| pgid.as_raw()) +} + +/// Move the specified process to the foreground of the attached terminal. +pub fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::unistd::tcsetpgrp(std::io::stdin(), nix::unistd::Pid::from_raw(pid))?; + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +// This function needs to return `std::io::Error` so that the OS error code can be recovered. +pub fn move_self_to_foreground() -> Result<(), std::io::Error> { + if std::io::stdin().is_terminal() { + let pgid = nix::unistd::getpgid(None)?; + + // TODO(jobs): This sometimes fails with ENOTTY even though we checked that stdin is a + // terminal. We should investigate why this is happening. + let _ = nix::unistd::tcsetpgrp(std::io::stdin(), pgid); + } + + Ok(()) +} + +/// Tries to get the path of the terminal device associated with the attached terminal. +/// Returns `None` if there is no terminal attached or the lookup failed. +pub fn try_get_terminal_device_path() -> Option { + nix::unistd::ttyname(std::io::stdin()).ok() +} + +/// Returns the width, in columns, of the terminal associated with the given file. +/// Returns `None` if it isn't a terminal or doesn't report a width. +/// +/// # Arguments +/// +/// * `file` - A reference to the open terminal. +pub fn get_columns(file: &openfiles::OpenFile) -> Option { + let fd = file.try_borrow_as_fd().ok()?; + let (terminal_size::Width(columns), _) = terminal_size::terminal_size_of(fd)?; + Some(usize::from(columns)) +} diff --git a/userland/brush/source/brush-core/src/sys/unix/users.rs b/userland/brush/source/brush-core/src/sys/unix/users.rs new file mode 100644 index 0000000..74d1437 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/unix/users.rs @@ -0,0 +1,90 @@ +use crate::{error, trace_categories}; +use std::path::PathBuf; + +use uzers::os::unix::UserExt; + +pub(crate) fn is_root() -> bool { + uzers::get_current_uid() == 0 +} + +pub(crate) fn get_user_home_dir(username: &str) -> Option { + if let Some(user_info) = uzers::get_user_by_name(username) { + return Some(user_info.home_dir().to_path_buf()); + } + + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + if let Some(username) = uzers::get_current_username() + && let Some(user_info) = uzers::get_user_by_name(&username) + { + return Some(user_info.home_dir().to_path_buf()); + } + + None +} + +pub(crate) fn get_current_user_default_shell() -> Option { + if let Some(username) = uzers::get_current_username() + && let Some(user_info) = uzers::get_user_by_name(&username) + { + return Some(user_info.shell().to_path_buf()); + } + + None +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_uid() -> Result { + Ok(uzers::get_current_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_gid() -> Result { + Ok(uzers::get_current_gid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_uid() -> Result { + Ok(uzers::get_effective_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_gid() -> Result { + Ok(uzers::get_effective_gid()) +} + +pub(crate) fn get_current_username() -> Result { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + Ok(username.to_string_lossy().to_string()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + let groups = get_current_user_groups()?; + Ok(groups.into_iter().map(|g| g.gid()).collect()) +} + +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO(#475): uzers::all_users() is available but unsafe; for now we just return the current + // user. That's better than nothing. + let user = get_current_username()?; + Ok(vec![user]) +} + +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO(#475): uzers::all_groups() is available but unsafe; for now we just return the current + // user's groups. That's better than nothing. + let groups = get_current_user_groups()?; + let group_names = groups + .into_iter() + .map(|g| g.name().to_string_lossy().to_string()); + Ok(group_names.collect()) +} + +fn get_current_user_groups() -> Result, error::Error> { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + let gid = uzers::get_current_gid(); + let groups = uzers::get_user_groups(&username, gid).unwrap_or_default(); + Ok(groups) +} diff --git a/userland/brush/source/brush-core/src/sys/wasm/async_pipe.rs b/userland/brush/source/brush-core/src/sys/wasm/async_pipe.rs new file mode 100644 index 0000000..0131b9a --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/async_pipe.rs @@ -0,0 +1,37 @@ +//! Async pipe reading utilities for WASM. + +use std::io::{self, Read}; + +use super::pipes::PipeReader; + +pub(crate) struct AsyncPipeReader { + inner: PipeReader, +} + +impl AsyncPipeReader { + #[allow( + clippy::unnecessary_wraps, + clippy::missing_const_for_fn, + reason = "matches the signature on other platforms" + )] + pub(crate) fn new(reader: PipeReader) -> io::Result { + Ok(Self { inner: reader }) + } + + /// Reads until all writers have closed the pipe, yielding to other tasks + /// (e.g., the one writing to the pipe) whenever the pipe is empty. + pub(crate) async fn read_to_string(&mut self) -> io::Result { + let mut bytes = vec![]; + loop { + match self.inner.read_to_end(&mut bytes) { + Ok(_) => break, + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + tokio::task::yield_now().await; + } + Err(err) => return Err(err), + } + } + + String::from_utf8(bytes).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } +} diff --git a/userland/brush/source/brush-core/src/sys/wasm/env.rs b/userland/brush/source/brush-core/src/sys/wasm/env.rs new file mode 100644 index 0000000..aad0ebf --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/env.rs @@ -0,0 +1,10 @@ +//! Environment variable retrieval for wasm platforms. + +/// Retrieves environment variables from the host process. +/// +/// A direct passthrough to [`std::env::vars()`]: WASI hosts supply the +/// environment, while targets without one (e.g., `wasm32-unknown-unknown`) +/// yield no variables. +pub(crate) fn get_host_env_vars() -> impl Iterator { + std::env::vars() +} diff --git a/userland/brush/source/brush-core/src/sys/wasm/fs.rs b/userland/brush/source/brush-core/src/sys/wasm/fs.rs new file mode 100644 index 0000000..b1458fe --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/fs.rs @@ -0,0 +1,68 @@ +//! Filesystem utilities for WASM. + +pub use crate::sys::stubs::fs::*; + +// WASI has no permissions to check, but a path must at least exist. +impl crate::sys::fs::PathExt for std::path::Path { + fn readable(&self) -> bool { + self.exists() + } + + fn writable(&self) -> bool { + self.exists() + } + + fn executable(&self) -> bool { + self.exists() + } + + fn exists_and_is_block_device(&self) -> bool { + false + } + + fn exists_and_is_char_device(&self) -> bool { + false + } + + fn exists_and_is_fifo(&self) -> bool { + false + } + + fn exists_and_is_socket(&self) -> bool { + false + } + + fn exists_and_is_setgid(&self) -> bool { + false + } + + fn exists_and_is_setuid(&self) -> bool { + false + } + + fn exists_and_is_sticky_bit(&self) -> bool { + false + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + Ok((0, 0)) + } +} + +/// Splits a PATH-like value into individual paths. +/// +/// On WASM, `std::env::split_paths` is not available, so this +/// implementation splits by the `:` separator. +pub fn split_paths + ?Sized>( + s: &T, +) -> impl Iterator { + s.as_ref() + .to_str() + .unwrap_or_default() + .split(':') + .map(std::path::PathBuf::from) + .collect::>() + .into_iter() +} + +include!("sausage-lookup.rs"); diff --git a/userland/brush/source/brush-core/src/sys/wasm/mod.rs b/userland/brush/source/brush-core/src/sys/wasm/mod.rs new file mode 100644 index 0000000..ac611fd --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/mod.rs @@ -0,0 +1,18 @@ +pub mod async_pipe; +pub(crate) mod env; +pub use crate::sys::stubs::fd; +pub(crate) mod fs; +pub use crate::sys::stubs::input; +pub(crate) use crate::sys::stubs::network; +pub(crate) mod pipes; +pub use crate::sys::stubs::poll; +pub mod process; +pub use crate::sys::stubs::resource; +pub use crate::sys::stubs::signal; +pub(crate) mod tasks; +pub use crate::sys::stubs::terminal; +pub(crate) use crate::sys::stubs::users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/userland/brush/source/brush-core/src/sys/wasm/pipes.rs b/userland/brush/source/brush-core/src/sys/wasm/pipes.rs new file mode 100644 index 0000000..cf21f39 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/pipes.rs @@ -0,0 +1,203 @@ +//! In-memory pipes for WASM. +//! +//! WASM targets have no OS pipes, and the shell's execution there is +//! single-threaded and cooperative. These pipes buffer everything written to +//! them (without bound) until it's read. Once every read end is gone, writes +//! fail with [`std::io::ErrorKind::BrokenPipe`], as they would on an OS pipe, +//! so a writer whose reader has stopped (e.g., `yes` in `yes | head`) stops +//! too rather than filling memory. A read from an empty pipe never +//! blocks: once all writers are gone it reports end-of-file, and while a +//! writer remains it fails with [`std::io::ErrorKind::WouldBlock`], as a +//! nonblocking pipe would, since nothing else could run to fill the pipe while +//! the reader blocked. Callers that can yield to other tasks (see +//! [`super::async_pipe`]) instead wait for writers to finish. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use crate::openfiles::{OpenFile, Stream}; + +#[derive(Default)] +struct PipeState { + buffer: VecDeque, + readers: usize, + writers: usize, +} + +/// Shared state of a pipe, referenced by both of its ends. +#[derive(Default)] +struct Pipe(Mutex); + +impl Pipe { + fn lock(&self) -> std::sync::MutexGuard<'_, PipeState> { + // The state is never left inconsistent, so a poisoned lock is still usable. + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// The read end of an in-memory pipe. +pub(crate) struct PipeReader(Arc); + +impl PipeReader { + fn new(pipe: Arc) -> Self { + pipe.lock().readers += 1; + Self(pipe) + } +} + +impl Clone for PipeReader { + fn clone(&self) -> Self { + Self::new(self.0.clone()) + } +} + +impl Drop for PipeReader { + fn drop(&mut self) { + self.0.lock().readers -= 1; + } +} + +impl std::io::Read for PipeReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut state = self.0.lock(); + if state.buffer.is_empty() && state.writers > 0 && !buf.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "the pipe is empty, and nothing can write to it until this command finishes", + )); + } + let count = buf.len().min(state.buffer.len()); + for (dest, src) in buf.iter_mut().zip(state.buffer.drain(..count)) { + *dest = src; + } + drop(state); + Ok(count) + } +} + +impl std::io::Write for PipeReader { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("cannot write to read end of pipe")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Stream for PipeReader { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl From for OpenFile { + fn from(reader: PipeReader) -> Self { + Self::Stream(Box::new(reader)) + } +} + +/// The write end of an in-memory pipe. +pub(crate) struct PipeWriter(Arc); + +impl PipeWriter { + fn new(pipe: Arc) -> Self { + pipe.lock().writers += 1; + Self(pipe) + } +} + +impl Clone for PipeWriter { + fn clone(&self) -> Self { + Self::new(self.0.clone()) + } +} + +impl Drop for PipeWriter { + fn drop(&mut self) { + self.0.lock().writers -= 1; + } +} + +impl std::io::Read for PipeWriter { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("cannot read from write end of pipe")) + } +} + +impl std::io::Write for PipeWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut state = self.0.lock(); + if state.readers == 0 { + return Err(std::io::ErrorKind::BrokenPipe.into()); + } + state.buffer.extend(buf); + drop(state); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Stream for PipeWriter { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl From for OpenFile { + fn from(writer: PipeWriter) -> Self { + Self::Stream(Box::new(writer)) + } +} + +/// Creates a new in-memory pipe, returning its read and write ends. +#[allow( + clippy::unnecessary_wraps, + reason = "matches the signature on other platforms" +)] +pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> { + let pipe = Arc::new(Pipe::default()); + Ok((PipeReader::new(pipe.clone()), PipeWriter::new(pipe))) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use std::io::{Read as _, Write as _}; + + use super::*; + + #[test] + fn writes_succeed_while_a_reader_remains() -> std::io::Result<()> { + let (reader, mut writer) = pipe()?; + let mut other_reader = reader.clone(); + drop(reader); + + writer.write_all(b"hi")?; + drop(writer); + + let mut read = String::new(); + other_reader.read_to_string(&mut read)?; + assert_eq!(read, "hi"); + Ok(()) + } + + #[test] + fn writes_fail_with_broken_pipe_once_every_reader_is_gone() -> std::io::Result<()> { + let (reader, mut writer) = pipe()?; + let other_reader = reader.clone(); + drop(reader); + drop(other_reader); + + assert!(matches!( + writer.write(b"hi"), + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe + )); + Ok(()) + } +} diff --git a/userland/brush/process.rs b/userland/brush/source/brush-core/src/sys/wasm/process.rs similarity index 100% rename from userland/brush/process.rs rename to userland/brush/source/brush-core/src/sys/wasm/process.rs diff --git a/userland/brush/lookup.rs b/userland/brush/source/brush-core/src/sys/wasm/sausage-lookup.rs similarity index 100% rename from userland/brush/lookup.rs rename to userland/brush/source/brush-core/src/sys/wasm/sausage-lookup.rs diff --git a/userland/brush/source/brush-core/src/sys/wasm/tasks.rs b/userland/brush/source/brush-core/src/sys/wasm/tasks.rs new file mode 100644 index 0000000..210f280 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/wasm/tasks.rs @@ -0,0 +1,37 @@ +//! Tasks run on tokio's (single-threaded) runtime. + +use futures::FutureExt as _; + +use crate::{error, tasks::TaskHandle}; + +/// Starts running a future concurrently with the caller. +/// +/// # Arguments +/// +/// * `future` - The future to run. +pub(crate) fn spawn(future: F) -> TaskHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + TaskHandle::from_future( + tokio::spawn(future) + .map(|r| r.map_err(|err| error::ErrorKind::TaskError(Box::new(err)).into())), + ) +} + +/// Starts running a future that may block the thread it runs on (e.g., with +/// synchronous I/O). With no other threads to run it on, it would hold up +/// whatever the caller does next, so the returned future yields the task's +/// handle only once the task has finished. +/// +/// # Arguments +/// +/// * `future` - The future to run. +pub(crate) fn spawn_blocking(future: F) -> impl Future> +where + F: Future + Send + 'static, + F::Output: Send + Sync + 'static, +{ + spawn(future).map(|result| TaskHandle::from_future(std::future::ready(result))) +} diff --git a/userland/brush/source/brush-core/src/sys/windows.rs b/userland/brush/source/brush-core/src/sys/windows.rs new file mode 100644 index 0000000..0406ce0 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/windows.rs @@ -0,0 +1,25 @@ +pub use crate::sys::stubs::async_pipe; +pub(crate) use crate::sys::stubs::commands; +pub(crate) mod env; +pub use crate::sys::stubs::fd; +pub(crate) mod fs; +pub(crate) use crate::sys::os_pipes as pipes; +pub use crate::sys::stubs::input; +pub(crate) mod network; +pub use crate::sys::stubs::poll; +pub use crate::sys::stubs::resource; + +/// Signal processing utilities +pub mod signal { + pub(crate) use crate::sys::stubs::signal::*; + pub use tokio::signal::ctrl_c as await_ctrl_c; +} + +pub use crate::sys::stubs::terminal; +pub use crate::sys::tokio_process as process; +pub(crate) use crate::sys::tokio_tasks as tasks; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/userland/brush/source/brush-core/src/sys/windows/env.rs b/userland/brush/source/brush-core/src/sys/windows/env.rs new file mode 100644 index 0000000..900da98 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/windows/env.rs @@ -0,0 +1,207 @@ +//! Environment variable retrieval for Windows. +//! +//! On Windows, well-known environment variable names are normalized to their +//! canonical POSIX forms (e.g. `Path` → `PATH`), and `HOME` is synthesized +//! from `USERPROFILE` or `HOMEDRIVE`+`HOMEPATH` if not already present. + +use std::collections::BTreeMap; + +/// Retrieves environment variables from the host process, applying +/// Windows-specific fixups. +/// +/// Normalizes well-known variable names to POSIX conventions, copies +/// `TEMP`/`TMP` to `TMPDIR` (preserving originals for native Windows apps), +/// and synthesizes `HOME` if it is not natively defined. +/// +/// A [`BTreeMap`] is used (rather than `HashMap`) so iteration order is +/// deterministic across runs. If two source variables collide under the same +/// canonical name (e.g. both `Path` and `PATH` are set to different values), +/// the conflict is logged and the last-seen value wins. +pub(crate) fn get_host_env_vars() -> impl Iterator { + collect_host_env_vars(std::env::vars()) +} + +/// Collects and normalizes a set of environment variables. Exposed as a +/// pure function (taking the source iterator) so it can be unit-tested +/// without touching the process environment. +fn collect_host_env_vars(source: I) -> std::collections::btree_map::IntoIter +where + I: IntoIterator, +{ + let mut vars = BTreeMap::new(); + + // Normalize host env vars and inject them into the map. + for (k, v) in source { + let normalized = normalize_env_name(&k); + if let Some(existing) = vars.get(&normalized) + && existing != &v + { + tracing::warn!( + "environment variable collision under canonical name {normalized}: \ + two different values were supplied (last-write wins)" + ); + } + vars.insert(normalized, v); + } + + // Synthesize HOME from Windows-native variables if not already present. + if !vars.contains_key("HOME") { + let home = vars.get("USERPROFILE").cloned().or_else(|| { + let d = vars.get("HOMEDRIVE")?; + let p = vars.get("HOMEPATH")?; + Some(format!("{d}{p}")) + }); + if let Some(home) = home { + vars.insert("HOME".to_string(), home); + } + } + + // Copy TEMP/TMP to TMPDIR if TMPDIR doesn't already exist. + if !vars.contains_key("TMPDIR") + && let Some(tmp) = vars.get("TEMP").or_else(|| vars.get("TMP")).cloned() + { + vars.insert("TMPDIR".to_string(), tmp); + } + + vars.into_iter() +} + +/// Normalizes the case of well-known environment variable names to their +/// canonical POSIX forms (`Path` → `PATH`, `home` → `HOME`). +/// +/// # Arguments +/// +/// * `name` - The environment variable name to normalize. +fn normalize_env_name(name: &str) -> String { + // Normalize well-known variable names so that later lookups by + // canonical (uppercase) spelling always succeed regardless of the + // host's original casing. + const WELL_KNOWN: &[&str] = &[ + "PATH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "TEMP", + "TMP", + "TMPDIR", + ]; + + for &canonical in WELL_KNOWN { + if name.eq_ignore_ascii_case(canonical) { + return canonical.to_string(); + } + } + + name.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(source: &[(&str, &str)]) -> BTreeMap { + collect_host_env_vars( + source + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())), + ) + .collect() + } + + #[test] + fn normalize_env_name_canonicalizes_well_known() { + assert_eq!(normalize_env_name("Path"), "PATH"); + assert_eq!(normalize_env_name("path"), "PATH"); + assert_eq!(normalize_env_name("PATH"), "PATH"); + assert_eq!(normalize_env_name("Home"), "HOME"); + assert_eq!(normalize_env_name("UserProfile"), "USERPROFILE"); + assert_eq!(normalize_env_name("Temp"), "TEMP"); + assert_eq!(normalize_env_name("Tmp"), "TMP"); + assert_eq!(normalize_env_name("TmpDir"), "TMPDIR"); + } + + #[test] + fn normalize_env_name_leaves_unknown_alone() { + assert_eq!(normalize_env_name("FOO"), "FOO"); + assert_eq!(normalize_env_name("myVar"), "myVar"); + // Does not uppercase unknown names. + assert_eq!(normalize_env_name("AppData"), "AppData"); + } + + #[test] + fn synthesizes_home_from_userprofile() { + let vars = run(&[("UserProfile", r"C:\Users\reuben")]); + assert_eq!( + vars.get("HOME").map(String::as_str), + Some(r"C:\Users\reuben") + ); + assert_eq!( + vars.get("USERPROFILE").map(String::as_str), + Some(r"C:\Users\reuben") + ); + } + + #[test] + fn synthesizes_home_from_homedrive_homepath_when_no_userprofile() { + let vars = run(&[("HomeDrive", "C:"), ("HomePath", r"\Users\reuben")]); + assert_eq!( + vars.get("HOME").map(String::as_str), + Some(r"C:\Users\reuben") + ); + } + + #[test] + fn preserves_existing_home() { + let vars = run(&[("HOME", "/already/set"), ("UserProfile", r"C:\Users\other")]); + assert_eq!(vars.get("HOME").map(String::as_str), Some("/already/set")); + } + + #[test] + fn copies_temp_to_tmpdir() { + let vars = run(&[("Temp", r"C:\Windows\Temp")]); + assert_eq!( + vars.get("TMPDIR").map(String::as_str), + Some(r"C:\Windows\Temp") + ); + } + + #[test] + fn prefers_temp_over_tmp_for_tmpdir() { + let vars = run(&[("TEMP", "one"), ("TMP", "two")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("one")); + } + + #[test] + fn falls_back_to_tmp_when_no_temp() { + let vars = run(&[("TMP", "two")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("two")); + } + + #[test] + fn preserves_existing_tmpdir() { + let vars = run(&[("TMPDIR", "original"), ("TEMP", "other")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("original")); + } + + #[test] + fn deterministic_iteration_order() { + // BTreeMap iteration order is determined by the sorted key order, + // so the outputs must be identical across invocations regardless of + // input ordering. + let a = run(&[("Path", "first"), ("ZETA", "zz"), ("Alpha", "aa")]); + let b = run(&[("ZETA", "zz"), ("Alpha", "aa"), ("Path", "first")]); + let keys_a: Vec<_> = a.keys().collect(); + let keys_b: Vec<_> = b.keys().collect(); + assert_eq!(keys_a, keys_b); + } + + #[test] + fn collision_last_write_wins() { + // When two source names normalize to the same canonical key, + // the later one should overwrite the earlier one (and it should not + // panic). + let vars = run(&[("Path", "first"), ("PATH", "second")]); + assert_eq!(vars.get("PATH").map(String::as_str), Some("second")); + } +} diff --git a/userland/brush/source/brush-core/src/sys/windows/fs.rs b/userland/brush/source/brush-core/src/sys/windows/fs.rs new file mode 100644 index 0000000..88e0be5 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/windows/fs.rs @@ -0,0 +1,464 @@ +//! Filesystem utilities for Windows. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use crate::error; + +// Selectively re-export items from stubs that we don't override. +pub(crate) use crate::sys::stubs::fs::MetadataExt; + +/// Cached list of executable extensions from the `PATHEXT` environment +/// variable. Each entry retains its leading dot (e.g. `".exe"`) and is stored +/// lowercased so case-insensitive comparisons can be done without allocating. +/// +/// NOTE: This is cached for the process lifetime. Changes to `PATHEXT` made +/// inside the running shell are not reflected here. Bash itself has no +/// `PATHEXT` semantics, so this is generally acceptable for now. +static PATHEXT_EXTENSIONS: LazyLock> = LazyLock::new(|| { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + .split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect() +}); + +/// Returns the stem of a PATHEXT entry (with any leading `.` removed). +/// +/// `PATHEXT` canonically stores entries like `.EXE`, but tolerant parsing +/// accepts entries without the leading dot too. +fn pathext_entry_stem(entry: &str) -> &str { + entry.strip_prefix('.').unwrap_or(entry) +} + +/// Returns true if the path's extension is in the PATHEXT list. +/// +/// Performs case-insensitive comparison against the cached PATHEXT entries +/// without allocating. +fn has_executable_extension(path: &Path) -> bool { + path.extension().is_some_and(|ext| { + PATHEXT_EXTENSIONS + .iter() + .any(|e| ext.eq_ignore_ascii_case(pathext_entry_stem(e))) + }) +} + +/// Returns true if `path` is, by itself, an existing executable file. +/// +/// Used both for the initial check in [`resolve_executable`] and for +/// [`PathExt::executable`]. +fn is_executable_file(path: &Path) -> bool { + has_executable_extension(path) && path.is_file() +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// If the path is already a file with a `PATHEXT` extension, it is returned +/// unchanged (no allocation). Otherwise, each `PATHEXT` extension is appended +/// in turn and the first existing file is returned. +pub fn resolve_executable(path: PathBuf) -> Option { + if is_executable_file(&path) { + return Some(path); + } + // Try appending each PATHEXT extension. + for ext in PATHEXT_EXTENSIONS.iter() { + let mut name = path.as_os_str().to_owned(); + name.push(ext); + let candidate = PathBuf::from(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Resolves a path to a potential executable. +/// +/// This exists because Windows needs `PATHEXT` resolution to determine if a path resolves to an executable or not. +/// For example, if the cwd is `C:/` and there's an executable at `C:/tools/my_tool.cmd`, we expect `tools/my_tool` +/// to resolve to the executable `my_tool.cmd` (assuming `.CMD` is part of `PATHEXT`). +pub(crate) fn resolve_executable_path(path: PathBuf, working_dir: &Path) -> PathBuf { + resolve_executable(working_dir.join(&path)).unwrap_or(path) +} + +impl crate::sys::fs::PathExt for Path { + fn readable(&self) -> bool { + self.exists() + } + + fn writable(&self) -> bool { + self.metadata().is_ok_and(|m| !m.permissions().readonly()) + } + + fn executable(&self) -> bool { + if is_executable_file(self) { + return true; + } + // Try each PATHEXT extension without allocating a separate PathBuf + // per candidate until one exists. + PATHEXT_EXTENSIONS.iter().any(|ext| { + let mut name = self.as_os_str().to_owned(); + name.push(ext); + Self::new(&name).is_file() + }) + } + + fn exists_and_is_block_device(&self) -> bool { + false + } + + fn exists_and_is_char_device(&self) -> bool { + false + } + + fn exists_and_is_fifo(&self) -> bool { + false + } + + fn exists_and_is_socket(&self) -> bool { + false + } + + fn exists_and_is_setgid(&self) -> bool { + false + } + + fn exists_and_is_setuid(&self) -> bool { + false + } + + fn exists_and_is_sticky_bit(&self) -> bool { + false + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + // TODO(windows): implement using file index / volume serial number. + Err(error::ErrorKind::NotSupportedOnThisPlatform("get_device_and_inode").into()) + } +} + +/// Splits a platform-specific PATH-like value into individual paths. +/// +/// On Windows, this delegates to [`std::env::split_paths`]. +pub fn split_paths + ?Sized>(s: &T) -> std::env::SplitPaths<'_> { + std::env::split_paths(s) +} + +/// Opens a null file that will discard all I/O. +pub fn open_null_file() -> Result { + let f = std::fs::File::options() + .read(true) + .write(true) + .open("NUL")?; + Ok(f) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +pub fn try_open_special_file(path: &Path) -> Option> { + if path.ends_with("dev/null") && path.is_absolute() { + Some(open_null_file().map_err(std::io::Error::other)) + } else { + None + } +} + +/// Returns the default paths where executables are typically found on Windows. +pub(crate) fn get_default_executable_search_paths() -> Vec { + default_system_paths() +} + +/// Returns the default paths where standard system utilities are found on Windows. +pub fn get_default_standard_utils_paths() -> Vec { + default_system_paths() +} + +fn default_system_paths() -> Vec { + let mut paths = Vec::new(); + if let Ok(sysroot) = std::env::var("SystemRoot") { + paths.push(PathBuf::from(&sysroot).join("system32")); + paths.push(PathBuf::from(&sysroot)); + paths.push(PathBuf::from(&sysroot).join("System32").join("Wbem")); + paths.push( + PathBuf::from(&sysroot) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0"), + ); + } + if let Ok(userprofile) = std::env::var("USERPROFILE") { + paths.push( + PathBuf::from(userprofile) + .join("AppData") + .join("Local") + .join("Microsoft") + .join("WindowsApps"), + ); + } + paths +} + +/// Returns the path to the system-wide shell profile script. +/// +/// On Windows, no system profile is loaded by default. +pub const fn get_system_profile_path() -> Option<&'static Path> { + None +} + +/// Returns the path to the system-wide shell rc script. +/// +/// On Windows, no system rc file is loaded by default. +pub const fn get_system_rc_path() -> Option<&'static Path> { + None +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// On Windows, filesystems are typically case-insensitive, so this returns `true`. +pub const fn default_case_insensitive_path_expansion() -> bool { + true +} + +/// Path separator characters on Windows. +const PATH_SEPARATORS: [char; 2] = ['/', '\\']; + +/// Returns true if the string contains a path separator character. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn contains_path_separator(s: &str) -> bool { + s.contains(PATH_SEPARATORS) +} + +/// Returns true if the string ends with a path separator character. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with(PATH_SEPARATORS) +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix(PATH_SEPARATORS).unwrap_or(s) +} + +/// Finds the byte index of the last path separator in the string. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind(PATH_SEPARATORS) +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// On Windows, both `/` and `\` are used as separators. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split(PATH_SEPARATORS) +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// On Windows, recognizes both a leading separator (empty first component from splitting +/// a path like `/foo`) and a drive-letter prefix like `C:` as absolute. +/// +/// TODO(windows): UNC paths like `\\server\share\foo` are not yet handled +/// specially; they split into `["", "", "server", "share", "foo"]`, and the +/// leading empty component causes them to be treated as if they were rooted +/// at `/`, which drops the server/share portion. Supporting UNC requires +/// peeking further into the component list. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + // Leading separator, e.g. `/foo` split into ["", "foo"]. + Some(PathBuf::from("/")) + } else if first_component.len() == 2 + && first_component.as_bytes()[0].is_ascii_alphabetic() + && first_component.as_bytes()[1] == b':' + { + // Drive letter prefix, e.g. `c:/foo` split into ["c:", "foo"]. + let mut root = String::with_capacity(3); + root.push_str(first_component); + root.push('/'); + Some(PathBuf::from(root)) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// On Windows, `PathBuf::push` has special drive-letter and root-replacement +/// semantics that conflict with shell path construction (e.g. pushing `C:foo` +/// onto `D:\bar` replaces the whole path). This function always appends the +/// component as a child, operating on the underlying `OsString` so non-UTF-8 +/// content in the path is preserved and no reallocation is needed. +pub fn push_path_for_pattern(path: &mut PathBuf, component: &str) { + // Separator characters are ASCII, and WTF-8-encoded OsStr bytes are a + // superset of UTF-8, so checking the last byte directly is safe. + let bytes = path.as_os_str().as_encoded_bytes(); + let needs_sep = !bytes.is_empty() && !matches!(bytes.last(), Some(b'/' | b'\\')); + + let buf = path.as_mut_os_string(); + if needs_sep { + buf.push("/"); + } + buf.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// On Windows, replaces `\` with `/` since backslash is the shell escape character. +pub fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + if s.contains('\\') { + std::borrow::Cow::Owned(s.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_separator_helpers_both_slashes() { + assert!(contains_path_separator("foo/bar")); + assert!(contains_path_separator(r"foo\bar")); + assert!(contains_path_separator(r"mixed/and\back")); + assert!(!contains_path_separator("foobar")); + + assert!(ends_with_path_separator("foo/")); + assert!(ends_with_path_separator(r"foo\")); + assert!(!ends_with_path_separator("foo")); + + assert_eq!(strip_path_separator_suffix("foo/"), "foo"); + assert_eq!(strip_path_separator_suffix(r"foo\"), "foo"); + assert_eq!(strip_path_separator_suffix("foo"), "foo"); + + assert_eq!(rfind_path_separator("a/b/c"), Some(3)); + assert_eq!(rfind_path_separator(r"a\b\c"), Some(3)); + assert_eq!(rfind_path_separator(r"a/b\c"), Some(3)); + assert_eq!(rfind_path_separator("abc"), None); + } + + #[test] + fn split_path_for_pattern_both_slashes() { + let parts: Vec<_> = split_path_for_pattern("a/b/c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern(r"a\b\c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern(r"a/b\c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern("/a/b").collect(); + assert_eq!(parts, vec!["", "a", "b"]); + } + + #[test] + fn pattern_path_root_leading_separator() { + assert_eq!(pattern_path_root(""), Some(PathBuf::from("/"))); + } + + #[test] + fn pattern_path_root_drive_letters() { + assert_eq!(pattern_path_root("c:"), Some(PathBuf::from("c:/"))); + assert_eq!(pattern_path_root("C:"), Some(PathBuf::from("C:/"))); + assert_eq!(pattern_path_root("Z:"), Some(PathBuf::from("Z:/"))); + } + + #[test] + fn pattern_path_root_rejects_non_drive_two_char_prefix() { + // "1:" is not a valid drive letter — must be alphabetic. + assert_eq!(pattern_path_root("1:"), None); + // Longer drive-like strings are not treated as roots. + assert_eq!(pattern_path_root("cd"), None); + assert_eq!(pattern_path_root("c:\\"), None); + assert_eq!(pattern_path_root("foo"), None); + } + + #[test] + fn push_path_for_pattern_appends_with_forward_slash() { + let mut p = PathBuf::from(r"C:\Users\reuben"); + push_path_for_pattern(&mut p, "foo"); + // Forward slash is used as the appended separator, yielding mixed + // separators — acceptable because `normalize_path_separators` is + // applied downstream before display. + assert_eq!(p, PathBuf::from(r"C:\Users\reuben/foo")); + } + + #[test] + fn push_path_for_pattern_no_double_separator() { + let mut p = PathBuf::from("C:/Users/reuben/"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("C:/Users/reuben/foo")); + + let mut p = PathBuf::from(r"C:\Users\reuben\"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from(r"C:\Users\reuben\foo")); + } + + #[test] + fn push_path_for_pattern_onto_drive_root() { + let mut p = PathBuf::from("c:/"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("c:/foo")); + } + + #[test] + fn push_path_for_pattern_onto_empty() { + let mut p = PathBuf::new(); + push_path_for_pattern(&mut p, "foo"); + // Empty path stays un-prefixed — we only add a separator between + // existing content and the new component. + assert_eq!(p, PathBuf::from("foo")); + } + + #[test] + fn normalize_path_separators_converts_backslashes() { + use std::borrow::Cow; + // Already-forward-slashed input is borrowed (no allocation). + assert!(matches!( + normalize_path_separators("c:/foo/bar"), + Cow::Borrowed("c:/foo/bar") + )); + // Mixed or backslashed input becomes owned and fully forward-slashed. + let normalized = normalize_path_separators(r"c:\foo\bar"); + assert_eq!(normalized.as_ref(), "c:/foo/bar"); + let normalized = normalize_path_separators(r"c:\foo/bar"); + assert_eq!(normalized.as_ref(), "c:/foo/bar"); + } + + #[test] + fn default_case_insensitive_is_true() { + assert!(default_case_insensitive_path_expansion()); + } + + #[test] + fn has_executable_extension_is_case_insensitive() { + // Force the PATHEXT cache for this test's defaults. + assert!(has_executable_extension(Path::new("foo.exe"))); + assert!(has_executable_extension(Path::new("foo.EXE"))); + assert!(has_executable_extension(Path::new("foo.Cmd"))); + assert!(!has_executable_extension(Path::new("foo.txt"))); + assert!(!has_executable_extension(Path::new("foo"))); + } + + #[test] + fn pathext_entry_stem_strips_dot() { + assert_eq!(pathext_entry_stem(".exe"), "exe"); + assert_eq!(pathext_entry_stem(".cmd"), "cmd"); + // Tolerant: entries without a leading dot are returned as-is. + assert_eq!(pathext_entry_stem("exe"), "exe"); + assert_eq!(pathext_entry_stem(""), ""); + } + + #[test] + fn resolve_executable_for_nonexistent_returns_none() { + // A path that cannot exist on any test host. + let path = PathBuf::from(r"C:\__brush_test_definitely_missing__"); + assert!(resolve_executable(path).is_none()); + } +} diff --git a/userland/brush/source/brush-core/src/sys/windows/network.rs b/userland/brush/source/brush-core/src/sys/windows/network.rs new file mode 100644 index 0000000..b2ae829 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/windows/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/userland/brush/source/brush-core/src/sys/windows/users.rs b/userland/brush/source/brush-core/src/sys/windows/users.rs new file mode 100644 index 0000000..97fde33 --- /dev/null +++ b/userland/brush/source/brush-core/src/sys/windows/users.rs @@ -0,0 +1,86 @@ +#![allow(clippy::missing_const_for_fn)] +#![allow(clippy::unnecessary_wraps)] + +use crate::error; +use std::path::PathBuf; +use std::sync::LazyLock; + +/// Placeholder UID for non-elevated Windows processes. +/// +/// Real Unix-style UIDs don't exist on Windows; this value is a +/// conventional non-root sentinel (matching the typical first +/// regular-user UID on Linux). +const NON_ELEVATED_UID: u32 = 1000; + +/// Placeholder GID for non-elevated Windows processes (see [`NON_ELEVATED_UID`]). +const NON_ELEVATED_GID: u32 = 1000; + +/// Cached elevation status. The underlying check queries the process token, +/// which can't change after process start, so it's safe to memoize. +static IS_ELEVATED: LazyLock = LazyLock::new(|| { + check_elevation::is_elevated().unwrap_or_else(|err| { + tracing::warn!("failed to determine process elevation: {err}"); + false + }) +}); + +pub(crate) fn get_user_home_dir(_username: &str) -> Option { + // std::env::home_dir() doesn't support getting home dir for arbitrary users + // For now, we only support getting the current user's home dir + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + std::env::home_dir() +} + +pub(crate) fn get_current_user_default_shell() -> Option { + None +} + +fn is_elevated() -> bool { + *IS_ELEVATED +} + +pub(crate) fn is_root() -> bool { + is_elevated() +} + +pub(crate) fn get_current_uid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID }) +} + +pub(crate) fn get_current_gid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID }) +} + +pub(crate) fn get_effective_uid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID }) +} + +pub(crate) fn get_effective_gid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID }) +} + +pub(crate) fn get_current_username() -> Result { + let username = whoami::username().map_err(std::io::Error::from)?; + Ok(username) +} + +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} diff --git a/userland/brush/source/brush-core/src/tasks.rs b/userland/brush/source/brush-core/src/tasks.rs new file mode 100644 index 0000000..0033dfc --- /dev/null +++ b/userland/brush/source/brush-core/src/tasks.rs @@ -0,0 +1,43 @@ +//! Tasks the shell runs concurrently with itself. + +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use crate::error; + +/// A future that yields a task's output when it finishes. +type TaskFuture = Pin> + Send + Sync>>; + +/// Tracks a task started on the shell's behalf. Awaiting it yields the task's +/// output, or an error if the task couldn't run to completion (e.g., it +/// panicked). +/// +/// Dropping the handle doesn't stop the task. +pub struct TaskHandle { + future: TaskFuture, +} + +impl TaskHandle { + /// Tracks a task through a future that yields its output when it finishes. + /// + /// # Arguments + /// + /// * `future` - The future to await for the task's output. + pub fn from_future( + future: impl Future> + Send + Sync + 'static, + ) -> Self { + Self { + future: Box::pin(future), + } + } +} + +impl Future for TaskHandle { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.future.as_mut().poll(cx) + } +} diff --git a/userland/brush/source/brush-core/src/terminal.rs b/userland/brush/source/brush-core/src/terminal.rs new file mode 100644 index 0000000..55c1da5 --- /dev/null +++ b/userland/brush/source/brush-core/src/terminal.rs @@ -0,0 +1,106 @@ +//! Terminal control utilities. + +use crate::{error, openfiles, sys}; + +/// Encapsulates the state of a controlled terminal. +pub struct TerminalControl { + prev_fg_pid: Option, +} + +impl TerminalControl { + /// Acquire the terminal for the shell. + pub fn acquire() -> Result { + // Mask out SIGTTOU *first*. If `lead_new_process_group` succeeds in + // moving us into a new process group, the subsequent `tcsetpgrp` call + // in `move_self_to_foreground` is a "write to the controlling + // terminal from a background process," which the kernel signals with + // SIGTTOU. The default action for SIGTTOU is to stop the process, + // leaving brush (and any downstream reads from the terminal) hung. + // Installing the SIG_IGN handler before the tcsetpgrp makes that call + // succeed instead of stopping us. + sys::signal::mask_sigttou()?; + + let prev_fg_pid = sys::terminal::get_foreground_pid(); + + // Break out into new process group. + // TODO(jobs): Investigate why this sometimes fails with EPERM. + let _ = sys::signal::lead_new_process_group(); + + // Take ownership. + sys::terminal::move_self_to_foreground()?; + + Ok(Self { prev_fg_pid }) + } + + fn try_release(&mut self) { + // Restore the previous foreground process group. + if let Some(pid) = self.prev_fg_pid + && sys::terminal::move_to_foreground(pid).is_ok() + { + self.prev_fg_pid = None; + } + } +} + +impl Drop for TerminalControl { + fn drop(&mut self) { + self.try_release(); + } +} + +/// Describes high-level terminal settings that can be requested. +#[derive(Default, bon::Builder)] +pub struct Settings { + /// Whether to put the terminal in raw mode, as `cfmakeraw` does: input + /// arrives a byte at a time, as typed, without being echoed, translated + /// (e.g., Enter to newline), or turned into signals, and output isn't + /// processed. The other settings then apply on top of it (e.g., to have + /// newlines output as CRLF pairs). + #[builder(default)] + pub raw_input: bool, + /// Whether to enable input echoing. + pub echo_input: Option, + /// Whether to enable line input (sometimes known as canonical mode). + pub line_input: Option, + /// Whether to disable interrupt signals and instead yield the control characters. + pub interrupt_signals: Option, + /// Whether to output newline characters as CRLF pairs. + pub output_nl_as_nlcr: Option, +} + +/// Guard that automatically restores terminal settings on drop. +pub struct AutoModeGuard { + initial: sys::terminal::Config, + file: openfiles::OpenFile, +} + +impl AutoModeGuard { + /// Creates a new `AutoModeGuard` for the given file. + /// + /// # Arguments + /// + /// * `file` - The file representing the terminal to control. + pub fn new(file: openfiles::OpenFile) -> Result { + let initial = sys::terminal::Config::from_term(&file)?; + Ok(Self { initial, file }) + } + + /// Applies the given terminal settings. + /// + /// # Arguments + /// + /// * `settings` - The terminal settings to apply. + pub fn apply_settings(&self, settings: &Settings) -> Result<(), error::Error> { + let mut config = sys::terminal::Config::from_term(&self.file)?; + config.update(settings); + config.apply_to_term(&self.file)?; + + Ok(()) + } +} + +impl Drop for AutoModeGuard { + fn drop(&mut self) { + let _ = self.initial.apply_to_term(&self.file); + } +} diff --git a/userland/brush/source/brush-core/src/tests.rs b/userland/brush/source/brush-core/src/tests.rs new file mode 100644 index 0000000..25c13ce --- /dev/null +++ b/userland/brush/source/brush-core/src/tests.rs @@ -0,0 +1,43 @@ +//! Shell test conditional expressions + +use crate::{ExecutionParameters, Shell, error, extendedtests, extensions}; + +/// Evaluate the given test expression within the provided shell and +/// execution context. Returns true if the expression evaluates to true, +/// false otherwise. +/// +/// # Arguments +/// +/// * `expr` - The test expression to evaluate. +/// * `shell` - The shell context in which to evaluate the expression. +/// * `params` - The execution parameters to use during evaluation. +pub fn eval_expr( + expr: &brush_parser::ast::TestExpr, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match expr { + brush_parser::ast::TestExpr::False => Ok(false), + brush_parser::ast::TestExpr::Literal(s) => Ok(!s.is_empty()), + // Like bash, both sides are always evaluated, so an invalid operand on either side is + // reported even when the other side decides the result. + brush_parser::ast::TestExpr::And(left, right) => { + let left = eval_expr(left, shell, params)?; + let right = eval_expr(right, shell, params)?; + Ok(left && right) + } + brush_parser::ast::TestExpr::Or(left, right) => { + let left = eval_expr(left, shell, params)?; + let right = eval_expr(right, shell, params)?; + Ok(left || right) + } + brush_parser::ast::TestExpr::Not(expr) => Ok(!eval_expr(expr, shell, params)?), + brush_parser::ast::TestExpr::Parenthesized(expr) => eval_expr(expr, shell, params), + brush_parser::ast::TestExpr::UnaryTest(op, operand) => { + extendedtests::apply_unary_predicate_to_str(op, operand, shell, params) + } + brush_parser::ast::TestExpr::BinaryTest(op, left, right) => { + extendedtests::apply_binary_predicate_to_strs(op, left.as_str(), right.as_str(), shell) + } + } +} diff --git a/userland/brush/source/brush-core/src/timing.rs b/userland/brush/source/brush-core/src/timing.rs new file mode 100644 index 0000000..e2faf67 --- /dev/null +++ b/userland/brush/source/brush-core/src/timing.rs @@ -0,0 +1,137 @@ +//! Command timing + +use crate::error; + +struct StopwatchTime { + now: std::time::SystemTime, + self_user: std::time::Duration, + self_system: std::time::Duration, + children_user: std::time::Duration, + children_system: std::time::Duration, +} + +impl StopwatchTime { + #[allow(clippy::unchecked_time_subtraction)] + fn minus(&self, other: &Self) -> Result { + let user = (self.self_user - other.self_user) + (self.children_user - other.children_user); + let system = + (self.self_system - other.self_system) + (self.children_system - other.children_system); + + Ok(StopwatchTiming { + wall: self.now.duration_since(other.now)?, + user, + system, + }) + } +} + +pub(crate) struct Stopwatch { + start: StopwatchTime, +} + +impl Stopwatch { + pub fn stop(&self) -> Result { + let end = get_current_stopwatch_time()?; + end.minus(&self.start) + } +} +pub(crate) struct StopwatchTiming { + pub wall: std::time::Duration, + pub user: std::time::Duration, + pub system: std::time::Duration, +} + +pub(crate) fn start_timing() -> Result { + Ok(Stopwatch { + start: get_current_stopwatch_time()?, + }) +} + +fn get_current_stopwatch_time() -> Result { + let now = std::time::SystemTime::now(); + let (self_user, self_system) = crate::sys::resource::get_self_user_and_system_time()?; + let (children_user, children_system) = + crate::sys::resource::get_children_user_and_system_time()?; + + Ok(StopwatchTime { + now, + self_user, + self_system, + children_user, + children_system, + }) +} + +/// Format the given duration in a non-POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_non_posixly(duration: &std::time::Duration) -> String { + let minutes = duration.as_secs() / 60; + let seconds = duration.as_secs() % 60; + let millis = duration.subsec_millis(); + format!("{minutes}m{seconds}.{millis:03}s") +} + +/// Format the given duration in a POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_posixly(duration: &std::time::Duration) -> String { + let seconds = duration.as_secs(); + let ten_millis = duration.subsec_millis() / 10; + format!("{seconds}.{ten_millis:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_format_time() { + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(0)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1)), + "0m0.001s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123)), + "0m0.123s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1234)), + "0m1.234s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(12345)), + "0m12.345s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123_456)), + "2m3.456s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1_234_567)), + "20m34.567s" + ); + + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(999)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1001)), + "0m0.001s" + ); + } +} diff --git a/userland/brush/source/brush-core/src/trace_categories.rs b/userland/brush/source/brush-core/src/trace_categories.rs new file mode 100644 index 0000000..a310699 --- /dev/null +++ b/userland/brush/source/brush-core/src/trace_categories.rs @@ -0,0 +1,20 @@ +//! Trace utilities + +/// Trace category for command execution. +pub const COMMANDS: &str = "commands"; +/// Trace category for completion. +pub const COMPLETION: &str = "completion"; +/// Trace category for word expansion. +pub const EXPANSION: &str = "expansion"; +/// Trace category for function calls. +pub const FUNCTIONS: &str = "functions"; +/// Trace category for user input. +pub const INPUT: &str = "input"; +/// Trace category for job control. +pub const JOBS: &str = "jobs"; +/// Trace category for parsing. +pub const PARSE: &str = "parse"; +/// Trace category for shell patterns. +pub const PATTERN: &str = "pattern"; +/// Trace category for unimplemented behavior. +pub const UNIMPLEMENTED: &str = "unimplemented"; diff --git a/userland/brush/source/brush-core/src/traps.rs b/userland/brush/source/brush-core/src/traps.rs new file mode 100644 index 0000000..e432488 --- /dev/null +++ b/userland/brush/source/brush-core/src/traps.rs @@ -0,0 +1,235 @@ +//! Facilities for configuring trap handlers. + +use std::str::FromStr; +use std::{collections::HashMap, fmt::Display}; + +use itertools::Itertools as _; + +use crate::{error, sys}; + +/// Type of signal that can be trapped in the shell. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TrapSignal { + /// A system signal. + Signal(sys::signal::Signal), + /// The `DEBUG` trap. + Debug, + /// The `ERR` trap. + Err, + /// The `EXIT` trap. + Exit, + /// The `RETURN` trp. + Return, +} + +#[cfg(feature = "serde")] +impl serde::Serialize for TrapSignal { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for TrapSignal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::try_from(s.as_str()).map_err(serde::de::Error::custom) + } +} + +impl Display for TrapSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TrapSignal { + /// Returns all possible values of [`TrapSignal`]. + pub fn iterator() -> impl Iterator { + const SIGNALS: &[TrapSignal] = &[TrapSignal::Debug, TrapSignal::Err, TrapSignal::Exit]; + + let iter = itertools::chain!( + SIGNALS.iter().copied(), + sys::signal::Signal::iterator().map(TrapSignal::Signal) + ); + + iter + } + + /// Converts [`TrapSignal`] into its corresponding signal name as a [`&'static str`](str) + pub const fn as_str(self) -> &'static str { + match self { + Self::Signal(s) => s.as_str(), + Self::Debug => "DEBUG", + Self::Err => "ERR", + Self::Exit => "EXIT", + Self::Return => "RETURN", + } + } +} + +/// Formats [`Iterator`](TrapSignal) to the provided writer. +/// +/// # Arguments +/// +/// * `f` - Any type that implements [`std::io::Write`]. +/// * `it` - An iterator over the signals that will be formatted into the `f`. +pub fn format_signals( + mut f: impl std::io::Write, + it: impl Iterator, +) -> Result<(), error::Error> { + let it = it + .filter_map(|s| i32::try_from(s).ok().map(|n| (s, n))) + .sorted_by(|a, b| Ord::cmp(&a.1, &b.1)) + .format_with("\n", |s, f| f(&format_args!("{}) {}", s.1, s.0))); + write!(f, "{it}")?; + Ok(()) +} + +// implement s.parse::() +impl FromStr for TrapSignal { + type Err = error::Error; + fn from_str(s: &str) -> Result::Err> { + if let Ok(n) = s.parse::() { + Self::try_from(n) + } else { + Self::try_from(s) + } + } +} + +// from a signal number +impl TryFrom for TrapSignal { + type Error = error::Error; + fn try_from(value: i32) -> Result { + // NOTE: DEBUG and ERR are real-time signals, defined based on NSIG or SIGRTMAX (is not + // available on bsd-like systems), + // and don't have persistent numbers across platforms, so we skip them here. + Ok(match value { + 0 => Self::Exit, + value => Self::Signal( + sys::signal::Signal::try_from(value) + .map_err(|_| error::ErrorKind::InvalidSignal(value.to_string()))?, + ), + }) + } +} + +// from a signal name +impl TryFrom<&str> for TrapSignal { + type Error = error::Error; + fn try_from(value: &str) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut s = value.to_ascii_uppercase(); + + Ok(match s.as_str() { + "DEBUG" => Self::Debug, + "ERR" => Self::Err, + "EXIT" => Self::Exit, + "RETURN" => Self::Return, + _ => { + // Bash compatibility: + // support for signal names without the `SIG` prefix, for example `HUP` -> `SIGHUP` + if !s.starts_with("SIG") { + s.insert_str(0, "SIG"); + } + sys::signal::Signal::from_str(s.as_str()) + .map(TrapSignal::Signal) + .map_err(|_| error::ErrorKind::InvalidSignal(value.into()))? + } + }) + } +} + +/// Error type used when failing to convert a `TrapSignal` to a number. +#[derive(Debug, Clone, Copy)] +pub struct TrapSignalNumberError; + +impl TryFrom for i32 { + type Error = TrapSignalNumberError; + fn try_from(value: TrapSignal) -> Result { + Ok(match value { + TrapSignal::Signal(s) => s as Self, + TrapSignal::Exit => 0, + _ => return Err(TrapSignalNumberError), + }) + } +} + +/// A handler for a trap signal. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TrapHandler { + /// The source text of the command to invoke. + pub command: String, + /// Source information for where the trap handler was defined. + pub source_info: crate::SourceInfo, +} + +/// Configuration for trap handlers in the shell. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TrapHandlerConfig { + /// Registered handlers for traps; maps signal type to command. + handlers: HashMap, +} + +impl TrapHandlerConfig { + /// Iterates over the registered handlers for trap signals. + pub fn iter_handlers(&self) -> impl Iterator { + self.handlers + .iter() + .map(|(signal, handler)| (*signal, handler)) + } + + /// Tries to find the handler associated with the given signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to get the handler for. + pub fn get_handler(&self, signal_type: TrapSignal) -> Option<&TrapHandler> { + self.handlers.get(&signal_type) + } + + /// Returns whether a handler is registered for the given signal. + pub fn handles(&self, signal_type: TrapSignal) -> bool { + self.handlers.contains_key(&signal_type) + } + + /// Registers a handler for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to register a handler for. + /// * `command` - The command to execute when the signal is trapped. + /// * `source_info` - The source info for where the trap handler was defined. + pub fn register_handler( + &mut self, + signal_type: TrapSignal, + command: String, + source_info: crate::SourceInfo, + ) { + let _ = self.handlers.insert( + signal_type, + TrapHandler { + command, + source_info, + }, + ); + } + + /// Removes handlers for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to remove handlers for. + pub fn remove_handlers(&mut self, signal_type: TrapSignal) { + self.handlers.remove(&signal_type); + } +} diff --git a/userland/brush/source/brush-core/src/variables.rs b/userland/brush/source/brush-core/src/variables.rs new file mode 100644 index 0000000..a87ddb1 --- /dev/null +++ b/userland/brush/source/brush-core/src/variables.rs @@ -0,0 +1,1122 @@ +//! Implements variables for a shell environment. + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt::{Display, Write}; + +use crate::shell::{Shell, ShellState}; +use crate::{error, escape, extensions}; + +/// A shell variable. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellVariable { + /// The value currently associated with the variable. + value: ShellValue, + /// Whether or not the variable is marked as exported to child processes. + exported: bool, + /// Whether or not the variable is marked as read-only. + readonly: bool, + /// Whether or not the variable should be enumerated in the shell's environment. + enumerable: bool, + /// The transformation to apply to the variable's value when it is updated. + transform_on_update: ShellVariableUpdateTransform, + /// Whether or not the variable is marked as being traced. + trace: bool, + /// Whether or not the variable should be treated as an integer. + treat_as_integer: bool, + /// Whether or not the variable should be treated as a name reference. + treat_as_nameref: bool, +} + +/// Kind of transformation to apply to a variable's value when it is updated. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellVariableUpdateTransform { + /// No transformation. + None, + /// Convert the value to lowercase. + Lowercase, + /// Convert the value to uppercase. + Uppercase, + /// Convert the value to lowercase, with the first character capitalized. + Capitalize, +} + +impl Default for ShellVariable { + fn default() -> Self { + Self { + value: ShellValue::String(String::new()), + exported: false, + readonly: false, + enumerable: true, + transform_on_update: ShellVariableUpdateTransform::None, + trace: false, + treat_as_integer: false, + treat_as_nameref: false, + } + } +} + +impl ShellVariable { + /// Returns a new shell variable, initialized with the given value. + /// + /// # Arguments + /// + /// * `value` - The value to associate with the variable. + pub fn new>(value: I) -> Self { + Self { + value: value.into(), + ..Self::default() + } + } + + /// Returns the value associated with the variable. + pub const fn value(&self) -> &ShellValue { + &self.value + } + + /// Returns whether or not the variable is exported to child processes. + pub const fn is_exported(&self) -> bool { + self.exported + } + + /// Marks the variable as exported to child processes. + pub const fn export(&mut self) -> &mut Self { + self.exported = true; + self + } + + /// Marks the variable as not exported to child processes. + pub const fn unexport(&mut self) -> &mut Self { + self.exported = false; + self + } + + /// Returns whether or not the variable is read-only. + pub const fn is_readonly(&self) -> bool { + self.readonly + } + + /// Marks the variable as read-only. + pub const fn set_readonly(&mut self) -> &mut Self { + self.readonly = true; + self + } + + /// Marks the variable as not read-only. + pub fn unset_readonly(&mut self) -> Result<&mut Self, error::Error> { + if self.readonly { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + self.readonly = false; + Ok(self) + } + + /// Returns whether or not the variable is traced. + pub const fn is_trace_enabled(&self) -> bool { + self.trace + } + + /// Marks the variable as traced. + pub const fn enable_trace(&mut self) -> &mut Self { + self.trace = true; + self + } + + /// Marks the variable as not traced. + pub const fn disable_trace(&mut self) -> &mut Self { + self.trace = false; + self + } + + /// Returns whether or not the variable should be enumerated in the shell's environment. + pub const fn is_enumerable(&self) -> bool { + self.enumerable + } + + /// Marks the variable as not enumerable in the shell's environment. + pub const fn hide_from_enumeration(&mut self) -> &mut Self { + self.enumerable = false; + self + } + + /// Return the update transform associated with the variable. + pub const fn get_update_transform(&self) -> ShellVariableUpdateTransform { + self.transform_on_update + } + + /// Set the update transform associated with the variable. + pub const fn set_update_transform(&mut self, transform: ShellVariableUpdateTransform) { + self.transform_on_update = transform; + } + + /// Returns whether or not the variable should be treated as an integer. + pub const fn is_treated_as_integer(&self) -> bool { + self.treat_as_integer + } + + /// Marks the variable as being treated as an integer. + pub const fn treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = true; + self + } + + /// Marks the variable as not being treated as an integer. + pub const fn unset_treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = false; + self + } + + /// Returns whether or not the variable should be treated as a name reference. + pub const fn is_treated_as_nameref(&self) -> bool { + self.treat_as_nameref + } + + /// Marks the variable as being treated as a name reference. + pub const fn treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = true; + self + } + + /// Marks the variable as not being treated as a name reference. + pub const fn unset_treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = false; + self + } + + /// Converts the variable to an indexed array. + pub fn convert_to_indexed_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::IndexedArray(_) => Ok(()), + ShellValue::AssociativeArray(_) => { + Err(error::ErrorKind::ConvertingAssociativeArrayToIndexedArray.into()) + } + _ => { + let mut new_values = BTreeMap::new(); + new_values.insert( + 0, + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::IndexedArray(new_values); + Ok(()) + } + } + } + + /// Converts the variable to an associative array. + pub fn convert_to_associative_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::AssociativeArray(_) => Ok(()), + ShellValue::IndexedArray(_) => { + Err(error::ErrorKind::ConvertingIndexedArrayToAssociativeArray.into()) + } + _ => { + let mut new_values: BTreeMap = BTreeMap::new(); + new_values.insert( + String::from("0"), + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::AssociativeArray(new_values); + Ok(()) + } + } + } + + /// Assign the given value to the variable, conditionally appending to the preexisting value. + /// + /// # Arguments + /// + /// * `value` - The value to assign to the variable. + /// * `append` - Whether or not to append the value to the preexisting value. + #[expect(clippy::too_many_lines)] + pub fn assign(&mut self, value: ShellValueLiteral, append: bool) -> Result<(), error::Error> { + if self.is_readonly() { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + let value = self.convert_value_literal_for_assignment(value); + + if append { + match (&self.value, &value) { + // If we're appending an array to a declared-but-unset variable (or appending + // anything to a declared-but-unset array), then fill it out first. + (ShellValue::Unset(_), ShellValueLiteral::Array(_)) + | ( + ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray, + ), + _, + ) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + // If we're appending a scalar to a declared-but-unset variable, then + // start with the empty string. This will result in the right thing happening, + // even in treat-as-integer cases. + (ShellValue::Unset(_), ShellValueLiteral::Scalar(_)) => { + self.assign(ShellValueLiteral::Scalar(String::new()), false)?; + } + // If we're trying to append an array to a string, we first promote the string to be + // an array with the string being present at index 0. + (ShellValue::String(_), ShellValueLiteral::Array(_)) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let update_transform = self.get_update_transform(); + + match &mut self.value { + ShellValue::String(base) => match value { + ShellValueLiteral::Scalar(suffix) => { + if treat_as_int { + let int_value = base + .parse::() + .unwrap_or(0) + .wrapping_add(suffix.parse::().unwrap_or(0)); + base.clear(); + base.push_str(int_value.to_string().as_str()); + } else { + base.push_str(suffix.as_str()); + Self::apply_value_transforms(base, treat_as_int, update_transform); + } + Ok(()) + } + ShellValueLiteral::Array(_) => { + // This case was already handled (see above). + Ok(()) + } + }, + ShellValue::IndexedArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_indexed_array_from_literals(existing_values, new_values); + Ok(()) + } + }, + ShellValue::AssociativeArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_associative_array_from_literals( + existing_values, + new_values, + ) + } + }, + ShellValue::Unset(_) => unreachable!("covered in conversion above"), + // TODO(dynamic): implement appending to dynamic vars + ShellValue::Dynamic { .. } => Ok(()), + } + } else { + match (&self.value, value) { + // If we're updating an array value with a string, then treat it as an update to + // just the "0"-indexed element of the array. + ( + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + | ShellValue::Unset( + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray, + ), + ShellValueLiteral::Scalar(s), + ) => self.assign_at_index(String::from("0"), s, false), + + // If we're updating an indexed array value with an array, then preserve the array + // type. We also default to using an indexed array if we are + // assigning an array to a previously string-holding variable. + ( + ShellValue::IndexedArray(_) + | ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::Untyped, + ) + | ShellValue::String(_) + | ShellValue::Dynamic { .. }, + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::indexed_array_from_literals(literal_values); + Ok(()) + } + + // If we're updating an associative array value with an array, then preserve the + // array type. + ( + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray), + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::associative_array_from_literals(literal_values)?; + Ok(()) + } + + // Handle updates to dynamic values; for now we just drop them. + // TODO(dynamic): Allow updates to dynamic values + (ShellValue::Dynamic { .. }, _) => Ok(()), + + // Assign a scalar value to a scalar or unset (and untyped) variable. + (ShellValue::String(_) | ShellValue::Unset(_), ShellValueLiteral::Scalar(s)) => { + self.value = ShellValue::String(s); + Ok(()) + } + } + } + } + + /// Assign the given value to the variable at the given index, conditionally appending to the + /// preexisting value present at that element within the value. + /// + /// # Arguments + /// + /// * `array_index` - The index at which to assign the value. + /// * `value` - The value to assign to the variable at the given index. + /// * `append` - Whether or not to append the value to the preexisting value stored at the given + /// index. + pub fn assign_at_index( + &mut self, + array_index: String, + value: String, + append: bool, + ) -> Result<(), error::Error> { + match &self.value { + ShellValue::Unset(_) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + ShellValue::String(_) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let value = self.convert_value_str_for_assignment(value); + + match &mut self.value { + ShellValue::IndexedArray(arr) => { + let key = get_key_for_indexed_array(arr, array_index.as_str())?; + + if append { + let existing_value = arr.get(&key).map_or("", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = existing_value + .parse::() + .unwrap_or(0) + .wrapping_add(value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(key, new_value); + } else { + arr.insert(key, value); + } + + Ok(()) + } + ShellValue::AssociativeArray(arr) => { + if append { + let existing_value = arr.get(array_index.as_str()).map_or("", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = existing_value + .parse::() + .unwrap_or(0) + .wrapping_add(value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(array_index, new_value.clone()); + } else { + arr.insert(array_index, value); + } + Ok(()) + } + _ => { + tracing::error!("assigning to index {array_index} of {:?}", self.value); + error::unimp("assigning to index of non-array variable") + } + } + } + + fn convert_value_literal_for_assignment(&self, value: ShellValueLiteral) -> ShellValueLiteral { + match value { + ShellValueLiteral::Scalar(s) => { + ShellValueLiteral::Scalar(self.convert_value_str_for_assignment(s)) + } + ShellValueLiteral::Array(literals) => ShellValueLiteral::Array(ArrayLiteral( + literals + .0 + .into_iter() + .map(|(k, v)| (k, self.convert_value_str_for_assignment(v))) + .collect(), + )), + } + } + + fn convert_value_str_for_assignment(&self, mut s: String) -> String { + Self::apply_value_transforms( + &mut s, + self.is_treated_as_integer(), + self.get_update_transform(), + ); + + s + } + + fn apply_value_transforms( + s: &mut String, + treat_as_int: bool, + update_transform: ShellVariableUpdateTransform, + ) { + if treat_as_int { + *s = (*s).parse::().unwrap_or(0).to_string(); + } else { + match update_transform { + ShellVariableUpdateTransform::None => (), + ShellVariableUpdateTransform::Lowercase => *s = (*s).to_lowercase(), + ShellVariableUpdateTransform::Uppercase => *s = (*s).to_uppercase(), + ShellVariableUpdateTransform::Capitalize => { + // This isn't really title-case; only the first character is capitalized. + *s = s.to_lowercase(); + if let Some(c) = s.chars().next() { + s.replace_range(0..1, &c.to_uppercase().to_string()); + } + } + } + } + } + + /// Tries to unset the value stored at the given index in the variable. Returns + /// whether or not a value was unset. + /// + /// # Arguments + /// + /// * `index` - The index at which to unset the value. + pub fn unset_index(&mut self, index: &str) -> Result { + match &mut self.value { + ShellValue::Unset(ty) => match ty { + ShellValueUnsetType::Untyped => Err(error::ErrorKind::NotArray.into()), + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray => { + Ok(false) + } + }, + ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()), + ShellValue::AssociativeArray(values) => Ok(values.remove(index).is_some()), + ShellValue::IndexedArray(values) => { + let key = get_key_for_indexed_array(values, index)?; + Ok(values.remove(&key).is_some()) + } + ShellValue::Dynamic { .. } => Ok(false), + } + } + + /// Returns the variable's value; for dynamic values, this will resolve the value. + /// + /// # Arguments + /// + /// * `shell` - The shell in which the variable is being resolved. + pub fn resolve_value(&self, shell: &Shell) -> ShellValue { + // N.B. We do *not* specially handle a dynamic value that resolves to a dynamic value. + match &self.value { + ShellValue::Dynamic { getter, .. } => getter(shell), + _ => self.value.clone(), + } + } + + /// Returns the canonical attribute flag string for this variable. + pub fn attribute_flags(&self, shell: &Shell) -> String { + let value = self.resolve_value(shell); + + let mut result = String::new(); + + if value.is_indexed_array() { + result.push('a'); + } + if value.is_associative_array() { + result.push('A'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Capitalize + ) { + result.push('c'); + } + if self.is_treated_as_integer() { + result.push('i'); + } + if self.is_treated_as_nameref() { + result.push('n'); + } + if self.is_readonly() { + result.push('r'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Lowercase + ) { + result.push('l'); + } + if self.is_trace_enabled() { + result.push('t'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Uppercase + ) { + result.push('u'); + } + if self.is_exported() { + result.push('x'); + } + + result + } +} + +type DynamicValueGetter = fn(&dyn ShellState) -> ShellValue; +type DynamicValueSetter = fn(&dyn ShellState) -> (); + +/// A shell value. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellValue { + /// A value that has been typed but not yet set. + Unset(ShellValueUnsetType), + /// A string. + String(String), + /// An associative array. + AssociativeArray(BTreeMap), + /// An indexed array. + IndexedArray(BTreeMap), + /// A value that is dynamically computed. + Dynamic { + /// Function that can query the value. + /// TODO(serde): figure out how to serialize/deserialize dynamic values. + #[cfg_attr( + feature = "serde", + serde(skip, default = "default_dynamic_value_getter") + )] + getter: DynamicValueGetter, + /// Function that receives value update requests. + /// TODO(serde): figure out how to serialize/deserialize dynamic values. + #[cfg_attr( + feature = "serde", + serde(skip, default = "default_dynamic_value_setter") + )] + setter: DynamicValueSetter, + }, +} + +#[cfg(feature = "serde")] +fn default_dynamic_value_getter() -> DynamicValueGetter { + |_shell: &dyn ShellState| ShellValue::String(String::new()) +} + +#[cfg(feature = "serde")] +fn default_dynamic_value_setter() -> DynamicValueSetter { + |_shell: &dyn ShellState| {} +} + +/// The type of an unset shell value. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellValueUnsetType { + /// The value is untyped. + Untyped, + /// The value is an associative array. + AssociativeArray, + /// The value is an indexed array. + IndexedArray, +} + +/// A shell value literal; used for assignment. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum ShellValueLiteral { + /// A scalar value. + Scalar(String), + /// An array value. + Array(ArrayLiteral), +} + +impl ShellValueLiteral { + pub(crate) fn fmt_for_tracing(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Scalar(s) => Self::fmt_scalar_for_tracing(s.as_str(), f), + Self::Array(elements) => { + write!(f, "(")?; + for (i, (key, value)) in elements.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + if let Some(key) = key { + write!(f, "[")?; + Self::fmt_scalar_for_tracing(key.as_str(), f)?; + write!(f, "]=")?; + } + Self::fmt_scalar_for_tracing(value.as_str(), f)?; + } + write!(f, ")") + } + } + } + + fn fmt_scalar_for_tracing(s: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let processed = escape::quote_if_needed(s, escape::QuoteMode::SingleQuote); + write!(f, "{processed}") + } +} + +impl Display for ShellValueLiteral { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_for_tracing(f) + } +} + +impl From<&str> for ShellValueLiteral { + fn from(value: &str) -> Self { + Self::Scalar(value.to_owned()) + } +} + +impl From for ShellValueLiteral { + fn from(value: String) -> Self { + Self::Scalar(value) + } +} + +impl From> for ShellValueLiteral { + fn from(value: Vec<&str>) -> Self { + Self::Array(ArrayLiteral( + value.into_iter().map(|s| (None, s.to_owned())).collect(), + )) + } +} + +impl From> for ShellValueLiteral { + fn from(value: Vec) -> Self { + Self::Array(ArrayLiteral(value.into_iter().map(|s| (None, s)).collect())) + } +} + +/// An array literal. +#[derive(Clone, Debug)] +pub struct ArrayLiteral(pub(crate) Vec<(Option, String)>); + +impl ArrayLiteral { + /// Returns an array literal with the given elements, in order. An element + /// with a key is assigned at that key (`[key]=value`); one without is + /// assigned at the next index. + /// + /// # Arguments + /// + /// * `elements` - The elements, each an optional key and a value. + pub const fn new(elements: Vec<(Option, String)>) -> Self { + Self(elements) + } +} + +/// Style for formatting a shell variable's value. +#[derive(Copy, Clone, Debug)] +pub enum FormatStyle { + /// Basic formatting. + Basic, + /// Formatting as appropriate in the `declare` built-in command. + DeclarePrint, +} + +impl ShellValue { + /// Returns whether or not the value is an indexed array, including a declared but unset one. + pub const fn is_indexed_array(&self) -> bool { + matches!( + self, + Self::IndexedArray(_) | Self::Unset(ShellValueUnsetType::IndexedArray) + ) + } + + /// Returns whether or not the value is an associative array, including a declared but unset + /// one. + pub const fn is_associative_array(&self) -> bool { + matches!( + self, + Self::AssociativeArray(_) | Self::Unset(ShellValueUnsetType::AssociativeArray) + ) + } + + /// Returns whether or not the value is an array. + pub const fn is_array(&self) -> bool { + self.is_indexed_array() || self.is_associative_array() + } + + /// Returns whether or not the value is set. + pub const fn is_set(&self) -> bool { + !matches!(self, Self::Unset(_)) + } + + /// Returns a new indexed array value constructed from the given slice of owned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strings(values: S) -> Self + where + S: IntoIterator, + { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.into_iter().enumerate() { + owned_values.insert(i as u64, value); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given slice of unowned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strs(values: &[&str]) -> Self { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.iter().enumerate() { + owned_values.insert(i as u64, (*value).to_string()); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the indexed array from. + pub fn indexed_array_from_literals(literals: ArrayLiteral) -> Self { + let mut values = BTreeMap::new(); + Self::update_indexed_array_from_literals(&mut values, literals); + + Self::IndexedArray(values) + } + + fn update_indexed_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) { + let mut new_key = if let Some((largest_index, _)) = existing_values.last_key_value() { + largest_index + 1 + } else { + 0 + }; + + for (key, value) in literal_values.0 { + if let Some(key) = key { + new_key = key.parse().unwrap_or(0); + } + + existing_values.insert(new_key, value); + new_key += 1; + } + } + + /// Returns a new associative array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the associative array from. + pub fn associative_array_from_literals(literals: ArrayLiteral) -> Result { + let mut values = BTreeMap::new(); + Self::update_associative_array_from_literals(&mut values, literals)?; + + Ok(Self::AssociativeArray(values)) + } + + fn update_associative_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) -> Result<(), error::Error> { + let mut current_key = None; + for (key, value) in literal_values.0 { + if let Some(current_key) = current_key.take() { + if key.is_some() { + return error::unimp("misaligned keys/values in associative array literal"); + } else { + existing_values.insert(current_key, value); + } + } else if let Some(key) = key { + existing_values.insert(key, value); + } else { + current_key = Some(value); + } + } + + if let Some(current_key) = current_key { + existing_values.insert(current_key, String::new()); + } + + Ok(()) + } + + /// Formats the value using the given style. + /// + /// # Arguments + /// + /// * `style` - The style to use for formatting the value. + pub fn format( + &self, + style: FormatStyle, + shell: &Shell, + ) -> Result, error::Error> { + match self { + Self::Unset(_) => Ok("".into()), + Self::String(s) => match style { + FormatStyle::Basic => Ok(escape::quote_if_needed( + s.as_str(), + escape::QuoteMode::SingleQuote, + )), + FormatStyle::DeclarePrint => { + Ok(escape::force_quote(s.as_str(), escape::QuoteMode::DoubleQuote).into()) + } + }, + Self::AssociativeArray(values) => { + let mut result = String::new(); + result.push('('); + + for (key, value) in values { + let formatted_key = + escape::quote_if_needed(key.as_str(), escape::QuoteMode::DoubleQuote); + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + + // N.B. We include an unconditional trailing space character (even after the + // last entry in the associative array) to match standard + // output behavior. + write!(result, "[{formatted_key}]={formatted_value} ")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::IndexedArray(values) => { + let mut result = String::new(); + result.push('('); + + for (i, (key, value)) in values.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + write!(result, "[{key}]={formatted_value}")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.format(style, shell)?.to_string(); + Ok(result.into()) + } + } + } + + /// Tries to retrieve the value stored at the given index in this variable. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value. + pub fn get_at( + &self, + index: &str, + shell: &Shell, + ) -> Result>, error::Error> { + match self { + Self::Unset(_) => Ok(None), + Self::String(s) => { + if index.parse::().unwrap_or(0) == 0 { + Ok(Some(Cow::Borrowed(s))) + } else { + Ok(None) + } + } + Self::AssociativeArray(values) => { + Ok(values.get(index).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::IndexedArray(values) => { + let key = get_key_for_indexed_array(values, index)?; + Ok(values.get(&key).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.get_at(index, shell)?; + Ok(result.map(|s| s.to_string().into())) + } + } + } + + /// Returns the keys of the elements in this variable. + pub fn element_keys(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(_) => vec!["0".to_owned()], + Self::AssociativeArray(array) => array.keys().map(|k| k.to_owned()).collect(), + Self::IndexedArray(array) => array.keys().map(|k| k.to_string()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_keys(shell), + } + } + + /// Returns the values of the elements in this variable. + pub fn element_values(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(s) => vec![s.to_owned()], + Self::AssociativeArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::IndexedArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_values(shell), + } + } + + /// Converts this value to a string. + pub fn to_cow_str(&self, shell: &Shell) -> Cow<'_, str> { + self.try_get_cow_str(shell).unwrap_or(Cow::Borrowed("")) + } + + fn to_cow_str_without_dynamic_support(&self) -> Cow<'_, str> { + self.try_get_cow_str_without_dynamic_support() + .unwrap_or(Cow::Borrowed("")) + } + + /// Tries to convert this value to a string; returns `None` if the value is unset + /// or otherwise doesn't exist. + pub fn try_get_cow_str( + &self, + shell: &Shell, + ) -> Option> { + match self { + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + dynamic_value + .try_get_cow_str(shell) + .map(|s| s.to_string().into()) + } + _ => self.try_get_cow_str_without_dynamic_support(), + } + } + + fn try_get_cow_str_without_dynamic_support(&self) -> Option> { + match self { + Self::Unset(_) => None, + Self::String(s) => Some(Cow::Borrowed(s.as_str())), + Self::AssociativeArray(values) => values.get("0").map(|s| Cow::Borrowed(s.as_str())), + Self::IndexedArray(values) => values.get(&0).map(|s| Cow::Borrowed(s.as_str())), + Self::Dynamic { .. } => None, + } + } + + /// Formats this value as a program string usable in an assignment. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value, if indexing is to be performed. + pub fn to_assignable_str( + &self, + index: Option<&str>, + shell: &Shell, + ) -> Result { + match self { + Self::Unset(_) => Ok(String::new()), + Self::String(s) => Ok(escape::force_quote( + s.as_str(), + escape::QuoteMode::SingleQuote, + )), + Self::AssociativeArray(_) | Self::IndexedArray(_) => { + if let Some(index) = index { + if let Ok(Some(value)) = self.get_at(index, shell) { + Ok(escape::force_quote( + value.as_ref(), + escape::QuoteMode::SingleQuote, + )) + } else { + Ok(String::new()) + } + } else { + Ok(self.format(FormatStyle::DeclarePrint, shell)?.into_owned()) + } + } + Self::Dynamic { getter, .. } => getter(shell).to_assignable_str(index, shell), + } + } +} + +fn get_key_for_indexed_array( + values: &BTreeMap, + index_str: &str, +) -> Result { + let mut index_value = index_str.parse::().unwrap_or(0); + + // Handle negative indices, but check for out-of-range values. A negative index is + // relative to one greater than the maximum index (not the element count, which + // differs for sparse arrays). + #[expect(clippy::cast_possible_wrap)] + if index_value < 0 { + let end = values + .last_key_value() + .map_or(0, |(k, _)| (*k as i64).saturating_add(1)); + index_value += end; + if index_value < 0 { + return Err(error::ErrorKind::ArrayIndexOutOfRange(index_str.to_owned()).into()); + } + } + + // Now that we've confirmed that the index is non-negative, we can safely convert it + // to a u64 without any fuss. + #[expect(clippy::cast_sign_loss)] + Ok(index_value as u64) +} + +impl From<&str> for ShellValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From<&String> for ShellValue { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl From for ShellValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From for ShellValue { + fn from(value: OsString) -> Self { + Self::String(value.to_string_lossy().into_owned()) + } +} + +impl From> for ShellValue { + fn from(values: Vec) -> Self { + Self::indexed_array_from_strings(values) + } +} + +impl From> for ShellValue { + fn from(values: Vec<&str>) -> Self { + Self::indexed_array_from_strs(values.as_slice()) + } +} diff --git a/userland/brush/source/brush-core/src/wellknownvars.rs b/userland/brush/source/brush-core/src/wellknownvars.rs new file mode 100644 index 0000000..fd9e70c --- /dev/null +++ b/userland/brush/source/brush-core/src/wellknownvars.rs @@ -0,0 +1,761 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use rand::RngExt as _; + +use crate::shell::ShellState; +use crate::{Shell, ShellValue, ShellVariable, error, extensions, sys, variables}; + +const BASH_MAJOR: u32 = 5; +const BASH_MINOR: u32 = 2; +const BASH_PATCH: u32 = 37; +const BASH_BUILD: u32 = 1; +const BASH_RELEASE: &str = "release"; +const BASH_MACHINE: &str = "unknown"; + +const DEFAULT_LINENO: usize = 1; + +/// Inherit environment variables from the host process into the shell's environment. +/// +/// # Arguments +/// +/// * `shell` - The shell instance to inherit environment variables into. +pub(crate) fn inherit_env_vars( + shell: &mut Shell, +) -> Result<(), error::Error> { + for (k, v) in sys::env::get_host_env_vars() { + // See if it's a function exported by an ancestor process. + if let Some(func_name) = k.strip_prefix("BASH_FUNC_") + && let Some(func_name) = func_name.strip_suffix("%%") + { + // Intentionally best-effort; don't fail out of the shell if we can't + // parse an incoming function. + if shell.define_func_from_str(func_name, v.as_str()).is_ok() + && let Some(func) = shell.func_mut(func_name) + { + func.export(); + } + + continue; + } + + // Special case OLDPWD for bash compatibility. + if k == "OLDPWD" { + continue; + } + + let mut var = ShellVariable::new(ShellValue::String(v)); + var.export(); + shell.env_mut().set_global(k, var)?; + } + + Ok(()) +} + +#[expect(clippy::too_many_lines)] +pub(crate) fn init_well_known_vars( + shell: &mut Shell, +) -> Result<(), error::Error> { + let shell_version = shell.version().map(ToString::to_string); + shell.env_mut().set_global( + "BRUSH_VERSION", + ShellVariable::new(shell_version.unwrap_or_default()), + )?; + + // BASH + if let Some(shell_name) = shell.current_shell_name().map(|s| s.to_string()) { + shell + .env_mut() + .set_global("BASH", ShellVariable::new(shell_name.clone()))?; + // Initialize $_ to the shell name ($0), unless the shell inherited one: as + // in bash, that one stays exported until $_ is next updated. + if shell.env_str("_").is_none() { + shell.update_last_arg_variable(Some(shell_name)); + } + } + + // BASHOPTS + let mut bashopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options().shopt_optstr().into(), + setter: |_| (), + }); + bashopts_var.set_readonly(); + shell.env_mut().set_global("BASHOPTS", bashopts_var)?; + + // BASHPID + #[cfg(not(target_family = "wasm"))] // ast-grep-ignore: platform-cfg-outside-sys + { + let mut bashpid_var = + ShellVariable::new(ShellValue::String(std::process::id().to_string())); + bashpid_var.treat_as_integer(); + shell.env_mut().set_global("BASHPID", bashpid_var)?; + } + + // BASH_ALIASES + shell.env_mut().set_global( + "BASH_ALIASES", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let values = variables::ArrayLiteral( + shell + .aliases() + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_owned())) + .collect::>(), + ); + + ShellValue::associative_array_from_literals(values) + .unwrap_or_else(|_error| ShellValue::AssociativeArray(BTreeMap::new())) + }, + setter: |_| (), + }), + )?; + + // BASH_ARGC + shell.env_mut().set_global( + "BASH_ARGC", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_argc_value(shell), + setter: |_| (), + }), + )?; + + // BASH_ARGV + shell.env_mut().set_global( + "BASH_ARGV", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_argv_value(shell), + setter: |_| (), + }), + )?; + + // BASH_ARGV0 + shell.env_mut().set_global( + "BASH_ARGV0", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let argv0 = shell.current_shell_name().unwrap_or_default(); + argv0.to_string().into() + }, + // TODO(vars): implement updating BASH_ARGV0 + setter: |_| (), + }), + )?; + + // TODO(vars): implement mutation of BASH_CMDS + shell.env_mut().set_global( + "BASH_CMDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .program_location_cache() + .to_value() + .unwrap_or_else(|_error| ShellValue::AssociativeArray(BTreeMap::new())) + }, + setter: |_| (), + }), + )?; + + // TODO(vars): implement BASH_COMMAND + // TODO(vars): implement BASH_EXECUTION_STRING + + // BASH_LINENO + shell.env_mut().set_global( + "BASH_LINENO", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_lineno_value(shell), + setter: |_| (), + }), + )?; + + // BASH_SOURCE + shell.env_mut().set_global( + "BASH_SOURCE", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_source_value(shell), + setter: |_| (), + }), + )?; + + // BASH_SUBSHELL + shell.env_mut().set_global( + "BASH_SUBSHELL", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.depth().to_string().into(), + setter: |_| (), + }), + )?; + + // BASH_VERSINFO + let mut bash_versinfo_var = ShellVariable::new(ShellValue::indexed_array_from_strs( + [ + BASH_MAJOR.to_string().as_str(), + BASH_MINOR.to_string().as_str(), + BASH_PATCH.to_string().as_str(), + BASH_BUILD.to_string().as_str(), + BASH_RELEASE, + BASH_MACHINE, + ] + .as_slice(), + )); + bash_versinfo_var.set_readonly(); + shell + .env_mut() + .set_global("BASH_VERSINFO", bash_versinfo_var)?; + + // BASH_VERSION + // This is the Bash interface version. See BRUSH_VERSION for its implementation version. + shell.env_mut().set_global( + "BASH_VERSION", + ShellVariable::new(std::format!( + "{BASH_MAJOR}.{BASH_MINOR}.{BASH_PATCH}({BASH_BUILD})-{BASH_RELEASE}" + )), + )?; + + // COMP_WORDBREAKS + let mut default_comp_wordbreaks = String::from(" \t\n\"\'><=;|&(:"); + if shell.options().enable_hostname_completion { + default_comp_wordbreaks.push('@'); + } + + shell.env_mut().set_global( + "COMP_WORDBREAKS", + ShellVariable::new(default_comp_wordbreaks), + )?; + + // DIRSTACK + shell.env_mut().set_global( + "DIRSTACK", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .directory_stack() + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .into() + }, + setter: |_| (), + }), + )?; + + // EPOCHREALTIME + shell.env_mut().set_global( + "EPOCHREALTIME", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + // Match bash: seconds with exactly six fractional (microsecond) digits, + // zero-padded. Tools strip the dot and do integer math on the result + // (atuin computes command durations this way), so the width must be fixed. + std::format!( + "{}.{:06}", + since_epoch.as_secs(), + since_epoch.subsec_micros() + ) + .into() + }, + setter: |_| (), + }), + )?; + + // EPOCHSECONDS + shell.env_mut().set_global( + "EPOCHSECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + since_epoch.as_secs().to_string().into() + }, + setter: |_| (), + }), + )?; + + // EUID + if let Ok(euid) = sys::users::get_effective_uid() { + let mut euid_var = ShellVariable::new(ShellValue::String(format!("{euid}"))); + euid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("EUID", euid_var)?; + } + + // FUNCNAME + shell.env_mut().set_global( + "FUNCNAME", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_funcname_value(shell), + setter: |_| (), + }), + )?; + + // GROUPS + // N.B. We could compute this up front, but we choose to make it dynamic so that we + // don't have to make costly system calls if the user never accesses it. + shell.env_mut().set_global( + "GROUPS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let groups = get_current_user_gids(); + ShellValue::indexed_array_from_strings( + groups.into_iter().map(|gid| gid.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // HISTCMD + let mut histcmd_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .history() + .map_or_else(|| "0".into(), |h| h.count().to_string().into()) + }, + setter: |_| (), + }); + histcmd_var.treat_as_integer(); + shell.env_mut().set_global("HISTCMD", histcmd_var)?; + + // HISTFILE (if not already set) + if !shell.env().is_set("HISTFILE") + && let Some(home_dir) = shell.home_dir() + { + let histfile = home_dir.join(".brush_history"); + shell.env_mut().set_global( + "HISTFILE", + ShellVariable::new(ShellValue::String(histfile.to_string_lossy().to_string())), + )?; + } + + // HOSTNAME + shell.env_mut().set_global( + "HOSTNAME", + ShellVariable::new( + sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ), + )?; + + // HOSTTYPE + shell.env_mut().set_global( + "HOSTTYPE", + ShellVariable::new(std::env::consts::ARCH.to_string()), + )?; + + // IFS + shell + .env_mut() + .set_global("IFS", ShellVariable::new(" \t\n"))?; + + // LINENO + shell.env_mut().set_global( + "LINENO", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_lineno(shell).to_string().into(), + setter: |_| (), + }), + )?; + + // MACHTYPE + shell + .env_mut() + .set_global("MACHTYPE", ShellVariable::new(BASH_MACHINE))?; + + // OLDPWD (initialization) + if !shell.env().is_set("OLDPWD") { + let mut oldpwd_var = + ShellVariable::new(ShellValue::Unset(variables::ShellValueUnsetType::Untyped)); + oldpwd_var.export(); + shell.env_mut().set_global("OLDPWD", oldpwd_var)?; + } + + // OPTERR + shell + .env_mut() + .set_global("OPTERR", ShellVariable::new("1"))?; + + // OPTIND + let mut optind_var = ShellVariable::new("1"); + optind_var.treat_as_integer(); + shell.env_mut().set_global("OPTIND", optind_var)?; + + // OSTYPE + // Match bash's conventional OSTYPE on each platform so that shell scripts + // branching on `[[ $OSTYPE == darwin* ]]` / `linux-gnu*` etc. (Homebrew + // shellenv, nvm, asdf, ...) take the expected path. Real bash includes a + // kernel-version suffix on macOS/BSDs (e.g. `darwin24`); we omit the + // suffix for now since the common patterns all use prefix matching. + let os_type = match std::env::consts::OS { + "linux" => "linux-gnu", + "android" => "linux-android", + "macos" | "ios" | "tvos" | "watchos" | "visionos" => "darwin", + "freebsd" => "freebsd", + "netbsd" => "netbsd", + "openbsd" => "openbsd", + "dragonfly" => "dragonfly", + "solaris" | "illumos" => "solaris", + "windows" => "windows", + _ => "unknown", + }; + shell + .env_mut() + .set_global("OSTYPE", ShellVariable::new(os_type))?; + + // PATH (if not already set) + if !shell.env().is_set("PATH") { + let default_path_str = std::env::join_paths(sys::fs::get_default_executable_search_paths()) + .unwrap_or_else(|_| PathBuf::from("").into()); + shell + .env_mut() + .set_global("PATH", ShellVariable::new(default_path_str))?; + } + + // PIPESTATUS + // TODO(well-known-vars): Investigate what happens if this gets unset. + // TODO(well-known-vars): Investigate if this needs to be saved/preserved across prompt display. + shell.env_mut().set_global( + "PIPESTATUS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + ShellValue::indexed_array_from_strings( + shell.last_pipeline_statuses().iter().map(|s| s.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // PPID + if let Some(ppid) = sys::terminal::get_parent_process_id() { + let mut ppid_var = ShellVariable::new(ppid.to_string()); + ppid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("PPID", ppid_var)?; + } + + // RANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_random_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env_mut().set_global("RANDOM", random_var)?; + + // SECONDS + shell.env_mut().set_global( + "SECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let now = std::time::SystemTime::now(); + let since_last = now + .duration_since(shell.last_stopwatch_time()) + .unwrap_or_default(); + let total_seconds = since_last.as_secs() + u64::from(shell.last_stopwatch_offset()); + total_seconds.to_string().into() + }, + // TODO(vars): implement updating SECONDS + setter: |_| (), + }), + )?; + + // SHELL (if not already set) + if !shell.env().is_set("SHELL") { + // Per docs, this should be the user's default login shell -- not the current shell. + if let Some(default_shell) = sys::users::get_current_user_default_shell() { + shell.env_mut().set_global( + "SHELL", + ShellVariable::new(default_shell.to_string_lossy().to_string()), + )?; + } + } + + // SHELLOPTS + let mut shellopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options().seto_optstr().into(), + setter: |_| (), + }); + shellopts_var.set_readonly(); + shell.env_mut().set_global("SHELLOPTS", shellopts_var)?; + + // SHLVL + let input_shlvl = shell.env_str("SHLVL").unwrap_or_else(|| "0".into()); + let updated_shlvl = input_shlvl.as_ref().parse::().unwrap_or(0) + 1; + let mut shlvl_var = ShellVariable::new(updated_shlvl.to_string()); + shlvl_var.export(); + shell.env_mut().set_global("SHLVL", shlvl_var)?; + + // SRANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_srandom_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env_mut().set_global("SRANDOM", random_var)?; + + // PS1 / PS2 + if shell.options().interactive { + if !shell.env().is_set("PS1") { + shell + .env_mut() + .set_global("PS1", ShellVariable::new(r"\s-\v\$ "))?; + } + + if !shell.env().is_set("PS2") { + shell + .env_mut() + .set_global("PS2", ShellVariable::new("> "))?; + } + } + + // PS4 + if !shell.env().is_set("PS4") { + shell + .env_mut() + .set_global("PS4", ShellVariable::new("+ "))?; + } + + // + // PWD + // + // Reflect our actual working directory. There's a chance + // we inherited an out-of-sync version of the variable. Future updates + // will be handled by set_working_dir(). + // + let pwd = shell.working_dir().to_string_lossy().to_string(); + let mut pwd_var = ShellVariable::new(pwd); + pwd_var.export(); + shell.env_mut().set_global("PWD", pwd_var)?; + + // UID + if let Ok(uid) = sys::users::get_current_uid() { + let mut uid_var = ShellVariable::new(ShellValue::String(format!("{uid}"))); + uid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("UID", uid_var)?; + } + + Ok(()) +} + +/// Returns a list of the current user's group IDs, with the effective GID at the front. +fn get_current_user_gids() -> Vec { + let mut groups = sys::users::get_user_group_ids().unwrap_or_default(); + + // If the effective GID is present but not in the first position in the list, then move + // it there. + if let Ok(gid) = sys::users::get_effective_gid() { + if let Some(index) = groups.iter().position(|&g| g == gid) { + if index > 0 { + // Move it to the front. + groups.remove(index); + groups.insert(0, gid); + } + } + } + + groups +} + +fn get_random_value(_shell: &dyn ShellState) -> ShellValue { + let mut rng = rand::rng(); + let num = rng.random_range(0..32768); + let str = num.to_string(); + str.into() +} + +fn get_srandom_value(_shell: &dyn ShellState) -> ShellValue { + let mut rng = rand::rng(); + let num: u32 = rng.random(); + let str = num.to_string(); + str.into() +} + +fn get_funcname_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + if stack.iter_function_calls().next().is_none() { + ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray) + } else { + // When in a function, include both functions and sourced scripts in the stack + stack + .iter() + .filter_map(|frame| match &frame.frame_type { + crate::callstack::FrameType::Function(func) => Some(func.function_name.as_str()), + crate::callstack::FrameType::Script(script) => { + // Only include sourced scripts, not run scripts + if matches!(script.call_type, crate::callstack::ScriptCallType::Source) { + Some("source") + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) + | crate::callstack::FrameType::Eval + | crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +fn get_bash_lineno_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + // BASH_LINENO[$i] contains the line number where FUNCNAME[$i] was called + // This is extracted from the call_site of each frame + if stack.iter_function_calls().next().is_none() { + ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray) + } else { + stack + .iter() + .enumerate() + .filter_map(|(frame_idx, frame)| match &frame.frame_type { + crate::callstack::FrameType::Function(..) + | crate::callstack::FrameType::Script(..) => { + let caller_idx = frame_idx + 1; + if caller_idx < stack.depth() { + let caller_frame = &stack[caller_idx]; + Some( + caller_frame + .current_line() + .unwrap_or(DEFAULT_LINENO) + .to_string(), + ) + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) + | crate::callstack::FrameType::Eval + | crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +fn get_bash_source_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + if stack.iter_function_calls().next().is_none() { + stack + .iter_script_calls() + .map(|frame| frame.source_info.source.clone()) + .collect::>() + .into() + } else { + // When in a function, include both functions and sourced scripts in the stack + // This mirrors the FUNCNAME array structure + stack + .iter() + .filter_map(|frame| match &frame.frame_type { + crate::callstack::FrameType::Function(func) => { + Some(func.function.source().source.clone()) + } + crate::callstack::FrameType::Script(script) => { + // Only include sourced scripts (matching the "source" in FUNCNAME) + if matches!(script.call_type, crate::callstack::ScriptCallType::Source) { + Some(script.source_info.source.clone()) + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) | crate::callstack::FrameType::Eval => { + None + } + crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +/// Returns the positional arguments that the given call-stack frame contributes +/// to `BASH_ARGC`/`BASH_ARGV`, or `None` if the frame contributes nothing. +/// +/// Even without extended debugging mode, bash tracks script-level frames (the +/// top-level invocation plus `source`/`.` calls); only *function* frames are +/// gated behind extended debugging mode. A sourced script invoked without +/// explicit positional arguments contributes its own name (the path passed to +/// `.`/`source`) as a single argument, matching bash. +fn frame_bash_args( + frame: &crate::callstack::Frame, + extdebug: bool, +) -> Option> { + use std::borrow::Cow; + match &frame.frame_type { + crate::callstack::FrameType::Script(call) => { + if matches!(call.call_type, crate::callstack::ScriptCallType::Source) + && frame.args.is_empty() + { + Some(Cow::Owned(vec![call.name().into_owned()])) + } else { + Some(Cow::Borrowed(frame.args.as_slice())) + } + } + crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => { + Some(Cow::Borrowed(frame.args.as_slice())) + } + crate::callstack::FrameType::Function(..) => { + extdebug.then_some(Cow::Borrowed(frame.args.as_slice())) + } + crate::callstack::FrameType::TrapHandler(_) | crate::callstack::FrameType::Eval => None, + } +} + +/// Returns `true` if `BASH_ARGC`/`BASH_ARGV` should report an empty stack. +/// +/// Without extended debugging mode, bash leaves these arrays empty whenever a +/// function is active on the call stack. +fn bash_args_suppressed(shell: &dyn ShellState) -> bool { + !shell.options().enable_debugger && shell.call_stack().in_function() +} + +fn get_bash_argc_value(shell: &dyn ShellState) -> variables::ShellValue { + if bash_args_suppressed(shell) { + return ShellValue::indexed_array_from_strs(&[]); + } + + let extdebug = shell.options().enable_debugger; + shell + .call_stack() + .iter() + .filter_map(|frame| frame_bash_args(frame, extdebug).map(|args| args.len().to_string())) + .collect::>() + .into() +} + +fn get_bash_argv_value(shell: &dyn ShellState) -> variables::ShellValue { + if bash_args_suppressed(shell) { + return ShellValue::indexed_array_from_strs(&[]); + } + + let extdebug = shell.options().enable_debugger; + let mut argv = Vec::new(); + + for frame in shell.call_stack().iter() { + if let Some(args) = frame_bash_args(frame, extdebug) { + // Push args in reverse order per frame (last arg at lowest index = top of stack) + argv.extend(args.iter().cloned().rev()); + } + } + + argv.into() +} + +fn get_lineno(shell: &dyn ShellState) -> usize { + shell + .call_stack() + .current_frame() + .and_then(|frame| frame.current_line()) + .unwrap_or(DEFAULT_LINENO) +} diff --git a/userland/brush/source/brush-core/tests/builtin_contract_tests.rs b/userland/brush/source/brush-core/tests/builtin_contract_tests.rs new file mode 100644 index 0000000..c5fd1d9 --- /dev/null +++ b/userland/brush/source/brush-core/tests/builtin_contract_tests.rs @@ -0,0 +1,435 @@ +//! Tests of the contract between the shell and its builtins, through a +//! builtin that parses its arguments by hand. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::io::{Read, Seek, Write}; + +use anyhow::{Context as _, Result}; +use brush_core::builtins::{ + Builtin, ContentOptions, ContentType, DeclarationRole, Invocation, ParseArgs, Registration, + UsageError, +}; +use brush_core::openfiles::OpenFiles; +use brush_core::{CommandArg, ExecutionContext, ExecutionResult, ShellExtensions}; + +/// Describes arguments by kind and text, e.g. `[x=1 2 (assignment), y]`. +fn describe(args: &[CommandArg]) -> String { + let args: Vec<_> = args + .iter() + .map(|arg| match arg { + CommandArg::String(s) => s.clone(), + CommandArg::Assignment(a) => format!("{a} (assignment)"), + }) + .collect(); + format!("[{}]", args.join(", ")) +} + +/// Writes how it was invoked: the name its parser saw, the context's command +/// name, and its arguments. A declaration utility when `DECLARATION`. +struct Show { + name: String, + args: Vec, +} + +impl ParseArgs for Show { + const DECLARATION_ROLE: DeclarationRole = if DECLARATION { + DeclarationRole::Utility + } else { + DeclarationRole::None + }; + + fn content( + name: &str, + content_type: ContentType, + options: &ContentOptions, + ) -> Result { + Ok(format!( + "{name}: {content_type:?} colorized={}\n", + options.colorized + )) + } + + fn parse(invocation: Invocation<'_>) -> Result { + Ok(Self { + name: invocation.name().to_owned(), + args: invocation.into_args(), + }) + } +} + +impl Builtin for Show { + type Error = brush_core::Error; + + #[allow( + clippy::unused_async_trait_impl, + reason = "the trait's method is async; this one has nothing to wait for" + )] + async fn execute( + self, + context: ExecutionContext<'_, SE>, + ) -> Result { + writeln!( + context.stdout(), + "{} ({}) {}", + self.name, + context.command_name, + describe(&self.args) + )?; + Ok(ExecutionResult::success()) + } +} + +/// The error `Picky` fails with. +#[derive(Debug, thiserror::Error)] +#[error("refused {0}")] +struct PickyError(String); + +impl brush_core::BuiltinError for PickyError {} + +impl From<&PickyError> for brush_core::ExecutionExitCode { + fn from(_: &PickyError) -> Self { + Self::Custom(42) + } +} + +/// Rejects the argument `bad` while parsing, asks for its help on `--help`, +/// fails on `fail`, returns status 7 for `seven`, and otherwise writes `ran`. +struct Picky { + arg: Option, +} + +impl ParseArgs for Picky { + fn content( + name: &str, + _content_type: ContentType, + _options: &ContentOptions, + ) -> Result { + Ok(format!("{name} - picky\n")) + } + + fn parse(invocation: Invocation<'_>) -> Result { + let name = invocation.name(); + let mut args = invocation.args().iter().map(ToString::to_string); + let arg = args.next(); + if arg.as_deref() == Some("--help") { + return Err(UsageError::help(format!("{name} - picky help"))); + } + if arg.as_deref() == Some("bad") { + return Err(UsageError::new(format!("{name}: bad: invalid argument"))); + } + if args.next().is_some() { + return Err(UsageError::new(format!("{name}: too many arguments"))); + } + Ok(Self { arg }) + } +} + +impl Builtin for Picky { + type Error = PickyError; + + #[allow( + clippy::unused_async_trait_impl, + reason = "the trait's method is async; this one has nothing to wait for" + )] + async fn execute( + self, + context: ExecutionContext<'_, SE>, + ) -> Result { + match self.arg.as_deref() { + Some("fail") => Err(PickyError("to run".to_owned())), + Some("seven") => Ok(ExecutionResult::new(7)), + _ => { + writeln!(context.stdout(), "ran").map_err(|e| PickyError(e.to_string()))?; + Ok(ExecutionResult::success()) + } + } + } +} + +async fn new_shell() -> Result { + Ok(brush_core::Shell::builder() + .builtin("show", Registration::of::>()) + .builtin("also", Registration::of::>()) + .builtin("decl", Registration::of::>()) + .builtin("picky", Registration::of::()) + .builtin("fussy", Registration::of::()) + .build() + .await?) +} + +/// What a command did. +#[derive(Debug, PartialEq, Eq)] +struct Output { + status: u8, + stdout: String, + stderr: String, +} + +/// Runs `command`, capturing its standard output and error. +async fn run(shell: &mut brush_core::Shell, command: &str) -> Result { + let mut stdout = tempfile::tempfile()?; + let mut stderr = tempfile::tempfile()?; + let mut params = shell.default_exec_params(); + params.set_fd(OpenFiles::STDOUT_FD, stdout.try_clone()?.into()); + params.set_fd(OpenFiles::STDERR_FD, stderr.try_clone()?.into()); + + let result = shell + .run_string(command, &brush_core::SourceInfo::default(), ¶ms) + .await?; + + Ok(Output { + status: u8::from(result.exit_code), + stdout: read_back(&mut stdout)?, + stderr: read_back(&mut stderr)?, + }) +} + +fn read_back(file: &mut std::fs::File) -> Result { + let mut text = String::new(); + file.rewind()?; + file.read_to_string(&mut text)?; + Ok(text) +} + +fn success(stdout: &str) -> Output { + Output { + status: 0, + stdout: stdout.to_owned(), + stderr: String::new(), + } +} + +#[tokio::test] +async fn a_builtin_is_parsed_under_the_name_it_was_invoked_under() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, "show a; also b").await?, + success("show (show) [a]\nalso (also) [b]\n") + ); + Ok(()) +} + +#[tokio::test] +async fn a_builtin_s_arguments_arrive_as_expanded() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, r#"v='1 2'; show "$v" $v x=$v '' --"#).await?, + success("show (show) [1 2, 1, 2, x=1, 2, , --]\n") + ); + Ok(()) +} + +#[tokio::test] +async fn a_declaration_utility_receives_its_assignments_unsplit() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, "v='1 2'; decl -a x=$v y+=$v z").await?, + success("decl (decl) [-a, x=1 2 (assignment), y+=1 2 (assignment), z]\n") + ); + Ok(()) +} + +#[tokio::test] +async fn a_usage_error_is_written_as_it_is_and_the_builtin_does_not_run() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, "fussy bad").await?, + Output { + status: 2, + stdout: String::new(), + stderr: "fussy: bad: invalid argument\n".to_owned(), + } + ); + assert_eq!( + run(&mut shell, "picky a b").await?.stderr, + "picky: too many arguments\n" + ); + Ok(()) +} + +#[tokio::test] +async fn a_usage_error_does_not_end_the_script() -> Result<()> { + let mut shell = new_shell().await?; + let output = run(&mut shell, "picky bad; echo $?; picky").await?; + assert_eq!(output.status, 0); + assert_eq!(output.stdout, "2\nran\n"); + Ok(()) +} + +#[tokio::test] +async fn a_help_request_writes_the_help_to_stdout_and_fails() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, "fussy --help").await?, + Output { + status: 2, + stdout: "fussy - picky help\n".to_owned(), + stderr: String::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn a_help_request_with_nowhere_to_write_still_fails_quietly() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run( + &mut shell, + "picky --help >&-; echo $?; picky --help 1 Result<()> { + let mut shell = new_shell().await?; + let output = run(&mut shell, "fussy fail").await?; + assert_eq!(output.status, 42); + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, "error: fussy: refused to run\n"); + Ok(()) +} + +#[tokio::test] +async fn a_builtin_s_result_is_the_command_s_status() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!( + run(&mut shell, "picky seven; echo $?").await?, + success("7\n") + ); + Ok(()) +} + +#[tokio::test] +async fn a_registration_runs_its_builtin_given_the_name_first() -> Result<()> { + let mut shell = new_shell().await?; + let registration = shell.builtins().get("show").context("no show")?.clone(); + let mut stdout = tempfile::tempfile()?; + let mut params = shell.default_exec_params(); + params.set_fd(OpenFiles::STDOUT_FD, stdout.try_clone()?.into()); + + let context = ExecutionContext::new(&mut shell, "context-name".to_owned(), params.clone()); + let result = registration + .execute(context, vec!["arg-name".into(), "a".into()]) + .await?; + assert!(result.is_success()); + + // With no name among the arguments, the builtin goes by the context's. + let context = ExecutionContext::new(&mut shell, "context-name".to_owned(), params); + let result = registration.execute(context, vec![]).await?; + assert!(result.is_success()); + + assert_eq!( + read_back(&mut stdout)?, + "arg-name (context-name) [a]\ncontext-name (context-name) []\n" + ); + Ok(()) +} + +#[tokio::test] +async fn a_registration_s_content_comes_from_its_builtin() -> Result<()> { + let shell = new_shell().await?; + let registration = shell.builtins().get("show").context("no show")?; + assert_eq!( + registration.content( + "alias", + ContentType::ShortUsage, + &ContentOptions { colorized: true } + )?, + "alias: ShortUsage colorized=true\n" + ); + assert_eq!( + registration.content( + "show", + ContentType::DetailedHelp, + &ContentOptions::default() + )?, + "show: DetailedHelp colorized=false\n" + ); + Ok(()) +} + +#[tokio::test] +async fn a_disabled_builtin_is_not_found() -> Result<()> { + let mut shell = new_shell().await?; + shell + .builtins_mut() + .get_mut("show") + .context("show isn't registered")? + .set_enabled(false); + let output = run(&mut shell, "show a").await?; + assert_eq!(output.status, 127); + assert_eq!(output.stdout, ""); + // Its other names are separate registrations. + assert_eq!( + run(&mut shell, "also a").await?, + success("also (also) [a]\n") + ); + Ok(()) +} + +#[tokio::test] +async fn only_an_enabled_declaration_utility_takes_assignments_unsplit() -> Result<()> { + let mut shell = new_shell().await?; + // A function shadowing the builtin runs instead of it, but the words were + // already expanded by the builtin's role. + let script = "decl() { show \"$@\"; }; v='1 2'; decl x=$v"; + assert_eq!( + run(&mut shell, script).await?, + success("show (show) [x=1 2]\n") + ); + + shell + .builtins_mut() + .get_mut("decl") + .context("decl isn't registered")? + .set_enabled(false); + assert_eq!( + run(&mut shell, script).await?, + success("show (show) [x=1, 2]\n") + ); + Ok(()) +} + +/// A registration can be made at compile time. +const SPECIAL_SHOW: Registration = + Registration::of::>().special(); + +#[test] +fn a_registration_can_be_made_at_compile_time() { + assert!(SPECIAL_SHOW.is_special()); + assert_eq!(SPECIAL_SHOW.declaration_role(), DeclarationRole::None); +} + +#[test] +fn a_registration_records_what_it_was_given() { + type SE = brush_core::extensions::DefaultShellExtensions; + + let plain = Registration::::of::>(); + assert!(plain.is_enabled()); + assert!(!plain.is_special()); + assert_eq!(plain.declaration_role(), DeclarationRole::None); + + // A builtin that doesn't say is not a declaration utility. + let picky = Registration::::of::(); + assert_eq!(picky.declaration_role(), DeclarationRole::None); + + let declaration = Registration::::of::>(); + assert_eq!(declaration.declaration_role(), DeclarationRole::Utility); + + let mut special = Registration::::of::>().special(); + assert!(special.is_special() && special.is_enabled()); + assert_eq!(special.declaration_role(), DeclarationRole::Utility); + + special.set_enabled(false); + let copy = special.clone(); + assert!(!copy.is_enabled() && copy.is_special()); + special.set_enabled(true); + assert!(!copy.is_enabled(), "a clone's flags are its own"); +} diff --git a/userland/brush/source/brush-core/tests/builtin_state_tests.rs b/userland/brush/source/brush-core/tests/builtin_state_tests.rs new file mode 100644 index 0000000..5a2e932 --- /dev/null +++ b/userland/brush/source/brush-core/tests/builtin_state_tests.rs @@ -0,0 +1,107 @@ +//! Tests of the state builtins keep in the shell between invocations. + +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Result; +use brush_core::{ExecutionResult, builtins}; + +/// Adds the step its name gives (`by`) to a running total, and returns +/// the total as its status. +struct Counter {} + +/// The total the counters share: their state. +#[derive(Clone, Default)] +struct Total(u8); + +/// A state no builtin uses. +#[derive(Clone, Default)] +struct UnusedState; + +impl builtins::ParseArgs for Counter { + fn content( + name: &str, + _content_type: builtins::ContentType, + _options: &builtins::ContentOptions, + ) -> Result { + Ok(format!("{name} - add to a running total\n")) + } + + fn parse(_invocation: builtins::Invocation<'_>) -> Result { + Ok(Self {}) + } +} + +impl builtins::Builtin for Counter { + type Error = brush_core::Error; + + #[allow( + clippy::unused_async_trait_impl, + reason = "the trait's method is async; this one has nothing to wait for" + )] + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let step: u8 = context + .command_name + .strip_prefix("by") + .and_then(|step| step.parse().ok()) + .unwrap_or_default(); + let total = context.state_mut::(); + total.0 += step; + Ok(ExecutionResult::new(total.0)) + } +} + +async fn new_shell() -> Result { + Ok(brush_core::Shell::builder() + .builtin("by1", builtins::Registration::of::()) + .builtin("by10", builtins::Registration::of::()) + .build() + .await?) +} + +async fn run(shell: &mut brush_core::Shell, command: &str) -> Result { + let params = shell.default_exec_params(); + let result = shell + .run_string(command, &brush_core::SourceInfo::default(), ¶ms) + .await?; + Ok(u8::from(result.exit_code)) +} + +#[tokio::test] +async fn a_builtin_s_state_follows_subshell_rules() -> Result<()> { + let mut shell = new_shell().await?; + // A subshell starts from the shell's state and keeps its own changes. + for (command, expected) in [("by1", 1), ("(by1; by1)", 3), ("by1", 2)] { + assert_eq!(run(&mut shell, command).await?, expected, "{command}"); + } + // The state is readable from outside, by its type; one that no builtin + // has used isn't there. + assert_eq!(shell.builtin_state::().map(|t| t.0), Some(2)); + assert!(shell.builtin_state::().is_none()); + Ok(()) +} + +#[tokio::test] +async fn a_state_is_kept_by_its_type_whatever_the_builtin_s_name() -> Result<()> { + let mut shell = new_shell().await?; + assert_eq!(run(&mut shell, "by1").await?, 1); + assert_eq!(run(&mut shell, "by10").await?, 11); + assert_eq!(run(&mut shell, "by1").await?, 12); + Ok(()) +} + +#[tokio::test] +async fn a_state_set_from_outside_is_where_builtins_start() -> Result<()> { + let mut shell = new_shell().await?; + // A builtin starts from the state set, not from its default. + shell.set_builtin_state(Total(40)); + assert_eq!(run(&mut shell, "by1").await?, 41); + // Setting it again replaces the state a builtin changed. + shell.set_builtin_state(Total(5)); + assert_eq!(run(&mut shell, "by10").await?, 15); + assert_eq!(shell.builtin_state::().map(|t| t.0), Some(15)); + Ok(()) +} diff --git a/userland/brush/source/brush-core/tests/cancellation_tests.rs b/userland/brush/source/brush-core/tests/cancellation_tests.rs new file mode 100644 index 0000000..a728949 --- /dev/null +++ b/userland/brush/source/brush-core/tests/cancellation_tests.rs @@ -0,0 +1,96 @@ +//! Tests that cancelling a command partway through -- dropping its future, as a front end +//! does when the user hits Ctrl-C during completion -- leaves the shell as it was before +//! the command started. + +#![cfg(unix)] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::time::Duration; + +use anyhow::Result; +use brush_core::traps::TrapSignal; + +/// A command that runs until it's cancelled. +const BLOCK: &str = "/bin/sleep 30"; + +async fn new_shell() -> Result { + Ok(brush_core::Shell::builder() + .do_not_inherit_env(true) + .skip_well_known_vars(true) + // Don't leave `sleep` running once a test cancels it. + .kill_external_commands_on_drop(true) + .build() + .await?) +} + +/// Waits on `run` until it's had time to block, then cancels it. +async fn cancel(run: impl Future) { + let finished = tokio::time::timeout(Duration::from_millis(250), run).await; + assert!( + finished.is_err(), + "the command finished before it was cancelled" + ); +} + +async fn run_and_cancel(shell: &mut brush_core::Shell, script: &str) { + let params = shell.default_exec_params(); + cancel(shell.run_string(script, &brush_core::SourceInfo::default(), ¶ms)).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_function_is_left() -> Result<()> { + let mut shell = new_shell().await?; + + run_and_cancel(&mut shell, &format!("f() {{ g; }}; g() {{ {BLOCK}; }}; f")).await; + + assert!(!shell.in_function()); + assert_eq!(shell.call_stack().function_call_depth(), 0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_command_drops_its_assignments() -> Result<()> { + let mut shell = new_shell().await?; + + run_and_cancel(&mut shell, &format!("f() {{ {BLOCK}; }}; FOO=1 f")).await; + + assert!(shell.env_str("FOO").is_none()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_sourced_script_is_left() -> Result<()> { + let mut shell = new_shell().await?; + let script = tempfile::NamedTempFile::new()?; + std::fs::write(script.path(), BLOCK)?; + + let params = shell.default_exec_params(); + cancel(shell.source_script(script.path(), std::iter::empty::(), ¶ms)).await; + + assert!(!shell.in_sourced_script()); + assert_eq!(shell.call_stack().script_source_depth(), 0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_trap_handler_is_left() -> Result<()> { + let mut shell = new_shell().await?; + shell.traps_mut().register_handler( + TrapSignal::Debug, + format!("/bin/false; {BLOCK}"), + brush_core::SourceInfo::default(), + ); + shell.set_last_exit_status(7); + + run_and_cancel(&mut shell, "true").await; + + assert!(!shell.call_stack().is_trap_signal_active(TrapSignal::Debug)); + assert!(shell.call_stack().current_frame().is_none()); + assert_eq!(shell.last_exit_status(), 7); + + Ok(()) +} diff --git a/userland/brush/source/brush-core/tests/history_tests.rs b/userland/brush/source/brush-core/tests/history_tests.rs new file mode 100644 index 0000000..35fb65b --- /dev/null +++ b/userland/brush/source/brush-core/tests/history_tests.rs @@ -0,0 +1,23 @@ +//! Tests of history that depend on the platform: here, a device that's always full. + +#![cfg(target_os = "linux")] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Result; +use brush_core::history::{History, Item}; + +/// A flush that fails leaves the items it was to write unsaved, so a later one retries them +/// rather than losing them (e.g. after a full disk). +#[test] +fn failed_flush_leaves_items_unsaved() -> Result<()> { + let mut history = History::default(); + for line in ["a", "b", "c"] { + history.add(Item::new(line))?; + } + + assert!(history.flush("/dev/full", true, true, false).is_err()); + assert!(history.iter().all(|item| item.dirty)); + + Ok(()) +} diff --git a/userland/brush/source/brush-core/tests/kill_on_drop_tests.rs b/userland/brush/source/brush-core/tests/kill_on_drop_tests.rs new file mode 100644 index 0000000..b14b332 --- /dev/null +++ b/userland/brush/source/brush-core/tests/kill_on_drop_tests.rs @@ -0,0 +1,162 @@ +//! Integration tests for the opt-in `kill_external_commands_on_drop` option: +//! that a child outlives its shell by default, and is reaped when the option is set. +//! +//! Note: these tear down the whole runtime, not just the `Shell`. A backgrounded +//! command runs in a detached `tokio` task owning a *clone* of the shell; the +//! shell's job table holds only the task's `TaskHandle`, and dropping that +//! detaches rather than aborts. Dropping the tasks is what drops the `Child`, +//! where `kill_on_drop` takes effect. + +#![cfg(unix)] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn, clippy::expect_used)] + +use std::time::{Duration, Instant}; + +use anyhow::Result; + +/// How long to wait for a polled condition to come true. +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Returns whether a process is still running. +/// +/// A killed-but-unreaped process lingers as a zombie, and `kill -0` reports +/// success for one, so this inspects the process *state* and treats `Z` as not +/// running. +fn is_running(pid: u32) -> bool { + let output = std::process::Command::new("ps") + .args(["-o", "state=", "-p", pid.to_string().as_str()]) + .output() + .expect("failed to run `ps`"); + + let state = String::from_utf8_lossy(&output.stdout); + let state = state.trim(); + !state.is_empty() && !state.starts_with('Z') +} + +/// Polls `condition` until it holds, returning whether it did within [`TIMEOUT`]. +fn poll_until(mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + TIMEOUT; + loop { + if condition() { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Builds a shell with the option set as requested, backgrounds a long-running +/// external child, tears the whole runtime down, and returns the child's pid so +/// the caller can check whether it survived. +fn spawn_child_then_tear_down(kill_on_drop: bool) -> Result { + let dir = tempfile::tempdir()?; + let pid_file = dir.path().join("pid"); + + // A multi-threaded runtime is required: the backgrounded command runs on a + // separate task, and the pid poll below blocks a worker. + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .build()?; + + // `/bin/sh` is the shell's direct child and `exec`s into `sleep`, so the pid + // it reports is the process the shell holds a handle to. No brush builtin is + // involved: the child is named by absolute path, and the inner `PATH` + // assignment is interpreted by `sh` itself. + let script = std::format!( + "/bin/sh -c 'PATH=/bin:/usr/bin; echo $$ > {}; exec sleep 300' &", + pid_file.display() + ); + + let pid = runtime.block_on(async { + let mut shell = brush_core::Shell::builder() + .do_not_inherit_env(true) + .skip_well_known_vars(true) + .kill_external_commands_on_drop(kill_on_drop) + .build() + .await?; + + let params = shell.default_exec_params(); + let result = shell + .run_string(script.as_str(), &brush_core::SourceInfo::default(), ¶ms) + .await?; + anyhow::ensure!(result.is_success(), "starting the background job failed"); + + // Wait for the child to report its own pid. + let mut pid = None; + poll_until(|| { + pid = std::fs::read_to_string(pid_file.as_path()) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + pid.is_some() + }); + pid.ok_or_else(|| anyhow::anyhow!("child never reported its pid")) + })?; + + assert!( + is_running(pid), + "child {pid} should be running before teardown" + ); + + // Tear everything down: this drops the task that owns the child, and with it + // the `Child` handle that carries the kill-on-drop request. + drop(runtime); + + Ok(pid) +} + +/// With the option ON, tearing the shell down reaps the child it spawned. +#[test] +fn child_is_killed_on_teardown_with_option_on() -> Result<()> { + let pid = spawn_child_then_tear_down(true)?; + + assert!( + poll_until(|| !is_running(pid)), + "with the option enabled, child {pid} should have been killed on teardown" + ); + + Ok(()) +} + +/// With the option OFF (the default), the child outlives teardown, exactly as it +/// does today. This is the behavior real shells depend on. +#[test] +fn child_outlives_teardown_by_default() -> Result<()> { + let pid = spawn_child_then_tear_down(false)?; + + // Give the child every chance to die, so a regression to + // kill-on-drop-by-default is caught rather than raced past. + std::thread::sleep(Duration::from_millis(500)); + let survived = is_running(pid); + + // This test intentionally leaves a live process behind, so clean it up + // regardless of the assertion's outcome. + let _ = std::process::Command::new("kill") + .args(["-9", pid.to_string().as_str()]) + .status(); + + assert!(survived, "by default child {pid} must outlive its shell"); + + Ok(()) +} + +/// The option must default to off, so merely adding it changes nothing for +/// existing consumers. +#[tokio::test] +async fn option_defaults_to_disabled() -> Result<()> { + let shell = brush_core::Shell::builder() + .do_not_inherit_env(true) + .skip_well_known_vars(true) + .build() + .await?; + + assert!( + !shell.options().kill_external_commands_on_drop, + "kill_external_commands_on_drop must default to disabled" + ); + + Ok(()) +} diff --git a/userland/brush/source/brush-core/tests/readme_snippet.rs b/userland/brush/source/brush-core/tests/readme_snippet.rs new file mode 100644 index 0000000..75bcf4b --- /dev/null +++ b/userland/brush/source/brush-core/tests/readme_snippet.rs @@ -0,0 +1,50 @@ +//! Keeps the embedding snippet in the repository `README.md` identical to the one +//! compiled and run as `examples/readme.rs`. + +#![cfg(test)] + +const README: &str = include_str!("../../README.md"); +const EXAMPLE: &str = include_str!("../examples/readme.rs"); + +/// Both files may be checked out with CRLF line endings on Windows. +fn normalized(text: &str) -> String { + text.replace("\r\n", "\n") +} + +/// The first fenced `rust` block in the README. +fn readme_snippet() -> Option { + let readme = normalized(README); + let (_, rest) = readme.split_once("```rust\n")?; + let (snippet, _) = rest.split_once("```")?; + Some(snippet.to_owned()) +} + +/// The region between the `readme:start` and `readme:end` markers, with the +/// function body's indentation removed. +fn example_snippet() -> Option { + let example = normalized(EXAMPLE); + let (_, rest) = example.split_once("// readme:start\n")?; + let (body, _) = rest.split_once(" // readme:end")?; + let body = body + .lines() + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + .collect::>() + .join("\n"); + Some(body + "\n") +} + +#[test] +fn readme_snippet_matches_example() { + let readme = readme_snippet().unwrap_or_default(); + let example = example_snippet().unwrap_or_default(); + + assert!(!readme.is_empty(), "no ```rust block found in README.md"); + assert!( + !example.is_empty(), + "no readme:start/readme:end markers found in examples/readme.rs" + ); + assert_eq!( + readme, example, + "the README.md snippet differs from examples/readme.rs" + ); +} diff --git a/userland/brush/source/brush-coreutils-builtins/Cargo.toml b/userland/brush/source/brush-coreutils-builtins/Cargo.toml new file mode 100644 index 0000000..4dfcc2b --- /dev/null +++ b/userland/brush/source/brush-coreutils-builtins/Cargo.toml @@ -0,0 +1,279 @@ +[package] +name = "brush-coreutils-builtins" +description = "Optional coreutils builtins for brush-shell (powered by uutils/coreutils)" +version = "0.1.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[features] +# No utilities are enabled by default, so plain `cargo build --workspace` +# pays only for the scaffolding. Consumers opt into the full set by enabling +# `coreutils.all` (or individual `coreutils.` features). +default = [] + +'coreutils.all' = [ + 'coreutils.arch', + 'coreutils.base32', + 'coreutils.base64', + 'coreutils.basename', + 'coreutils.basenc', + 'coreutils.cat', + 'coreutils.cksum', + 'coreutils.b2sum', + 'coreutils.md5sum', + 'coreutils.sha1sum', + 'coreutils.sha224sum', + 'coreutils.sha256sum', + 'coreutils.sha384sum', + 'coreutils.sha512sum', + 'coreutils.comm', + 'coreutils.cp', + 'coreutils.csplit', + 'coreutils.cut', + 'coreutils.date', + 'coreutils.dd', + 'coreutils.df', + 'coreutils.dir', + 'coreutils.dircolors', + 'coreutils.dirname', + 'coreutils.du', + 'coreutils.echo', + 'coreutils.env', + 'coreutils.expand', + 'coreutils.expr', + 'coreutils.factor', + 'coreutils.false', + 'coreutils.fmt', + 'coreutils.fold', + 'coreutils.head', + 'coreutils.hostname', + 'coreutils.join', + 'coreutils.link', + 'coreutils.ln', + 'coreutils.ls', + 'coreutils.mkdir', + 'coreutils.mktemp', + 'coreutils.more', + 'coreutils.mv', + 'coreutils.nl', + 'coreutils.nproc', + 'coreutils.numfmt', + 'coreutils.od', + 'coreutils.paste', + 'coreutils.pr', + 'coreutils.printenv', + 'coreutils.printf', + 'coreutils.ptx', + 'coreutils.pwd', + 'coreutils.readlink', + 'coreutils.realpath', + 'coreutils.rm', + 'coreutils.rmdir', + 'coreutils.seq', + 'coreutils.shred', + 'coreutils.shuf', + 'coreutils.sleep', + 'coreutils.sort', + 'coreutils.split', + 'coreutils.sum', + 'coreutils.sync', + 'coreutils.tac', + 'coreutils.tail', + 'coreutils.tee', + 'coreutils.test', + 'coreutils.touch', + 'coreutils.tr', + 'coreutils.true', + 'coreutils.truncate', + 'coreutils.tsort', + 'coreutils.uname', + 'coreutils.unexpand', + 'coreutils.uniq', + 'coreutils.unlink', + 'coreutils.vdir', + 'coreutils.wc', + 'coreutils.whoami', + 'coreutils.yes', +] + +'coreutils.arch' = ["dep:uu_arch"] +'coreutils.base32' = ["dep:uu_base32"] +'coreutils.base64' = ["dep:uu_base64"] +'coreutils.basename' = ["dep:uu_basename"] +'coreutils.basenc' = ["dep:uu_basenc"] +'coreutils.cat' = ["dep:uu_cat"] +'coreutils.cksum' = ["dep:uu_cksum"] +'coreutils.b2sum' = ["dep:uu_b2sum"] +'coreutils.md5sum' = ["dep:uu_md5sum"] +'coreutils.sha1sum' = ["dep:uu_sha1sum"] +'coreutils.sha224sum' = ["dep:uu_sha224sum"] +'coreutils.sha256sum' = ["dep:uu_sha256sum"] +'coreutils.sha384sum' = ["dep:uu_sha384sum"] +'coreutils.sha512sum' = ["dep:uu_sha512sum"] +'coreutils.comm' = ["dep:uu_comm"] +'coreutils.cp' = ["dep:uu_cp"] +'coreutils.csplit' = ["dep:uu_csplit"] +'coreutils.cut' = ["dep:uu_cut"] +'coreutils.date' = ["dep:uu_date"] +'coreutils.dd' = ["dep:uu_dd"] +'coreutils.df' = ["dep:uu_df"] +'coreutils.dir' = ["dep:uu_dir"] +'coreutils.dircolors' = ["dep:uu_dircolors"] +'coreutils.dirname' = ["dep:uu_dirname"] +'coreutils.du' = ["dep:uu_du"] +'coreutils.echo' = ["dep:uu_echo"] +'coreutils.env' = ["dep:uu_env"] +'coreutils.expand' = ["dep:uu_expand"] +'coreutils.expr' = ["dep:uu_expr"] +'coreutils.factor' = ["dep:uu_factor"] +'coreutils.false' = ["dep:uu_false"] +'coreutils.fmt' = ["dep:uu_fmt"] +'coreutils.fold' = ["dep:uu_fold"] +'coreutils.head' = ["dep:uu_head"] +'coreutils.hostname' = ["dep:uu_hostname"] +'coreutils.join' = ["dep:uu_join"] +'coreutils.link' = ["dep:uu_link"] +'coreutils.ln' = ["dep:uu_ln"] +'coreutils.ls' = ["dep:uu_ls"] +'coreutils.mkdir' = ["dep:uu_mkdir"] +'coreutils.mktemp' = ["dep:uu_mktemp"] +'coreutils.more' = ["dep:uu_more"] +'coreutils.mv' = ["dep:uu_mv"] +'coreutils.nl' = ["dep:uu_nl"] +'coreutils.nproc' = ["dep:uu_nproc"] +'coreutils.numfmt' = ["dep:uu_numfmt"] +'coreutils.od' = ["dep:uu_od"] +'coreutils.paste' = ["dep:uu_paste"] +'coreutils.pr' = ["dep:uu_pr"] +'coreutils.printenv' = ["dep:uu_printenv"] +'coreutils.printf' = ["dep:uu_printf"] +'coreutils.ptx' = ["dep:uu_ptx"] +'coreutils.pwd' = ["dep:uu_pwd"] +'coreutils.readlink' = ["dep:uu_readlink"] +'coreutils.realpath' = ["dep:uu_realpath"] +'coreutils.rm' = ["dep:uu_rm"] +'coreutils.rmdir' = ["dep:uu_rmdir"] +'coreutils.seq' = ["dep:uu_seq"] +'coreutils.shred' = ["dep:uu_shred"] +'coreutils.shuf' = ["dep:uu_shuf"] +'coreutils.sleep' = ["dep:uu_sleep"] +'coreutils.sort' = ["dep:uu_sort"] +'coreutils.split' = ["dep:uu_split"] +'coreutils.sum' = ["dep:uu_sum"] +'coreutils.sync' = ["dep:uu_sync"] +'coreutils.tac' = ["dep:uu_tac"] +'coreutils.tail' = ["dep:uu_tail"] +'coreutils.tee' = ["dep:uu_tee"] +'coreutils.test' = ["dep:uu_test"] +'coreutils.touch' = ["dep:uu_touch"] +'coreutils.tr' = ["dep:uu_tr"] +'coreutils.true' = ["dep:uu_true"] +'coreutils.truncate' = ["dep:uu_truncate"] +'coreutils.tsort' = ["dep:uu_tsort"] +'coreutils.uname' = ["dep:uu_uname"] +'coreutils.unexpand' = ["dep:uu_unexpand"] +'coreutils.uniq' = ["dep:uu_uniq"] +'coreutils.unlink' = ["dep:uu_unlink"] +'coreutils.vdir' = ["dep:uu_vdir"] +'coreutils.wc' = ["dep:uu_wc"] +'coreutils.whoami' = ["dep:uu_whoami"] +'coreutils.yes' = ["dep:uu_yes"] + +[lints] +workspace = true + +[dependencies] +uucore = { version = "0.12.0", default-features = false, features = [ + "signals", +] } + +uu_arch = { version = "0.12.0", optional = true } +uu_base32 = { version = "0.12.0", optional = true } +uu_base64 = { version = "0.12.0", optional = true } +uu_basename = { version = "0.12.0", optional = true } +uu_basenc = { version = "0.12.0", optional = true } +uu_cat = { version = "0.12.0", optional = true } +uu_cksum = { version = "0.12.0", optional = true } +uu_b2sum = { version = "0.12.0", optional = true } +uu_md5sum = { version = "0.12.0", optional = true } +uu_sha1sum = { version = "0.12.0", optional = true } +uu_sha224sum = { version = "0.12.0", optional = true } +uu_sha256sum = { version = "0.12.0", optional = true } +uu_sha384sum = { version = "0.12.0", optional = true } +uu_sha512sum = { version = "0.12.0", optional = true } +uu_comm = { version = "0.12.0", optional = true } +uu_cp = { version = "0.12.0", optional = true } +uu_csplit = { version = "0.12.0", optional = true } +uu_cut = { version = "0.12.0", optional = true } +uu_date = { version = "0.12.0", optional = true } +uu_dd = { version = "0.12.0", optional = true } +uu_df = { version = "0.12.0", optional = true } +uu_dir = { version = "0.12.0", optional = true } +uu_dircolors = { version = "0.12.0", optional = true } +uu_dirname = { version = "0.12.0", optional = true } +uu_du = { version = "0.12.0", optional = true } +uu_echo = { version = "0.12.0", optional = true } +uu_env = { version = "0.12.0", optional = true } +uu_expand = { version = "0.12.0", optional = true } +uu_expr = { version = "0.12.0", optional = true } +uu_factor = { version = "0.12.0", optional = true } +uu_false = { version = "0.12.0", optional = true } +uu_fmt = { version = "0.12.0", optional = true } +uu_fold = { version = "0.12.0", optional = true } +uu_head = { version = "0.12.0", optional = true } +uu_hostname = { version = "0.12.0", optional = true } +uu_join = { version = "0.12.0", optional = true } +uu_link = { version = "0.12.0", optional = true } +uu_ln = { version = "0.12.0", optional = true } +uu_ls = { version = "0.12.0", optional = true } +uu_mkdir = { version = "0.12.0", optional = true } +uu_mktemp = { version = "0.12.0", optional = true } +uu_more = { version = "0.12.0", optional = true } +uu_mv = { version = "0.12.0", optional = true } +uu_nl = { version = "0.12.0", optional = true } +uu_nproc = { version = "0.12.0", optional = true } +uu_numfmt = { version = "0.12.0", optional = true } +uu_od = { version = "0.12.0", optional = true } +uu_paste = { version = "0.12.0", optional = true } +uu_pr = { version = "0.12.0", optional = true } +uu_printenv = { version = "0.12.0", optional = true } +uu_printf = { version = "0.12.0", optional = true } +uu_ptx = { version = "0.12.0", optional = true } +uu_pwd = { version = "0.12.0", optional = true } +uu_readlink = { version = "0.12.0", optional = true } +uu_realpath = { version = "0.12.0", optional = true } +uu_rm = { version = "0.12.0", optional = true } +uu_rmdir = { version = "0.12.0", optional = true } +uu_seq = { version = "0.12.0", optional = true } +uu_shred = { version = "0.12.0", optional = true } +uu_shuf = { version = "0.12.0", optional = true } +uu_sleep = { version = "0.12.0", optional = true } +uu_sort = { version = "0.12.0", optional = true } +uu_split = { version = "0.12.0", optional = true } +uu_sum = { version = "0.12.0", optional = true } +uu_sync = { version = "0.12.0", optional = true } +uu_tac = { version = "0.12.0", optional = true } +uu_tail = { version = "0.12.0", optional = true } +uu_tee = { version = "0.12.0", optional = true } +uu_test = { version = "0.12.0", optional = true } +uu_touch = { version = "0.12.0", optional = true } +uu_tr = { version = "0.12.0", optional = true } +uu_true = { version = "0.12.0", optional = true } +uu_truncate = { version = "0.12.0", optional = true } +uu_tsort = { version = "0.12.0", optional = true } +uu_uname = { version = "0.12.0", optional = true } +uu_unexpand = { version = "0.12.0", optional = true } +uu_uniq = { version = "0.12.0", optional = true } +uu_unlink = { version = "0.12.0", optional = true } +uu_vdir = { version = "0.12.0", optional = true } +uu_wc = { version = "0.12.0", optional = true } +uu_whoami = { version = "0.12.0", optional = true } +uu_yes = { version = "0.12.0", optional = true } diff --git a/userland/brush/source/brush-coreutils-builtins/LICENSE b/userland/brush/source/brush-coreutils-builtins/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-coreutils-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-coreutils-builtins/src/lib.rs b/userland/brush/source/brush-coreutils-builtins/src/lib.rs new file mode 100644 index 0000000..dfec486 --- /dev/null +++ b/userland/brush/source/brush-coreutils-builtins/src/lib.rs @@ -0,0 +1,207 @@ +//! Optional bundled coreutils for brush, powered by uutils/coreutils. +//! +//! Each utility is feature-gated (e.g., `coreutils.cat`, `coreutils.ls`) and +//! can be individually enabled or disabled. The `coreutils.all` feature enables +//! all cross-platform utilities. +//! +//! The crate exposes [`bundled_commands`], a registry of utility name to +//! `uumain`-style function pointer. The binary consumer (typically +//! brush-shell) installs this registry at startup; when its bundled-dispatch +//! protocol fires, the registered function is called directly. The consumer +//! is also responsible for registering any per-name builtin shims that +//! re-enter the binary as an external process, so shell redirections, pipes, +//! and process-group state are honored by uutils transparently. +//! +//! ## Relationship to `uucore::bin!` +//! +//! Each adapter generated by [`register!`] is a hand-rolled port of the body +//! of upstream's `uucore::bin!($util)` macro (SIGPIPE setup, Fluent +//! localization, `uumain` call, stdout flush). We can't invoke the macro +//! directly because it expands to a `pub fn main()` that reads argv via +//! `uucore::args_os()` and terminates the process with `std::process::exit`. +//! Our call shape is different: adapters receive argv as a parameter (the +//! `--invoke-bundled` dispatcher owns argv slicing) and must *return* an exit +//! code so the dispatcher can run a single `exit` site. +//! +//! Intentional divergences from `bin!`, all of which are benign in our usage: +//! +//! * **No `exit`:** adapters return `i32` instead of calling +//! `std::process::exit`. +//! * **Caller-supplied argv:** `args` is passed in rather than pulled from +//! `uucore::args_os()`. +//! * **Simpler `LocalizationError` formatting:** we render with `Display` +//! rather than matching on `ParseResource` for snippet-aware output. + +use std::collections::HashMap; +use std::ffi::OsString; + +/// First half of the `uucore::bin!` body: install SIGPIPE handling and +/// initialize the thread-local Fluent localizer. Without this, `translate!` +/// calls inside the utility fall back to emitting raw Fluent keys (e.g., +/// `ls-error-cannot-access-no-such-file`) instead of rendered messages. +/// +/// `util_crate_name` is the upstream crate name (e.g., `"uu_dir"`), matching +/// what `bin!` passes via `stringify!($util)`. We route it through +/// [`uucore::get_canonical_util_name`] to apply upstream's alias map (`dir` → +/// `ls`, `vdir` → `ls`, `[` → `test`); utilities implemented as thin wrappers +/// over another crate load their translation bundle under the wrapped name, +/// so skipping this step silently breaks localization for the aliases. +/// +/// Matches `uucore::bin!` behavior on localization-init failure: exit 99. +/// (This is uutils' contract, not brush's — the fact that brush-core uses 99 +/// for `ExecutionExitCode::Unimplemented` is coincidental.) +/// `setup_localization` is idempotent per thread, so repeated invocations +/// (e.g., successive bundled-dispatch calls in a long-lived host, if that +/// ever happens) are cheap. +#[allow( + dead_code, + reason = "only called by adapters generated by the `register!` macro; with no coreutils.* features enabled, no adapter exists" +)] +fn prepare_uutil_runtime(util_crate_name: &str) { + uucore::panic::preserve_inherited_sigpipe(); + uucore::panic::mute_sigpipe_panic(); + let localization_name = uucore::get_canonical_util_name(util_crate_name); + if let Err(err) = uucore::locale::setup_localization(localization_name) { + eprintln!("brush: could not initialize localization for '{localization_name}': {err}"); + std::process::exit(99); + } +} + +/// Second half of the `uucore::bin!` body: flush stdout after `uumain` +/// returns. `bin!` does this before calling `std::process::exit` to work +/// around . We do it here so +/// callers that don't immediately `exit` (e.g., a test harness) still observe +/// complete output. +#[allow( + dead_code, + reason = "only called by adapters generated by the `register!` macro; with no coreutils.* features enabled, no adapter exists" +)] +fn finalize_uutil_runtime() { + use std::io::Write; + if let Err(e) = std::io::stdout().flush() { + eprintln!("brush: error flushing stdout: {e}"); + } +} + +/// Adapts a uutils `uumain` (`impl Iterator -> i32`) to a +/// plain `fn(Vec) -> i32` and inserts it into the registry under +/// the given name when its feature is enabled. The generated adapter mirrors +/// the body of `uucore::bin!`; see the module-level docs for why we port it +/// by hand rather than invoke the macro. +macro_rules! register { + ($map:expr, $feature:literal, $name:literal, $util_crate:ident) => { + #[cfg(feature = $feature)] + { + fn adapter(args: I) -> i32 + where + I: IntoIterator, + { + $crate::prepare_uutil_runtime(stringify!($util_crate)); + let code = $util_crate::uumain(args.into_iter()); + $crate::finalize_uutil_runtime(); + code + } + $map.insert($name.to_string(), adapter as fn(Vec) -> i32); + } + }; +} + +/// Returns the set of bundled coreutils commands enabled by feature flags. +/// +/// Keyed by utility name (e.g., `"cat"`); values are `uumain`-shaped entry +/// points that consume a full argv (with `argv[0]` as the command name) and +/// return an exit code. +#[allow(clippy::too_many_lines)] +#[must_use] +pub fn bundled_commands() -> HashMap) -> i32> { + #[allow( + unused_mut, + reason = "every register! is cfg'd on a coreutils.* feature; with none enabled, nothing mutates the map" + )] + let mut m = HashMap::) -> i32>::new(); + + register!(m, "coreutils.arch", "arch", uu_arch); + register!(m, "coreutils.base32", "base32", uu_base32); + register!(m, "coreutils.base64", "base64", uu_base64); + register!(m, "coreutils.basename", "basename", uu_basename); + register!(m, "coreutils.basenc", "basenc", uu_basenc); + register!(m, "coreutils.cat", "cat", uu_cat); + register!(m, "coreutils.cksum", "cksum", uu_cksum); + register!(m, "coreutils.b2sum", "b2sum", uu_b2sum); + register!(m, "coreutils.md5sum", "md5sum", uu_md5sum); + register!(m, "coreutils.sha1sum", "sha1sum", uu_sha1sum); + register!(m, "coreutils.sha224sum", "sha224sum", uu_sha224sum); + register!(m, "coreutils.sha256sum", "sha256sum", uu_sha256sum); + register!(m, "coreutils.sha384sum", "sha384sum", uu_sha384sum); + register!(m, "coreutils.sha512sum", "sha512sum", uu_sha512sum); + register!(m, "coreutils.comm", "comm", uu_comm); + register!(m, "coreutils.cp", "cp", uu_cp); + register!(m, "coreutils.csplit", "csplit", uu_csplit); + register!(m, "coreutils.cut", "cut", uu_cut); + register!(m, "coreutils.date", "date", uu_date); + register!(m, "coreutils.dd", "dd", uu_dd); + register!(m, "coreutils.df", "df", uu_df); + register!(m, "coreutils.dir", "dir", uu_dir); + register!(m, "coreutils.dircolors", "dircolors", uu_dircolors); + register!(m, "coreutils.dirname", "dirname", uu_dirname); + register!(m, "coreutils.du", "du", uu_du); + register!(m, "coreutils.echo", "echo", uu_echo); + register!(m, "coreutils.env", "env", uu_env); + register!(m, "coreutils.expand", "expand", uu_expand); + register!(m, "coreutils.expr", "expr", uu_expr); + register!(m, "coreutils.factor", "factor", uu_factor); + register!(m, "coreutils.false", "false", uu_false); + register!(m, "coreutils.fmt", "fmt", uu_fmt); + register!(m, "coreutils.fold", "fold", uu_fold); + register!(m, "coreutils.head", "head", uu_head); + register!(m, "coreutils.hostname", "hostname", uu_hostname); + register!(m, "coreutils.join", "join", uu_join); + register!(m, "coreutils.link", "link", uu_link); + register!(m, "coreutils.ln", "ln", uu_ln); + register!(m, "coreutils.ls", "ls", uu_ls); + register!(m, "coreutils.mkdir", "mkdir", uu_mkdir); + register!(m, "coreutils.mktemp", "mktemp", uu_mktemp); + register!(m, "coreutils.more", "more", uu_more); + register!(m, "coreutils.mv", "mv", uu_mv); + register!(m, "coreutils.nl", "nl", uu_nl); + register!(m, "coreutils.nproc", "nproc", uu_nproc); + register!(m, "coreutils.numfmt", "numfmt", uu_numfmt); + register!(m, "coreutils.od", "od", uu_od); + register!(m, "coreutils.paste", "paste", uu_paste); + register!(m, "coreutils.pr", "pr", uu_pr); + register!(m, "coreutils.printenv", "printenv", uu_printenv); + register!(m, "coreutils.printf", "printf", uu_printf); + register!(m, "coreutils.ptx", "ptx", uu_ptx); + register!(m, "coreutils.pwd", "pwd", uu_pwd); + register!(m, "coreutils.readlink", "readlink", uu_readlink); + register!(m, "coreutils.realpath", "realpath", uu_realpath); + register!(m, "coreutils.rm", "rm", uu_rm); + register!(m, "coreutils.rmdir", "rmdir", uu_rmdir); + register!(m, "coreutils.seq", "seq", uu_seq); + register!(m, "coreutils.shred", "shred", uu_shred); + register!(m, "coreutils.shuf", "shuf", uu_shuf); + register!(m, "coreutils.sleep", "sleep", uu_sleep); + register!(m, "coreutils.sort", "sort", uu_sort); + register!(m, "coreutils.split", "split", uu_split); + register!(m, "coreutils.sum", "sum", uu_sum); + register!(m, "coreutils.sync", "sync", uu_sync); + register!(m, "coreutils.tac", "tac", uu_tac); + register!(m, "coreutils.tail", "tail", uu_tail); + register!(m, "coreutils.tee", "tee", uu_tee); + register!(m, "coreutils.test", "test", uu_test); + register!(m, "coreutils.touch", "touch", uu_touch); + register!(m, "coreutils.tr", "tr", uu_tr); + register!(m, "coreutils.true", "true", uu_true); + register!(m, "coreutils.truncate", "truncate", uu_truncate); + register!(m, "coreutils.tsort", "tsort", uu_tsort); + register!(m, "coreutils.uname", "uname", uu_uname); + register!(m, "coreutils.unexpand", "unexpand", uu_unexpand); + register!(m, "coreutils.uniq", "uniq", uu_uniq); + register!(m, "coreutils.unlink", "unlink", uu_unlink); + register!(m, "coreutils.vdir", "vdir", uu_vdir); + register!(m, "coreutils.wc", "wc", uu_wc); + register!(m, "coreutils.whoami", "whoami", uu_whoami); + register!(m, "coreutils.yes", "yes", uu_yes); + + m +} diff --git a/userland/brush/source/brush-experimental-builtins/Cargo.toml b/userland/brush/source/brush-experimental-builtins/Cargo.toml new file mode 100644 index 0000000..732ebb8 --- /dev/null +++ b/userland/brush/source/brush-experimental-builtins/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "brush-experimental-builtins" +description = "*EXPERIMENTAL builtins for a POSIX/bash shell (used by brush-shell)" +version = "0.1.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[features] +default = ['builtin.save'] +'builtin.save' = ["brush-core/serde", "dep:serde_json"] + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +brush-winnow-args-adapter = { version = "^0.1.0", path = "../brush-winnow-args-adapter" } +winnow-args = { version = "0.6.0", default-features = false, features = ["derive", "help-text"] } +serde_json = { version = "1.0.149", optional = true } diff --git a/userland/brush/source/brush-experimental-builtins/LICENSE b/userland/brush/source/brush-experimental-builtins/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-experimental-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-experimental-builtins/src/lib.rs b/userland/brush/source/brush-experimental-builtins/src/lib.rs new file mode 100644 index 0000000..fbeb5e0 --- /dev/null +++ b/userland/brush/source/brush-experimental-builtins/src/lib.rs @@ -0,0 +1,45 @@ +//! Experimental builtins. + +// See the equivalent note in `brush-builtins`: `brush_core::builtins::Builtin::execute` is async +// by contract (the trait declares a desugared `-> impl Future<...> + Send` so that the dispatch +// path can box every builtin uniformly), so a builtin that does purely synchronous work still +// implements `execute` as an `async fn` with no `.await`. +#![allow( + clippy::unused_async_trait_impl, + reason = "builtins implement a trait whose `execute` is async by contract" +)] + +#[cfg(feature = "builtin.save")] +mod save; + +#[allow(unused_imports, reason = "not all builtins are used in all configs")] +use brush_core::builtins; + +/// Returns the set of experimental built-in commands. +pub fn experimental_builtins() +-> std::collections::HashMap> { + let mut m = std::collections::HashMap::>::new(); + + #[cfg(feature = "builtin.save")] + m.insert( + "save".into(), + builtins::Registration::of::(), + ); + + m +} + +/// Extension trait that simplifies adding experimental builtins to a shell builder. +pub trait ShellBuilderExt { + /// Add experimental builtins to the shell being built. + #[must_use] + fn experimental_builtins(self) -> Self; +} + +impl ShellBuilderExt + for brush_core::ShellBuilder +{ + fn experimental_builtins(self) -> Self { + self.builtins(crate::experimental_builtins()) + } +} diff --git a/userland/brush/source/brush-experimental-builtins/src/save.rs b/userland/brush/source/brush-experimental-builtins/src/save.rs new file mode 100644 index 0000000..cdf8e3d --- /dev/null +++ b/userland/brush/source/brush-experimental-builtins/src/save.rs @@ -0,0 +1,28 @@ +use brush_core::{ExecutionResult, builtins}; +use std::io::Write; + +/// (*EXPERIMENTAL*) Serializes the current shell state to JSON and writes it to stdout. +/// Beware that the serialized state may include sensitive information, such as any +/// secrets stored in shell variables or referenced in command history. +#[derive(winnow_args::Args)] +#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)] +pub(crate) struct SaveCommand {} + +brush_winnow_args_adapter::impl_parse_args!(SaveCommand); + +impl builtins::Builtin for SaveCommand { + type Error = brush_core::Error; + + async fn execute( + self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let serialized_str = serde_json::to_string(&context.shell).map_err(|e| { + brush_core::Error::from(brush_core::ErrorKind::InternalError(e.to_string())) + })?; + + writeln!(context.stdout(), "{serialized_str}")?; + + Ok(ExecutionResult::success()) + } +} diff --git a/userland/brush/source/brush-interactive/Cargo.toml b/userland/brush/source/brush-interactive/Cargo.toml new file mode 100644 index 0000000..96322d9 --- /dev/null +++ b/userland/brush/source/brush-interactive/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "brush-interactive" +description = "Interactive layer of brush-shell" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +# Application tier; see docs/reference/msrv-policy.md. +rust-version = "1.95.0" + +[lib] +bench = false + +[features] +default = [] +basic = ["vt"] +completion = [] +minimal = [] +# `minimal` reads input when standard input isn't a terminal. +vt = ["dep:unicode-width", "completion", "minimal", "highlighting"] +# The backend is built only where reedline builds (not, e.g., on wasm); see +# `build.rs`. +reedline = ["dep:reedline", "completion", "highlighting"] +highlighting = ["dep:nu-ansi-term"] + +[lints] +workspace = true + +[dependencies] +bon = "3.9.1" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +brush-core = { version = "^0.5.0", path = "../brush-core" } +indexmap = "2.13.0" +nu-ansi-term = { version = "0.50.3", optional = true } +radix_trie = "0.3.0" +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "sync"] } +tracing = "0.1.44" +unicode-width = { version = "0.2.2", optional = true } + +# reedline builds only for these targets; see `build.rs`. +[target.'cfg(any(unix, windows))'.dependencies] +reedline = { version = "0.52.0", optional = true } + +[dev-dependencies] +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +vt100 = "0.18.1" diff --git a/userland/brush/source/brush-interactive/LICENSE b/userland/brush/source/brush-interactive/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-interactive/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-interactive/build.rs b/userland/brush/source/brush-interactive/build.rs new file mode 100644 index 0000000..048a25b --- /dev/null +++ b/userland/brush/source/brush-interactive/build.rs @@ -0,0 +1,19 @@ +//! Defines `cfg(reedline_backend)` where the reedline input backend is built: with +//! the `reedline` feature, on a target it builds for. + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rustc-check-cfg=cfg(reedline_backend)"); + + // reedline draws through crossterm, which builds only for Unix and Windows. + // Elsewhere (e.g., on wasm), the feature enables nothing, so it's safe to + // enable everywhere. Keep this in step with the `[target]` table in + // Cargo.toml that declares reedline. + let families = std::env::var("CARGO_CFG_TARGET_FAMILY").unwrap_or_default(); + let supported = families + .split(',') + .any(|family| family == "unix" || family == "windows"); + if supported && std::env::var_os("CARGO_FEATURE_REEDLINE").is_some() { + println!("cargo::rustc-cfg=reedline_backend"); + } +} diff --git a/userland/brush/source/brush-interactive/src/completeness.rs b/userland/brush/source/brush-interactive/src/completeness.rs new file mode 100644 index 0000000..b797431 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/completeness.rs @@ -0,0 +1,186 @@ +//! Deciding whether accumulated input forms a complete shell program. +//! +//! Input backends read a line at a time and need to know when they have enough to hand +//! over for execution. + +use brush_core::Shell; + +/// Returns whether the given accumulated input is an incomplete shell program, i.e. +/// whether more input must be read before it can be executed. +/// +/// Acquires the shell lock without blocking. Input is read between commands, with no +/// other task holding the lock, so acquisition is expected to succeed; blocking instead +/// isn't an option because backends also run on single-threaded runtimes. +/// +/// # Arguments +/// +/// * `shell_ref` - The shell whose parsing options should be used. +/// * `input` - The input accumulated so far. +#[allow(dead_code)] +pub(crate) fn needs_more_input( + shell_ref: &crate::ShellRef, + input: &str, +) -> bool { + let Ok(shell) = shell_ref.try_lock() else { + tracing::warn!("shell was unexpectedly busy; treating input as complete"); + return false; + }; + + needs_more_input_locked(&shell, input) +} + +/// Returns whether more input is needed, given an already-acquired shell. +#[allow(dead_code)] +fn needs_more_input_locked(shell: &Shell, input: &str) -> bool { + match shell.parse_string(input) { + // Mid-token: unclosed quotes, unterminated here documents, and the like. + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) + if inner.is_incomplete() => + { + true + } + // Ran out of tokens partway through a construct; more input may complete it. + Err(brush_parser::ParseError::ParsingAtEndOfInput) => true, + // A bad token at a specific position stays bad no matter what follows it. + Err(_) => false, + // Parsed cleanly. One catch: a trailing backslash-newline is a line + // continuation, which the tokenizer drops silently at end of input. Ask again + // with the newline removed; that leaves the input incomplete (e.g., an + // unterminated escape, or a here-document body not yet ended) only if that + // backslash was really escaping the newline. + Ok(_) => ends_with_line_continuation(shell, input), + } +} + +/// Returns whether the given input ends with a backslash-newline acting as a line +/// continuation. +fn ends_with_line_continuation( + shell: &Shell, + input: &str, +) -> bool { + let Some(truncated) = input.strip_suffix('\n') else { + return false; + }; + + // Keeps the extra parse off the common path. + if !truncated.ends_with('\\') { + return false; + } + + matches!( + shell.parse_string(truncated), + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) if inner.is_incomplete() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_shell() -> Shell { + brush_core::Shell::builder().build().await.unwrap() + } + + #[tokio::test] + async fn treats_trailing_line_continuation_as_incomplete() { + let shell = test_shell().await; + + assert!(needs_more_input_locked(&shell, "echo a\\\n")); + assert!(needs_more_input_locked(&shell, "printf '%s' \\\n")); + assert!(needs_more_input_locked(&shell, "echo \"quoted\" \\\n")); + // A backslash at the very end, with no newline yet. + assert!(needs_more_input_locked(&shell, "echo a\\")); + } + + #[tokio::test] + async fn treats_input_ending_before_here_tag_as_incomplete() { + let shell = test_shell().await; + + // A line continuation right after the operator: the tag comes on the next line. + assert!(needs_more_input_locked(&shell, "cat << \\\n")); + // Input ending right at the operator is incomplete too (the parser reports it). + assert!(needs_more_input_locked(&shell, "cat <<")); + assert!(needs_more_input_locked(&shell, "cat <<\\\n")); + // A line continuation after what would be a here-document's end tag joins the next + // line onto it, so the body hasn't ended. + assert!(needs_more_input_locked(&shell, "cat <, + line: &str, + pos: usize, +) -> brush_core::completion::Completions { + let working_dir = shell.working_dir().to_path_buf(); + + // Intentionally ignore any errors that arise. + let completion_future = shell.complete(line, pos); + tokio::pin!(completion_future); + + // Wait for the completions to come back or interruption, whichever happens first. + let result = tokio::select! { + result = &mut completion_future => { + result + } + _ = brush_core::sys::signal::await_ctrl_c() => { + Err(brush_core::ErrorKind::Interrupted.into()) + }, + }; + + let mut completions = result.unwrap_or_else(|_| brush_core::completion::Completions { + insertion_index: pos, + delete_count: 0, + candidates: Vec::new(), + options: brush_core::completion::ProcessingOptions::default(), + }); + + // Look at the line up to 'pos' to check if we're in an unterminated + // single or double quote string. + let mut quote_char: Option = None; + let mut escaped = false; + for (i, c) in line.char_indices() { + if i >= pos { + break; + } + + if escaped { + escaped = false; + continue; + } + + if let Some(q) = quote_char { + if c == q { + quote_char = None; + } + } else if c == '\\' { + escaped = true; + } else if c == '\'' || c == '\"' { + quote_char = Some(c); + } + } + + let completing_end_of_line = pos == line.len(); + + // Deduplicate the candidates (retaining order), then postprocess them. + completions.candidates = completions + .candidates + .into_iter() + .collect::>() + .into_iter() + .map(|candidate| { + postprocess_completion_candidate( + candidate, + &completions.options, + working_dir.as_ref(), + completing_end_of_line, + quote_char, + ) + }) + .collect(); + + completions +} + +#[allow(dead_code)] +fn postprocess_completion_candidate( + mut candidate: String, + options: &brush_core::completion::ProcessingOptions, + working_dir: &Path, + completing_end_of_line: bool, + quote_char: Option, +) -> String { + if options.treat_as_filenames { + // Check if it's a directory. + if !brush_core::sys::fs::ends_with_path_separator(&candidate) { + let candidate_path = Path::new(&candidate); + let abs_candidate_path = if candidate_path.is_absolute() { + PathBuf::from(candidate_path) + } else { + working_dir.join(candidate_path) + }; + + if abs_candidate_path.is_dir() { + // Use forward slash: backslash is the shell escape character. + candidate.push('/'); + } + } + + if !options.no_autoquote_filenames { + let quote_mode = match quote_char { + Some('\'') => escape::QuoteMode::SingleQuote, + Some('\"') => escape::QuoteMode::DoubleQuote, + _ => escape::QuoteMode::BackslashEscape, + }; + + // Like bash, leave a `~` unquoted, so a `~user` completion still expands. + let options = escape::QuoteOptions::builder() + .preferred_mode(quote_mode) + .leave_tilde(true) + .build(); + candidate = escape::quote(&candidate, &options).to_string(); + } + } + if completing_end_of_line && !options.no_trailing_space_at_end_of_line { + if !options.treat_as_filenames || !brush_core::sys::fs::ends_with_path_separator(&candidate) + { + candidate.push(' '); + } + } + + candidate +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Quotes a file-name candidate completed at the end of the line. + fn quoted_file_name(candidate: &str) -> String { + let options = brush_core::completion::ProcessingOptions { + treat_as_filenames: true, + ..Default::default() + }; + postprocess_completion_candidate( + candidate.to_owned(), + &options, + Path::new("/nonexistent"), + true, + None, + ) + } + + #[test] + fn file_names_are_quoted_like_bash() { + // Like bash, a `~user` candidate isn't quoted, so it still expands... + assert_eq!(quoted_file_name("~root"), "~root "); + // ...but a leading `#` is, so it doesn't start a comment. + assert_eq!(quoted_file_name("#hash"), r"\#hash "); + assert_eq!(quoted_file_name("a b"), r"a\ b "); + // Like bash, a comma isn't special on its own. + assert_eq!(quoted_file_name("a,b"), "a,b "); + } +} diff --git a/userland/brush/source/brush-interactive/src/error.rs b/userland/brush/source/brush-interactive/src/error.rs new file mode 100644 index 0000000..137f0a1 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/error.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +/// Represents an error encountered while running or otherwise managing an interactive shell. +#[derive(thiserror::Error, Debug)] +pub enum ShellError { + /// An error occurred with the embedded shell. + #[error("{0}")] + ShellError(#[from] brush_core::Error), + + /// A generic I/O error occurred. + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Failed to create xtrace file. + #[error("failed to create xtrace file '{0}': {1}")] + FailedToCreateXtraceFile(PathBuf, std::io::Error), + + /// An error occurred while reading input. + #[error("input error occurred: {0}")] + InputError(std::io::Error), + + /// The requested input backend type is not supported. + #[error("requested input backend type not supported")] + InputBackendNotSupported, + + /// An unexpected error occurred while reading input. + #[error("unexpected error occurred reading input")] + UnexpectedInputFailure, +} diff --git a/userland/brush/source/brush-interactive/src/highlighting.rs b/userland/brush/source/brush-interactive/src/highlighting.rs new file mode 100644 index 0000000..c1123a8 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/highlighting.rs @@ -0,0 +1,597 @@ +//! Generic syntax highlighting for shell commands. +//! +//! This module provides semantic tagging of shell command strings without +//! imposing any specific styling. Consumers can map the semantic categories +//! to their own color schemes or styles. + +/// Semantic category for a highlighted span. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightKind { + /// Default text + Default, + /// Comment text + Comment, + /// Arithmetic expression + Arithmetic, + /// Parameter expansion (variables, etc.) + Parameter, + /// Command substitution + CommandSubstitution, + /// Quoted text + Quoted, + /// Operator (|, &&, etc.) + Operator, + /// Variable assignment + Assignment, + /// Hyphen-prefixed option + HyphenOption, + /// Function definition + Function, + /// Shell keyword + Keyword, + /// Builtin command + Builtin, + /// Alias + Alias, + /// External command (found in PATH) + ExternalCommand, + /// Command not found + NotFoundCommand, + /// Unknown command (cursor still in token) + UnknownCommand, +} + +/// A highlighted span of text with semantic meaning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HighlightSpan { + /// Byte range of this span within the input string. + pub range: std::ops::Range, + /// Semantic category of this span. + pub kind: HighlightKind, +} + +impl HighlightSpan { + /// Creates a new highlight span over the given byte range. + #[must_use] + pub const fn new(range: std::ops::Range, kind: HighlightKind) -> Self { + Self { range, kind } + } +} + +/// The result of highlighting a line: the spans plus the line they index into. +/// +/// Pairing the two means span text is resolved against the original input +/// rather than a separately-supplied (and possibly mismatched) string. +pub struct Highlighted<'a> { + line: &'a str, + spans: Vec, +} + +impl<'a> Highlighted<'a> { + /// The line that was highlighted. + #[must_use] + pub const fn line(&self) -> &'a str { + self.line + } + + /// The spans, in order, covering the entire line. + #[must_use] + pub fn spans(&self) -> &[HighlightSpan] { + &self.spans + } + + /// Returns the text of `span` within the highlighted line. + /// + /// `span` is expected to be one of [`Self::spans`]; a range that is out of + /// bounds or off a UTF-8 char boundary (debug-asserted) yields `""` rather + /// than panicking. + #[must_use] + pub fn text(&self, span: &HighlightSpan) -> &'a str { + debug_assert!( + self.line.is_char_boundary(span.range.start), + "highlight span start {} is not a UTF-8 char boundary in {:?}", + span.range.start, + self.line, + ); + debug_assert!( + self.line.is_char_boundary(span.range.end), + "highlight span end {} is not a UTF-8 char boundary in {:?}", + span.range.end, + self.line, + ); + self.line.get(span.range.clone()).unwrap_or("") + } + + /// Iterates `(kind, text)` for each span, with text borrowed from the line. + pub fn iter(&self) -> impl Iterator + '_ { + self.spans.iter().map(|span| (span.kind, self.text(span))) + } +} + +/// Highlights a shell command string. +/// +/// # Arguments +/// * `shell` - Reference to the shell for context (aliases, functions, builtins, etc.) +/// * `line` - The command string to highlight +/// * `cursor` - Current cursor position (byte offset) +/// +/// # Returns +/// The highlighted line: spans covering the entire input, paired with the input. +#[must_use] +pub fn highlight_command<'a>( + shell: &brush_core::Shell, + line: &'a str, + cursor: usize, +) -> Highlighted<'a> { + let mut highlighter = Highlighter::new(shell, line, cursor); + highlighter.highlight_program(line, 0); + Highlighted { + line, + spans: highlighter.spans, + } +} + +enum CommandType { + Function, + Keyword, + Builtin, + Alias, + External, + NotFound, + Unknown, +} + +struct Highlighter<'a, SE: brush_core::ShellExtensions> { + shell: &'a brush_core::Shell, + /// The topmost input line; span offsets stored in `spans` index into this string. + input_line: &'a str, + cursor: usize, + spans: Vec, + current_byte_index: usize, + next_missing_kind: Option, +} + +impl<'a, SE: brush_core::ShellExtensions> Highlighter<'a, SE> { + const fn new(shell: &'a brush_core::Shell, input_line: &'a str, cursor: usize) -> Self { + Self { + shell, + input_line, + cursor, + spans: Vec::new(), + current_byte_index: 0, + next_missing_kind: None, + } + } + + fn highlight_program(&mut self, line: &str, global_offset: usize) { + if let Ok(tokens) = brush_parser::tokenize_str_with_options( + line, + &(self.shell.parser_options().tokenizer_options()), + ) { + let mut saw_command_token = false; + + // Tokenizer offsets are *character* indices into `line`; slicing needs bytes. + // `char_indices()` gives the byte offset of each char, plus a sentinel for the + // end, so a lookup is O(1) and the whole line costs one pass (not O(n²)). + let char_byte_offsets: Vec = line + .char_indices() + .map(|(byte_idx, _)| byte_idx) + .chain(std::iter::once(line.len())) + .collect(); + let byte_offset = |char_offset: usize| { + char_byte_offsets + .get(char_offset) + .copied() + .unwrap_or(line.len()) + }; + + for token in tokens { + match token { + brush_parser::Token::Operator(_op, token_location) => { + let start = global_offset + byte_offset(token_location.start.index); + let end = global_offset + byte_offset(token_location.end.index); + self.append_span(HighlightKind::Operator, start..end); + } + brush_parser::Token::Word(w, token_location) => { + let start_byte = byte_offset(token_location.start.index); + let end_byte = byte_offset(token_location.end.index); + + // Parse the raw slice from `line`, not `w.as_str()`: the tokenizer may + // drop chars from `w` (e.g. `\` continuations), so offsets into + // `w` no longer map onto `line`; offsets into the raw slice do. + let raw_word_text = line.get(start_byte..end_byte).unwrap_or(""); + if let Ok(word_pieces) = + brush_parser::word::parse(raw_word_text, &self.shell.parser_options()) + { + let token_range = + (global_offset + start_byte)..(global_offset + end_byte); + + // Classify against the tokenized form `w` (the logical word) so + // command lookups ignore mid-word line continuations. + let default_text_kind = self.get_kind_for_word( + w.as_str(), + &token_range, + &mut saw_command_token, + ); + + for word_piece in word_pieces { + self.highlight_word_piece( + word_piece, + default_text_kind, + token_range.start, + ); + } + } + } + } + } + + self.skip_ahead(global_offset + line.len()); + } else { + self.append_span( + HighlightKind::Default, + global_offset..global_offset + line.len(), + ); + } + } + + fn highlight_word_piece( + &mut self, + word_piece: brush_parser::word::WordPieceWithSource, + default_text_kind: HighlightKind, + global_offset: usize, + ) { + let piece = + (global_offset + word_piece.start_index)..(global_offset + word_piece.end_index); + self.skip_ahead(piece.start); + + match word_piece.piece { + brush_parser::word::WordPiece::SingleQuotedText(_) + | brush_parser::word::WordPiece::AnsiCQuotedText(_) + | brush_parser::word::WordPiece::EscapeSequence(_) => { + self.append_span(HighlightKind::Quoted, piece.clone()); + } + brush_parser::word::WordPiece::DoubleQuotedSequence(subpieces) + | brush_parser::word::WordPiece::GettextDoubleQuotedSequence(subpieces) => { + self.set_next_missing_kind(HighlightKind::Quoted); + for subpiece in subpieces { + self.highlight_word_piece(subpiece, HighlightKind::Quoted, global_offset); + } + self.set_next_missing_kind(HighlightKind::Quoted); + } + brush_parser::word::WordPiece::ParameterExpansion(_) + | brush_parser::word::WordPiece::TildeExpansion(_) => { + self.append_span(HighlightKind::Parameter, piece.clone()); + } + brush_parser::word::WordPiece::BackquotedCommandSubstitution(command) => { + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + self.highlight_program( + command.as_str(), + piece.start + 1, /* opening backtick */ + ); + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + } + brush_parser::word::WordPiece::CommandSubstitution(command) => { + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + self.highlight_program(command.as_str(), piece.start + 2 /* opening $( */); + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + } + brush_parser::word::WordPiece::ArithmeticExpression(_) => { + // TODO(highlighting): Consider individually highlighting pieces of the expression + // itself. + self.append_span(HighlightKind::Arithmetic, piece.clone()); + } + brush_parser::word::WordPiece::Text(_text) => { + self.append_span(default_text_kind, piece.clone()); + } + } + + self.skip_ahead(piece.end); + } + + fn append_span(&mut self, kind: HighlightKind, range: std::ops::Range) { + debug_assert!( + self.input_line.is_char_boundary(range.start), + "span start {} is not a UTF-8 char boundary in {:?}", + range.start, + self.input_line, + ); + debug_assert!( + self.input_line.is_char_boundary(range.end), + "span end {} is not a UTF-8 char boundary in {:?}", + range.end, + self.input_line, + ); + + // See if we need to cover a gap between this substring and the one that preceded it. + if range.start > self.current_byte_index { + let missing_kind = self.next_missing_kind.unwrap_or(HighlightKind::Comment); + self.spans.push(HighlightSpan::new( + self.current_byte_index..range.start, + missing_kind, + )); + self.current_byte_index = range.start; + } + + let end = range.end; + if !range.is_empty() { + self.spans.push(HighlightSpan::new(range, kind)); + } + + self.current_byte_index = end; + } + + fn skip_ahead(&mut self, dest: usize) { + // Append a no-op span to make sure we cover any trailing gaps in the input line not + // otherwise styled. + self.append_span(HighlightKind::Default, dest..dest); + } + + const fn set_next_missing_kind(&mut self, kind: HighlightKind) { + self.next_missing_kind = Some(kind); + } + + fn get_kind_for_word( + &self, + w: &str, + token_range: &std::ops::Range, + saw_command_token: &mut bool, + ) -> HighlightKind { + if !*saw_command_token { + if w.contains('=') { + HighlightKind::Assignment + } else { + *saw_command_token = true; + match self.classify_possible_command(w, token_range) { + CommandType::Function => HighlightKind::Function, + CommandType::Keyword => HighlightKind::Keyword, + CommandType::Builtin => HighlightKind::Builtin, + CommandType::Alias => HighlightKind::Alias, + CommandType::External => HighlightKind::ExternalCommand, + CommandType::NotFound => HighlightKind::NotFoundCommand, + CommandType::Unknown => HighlightKind::UnknownCommand, + } + } + } else { + if self.shell.is_keyword(w) { + HighlightKind::Keyword + } else if w.starts_with('-') { + HighlightKind::HyphenOption + } else { + HighlightKind::Default + } + } + } + + fn classify_possible_command( + &self, + name: &str, + token_range: &std::ops::Range, + ) -> CommandType { + if self.shell.is_keyword(name) { + return CommandType::Keyword; + } else if self.shell.aliases().contains_key(name) { + return CommandType::Alias; + } else if self.shell.funcs().get(name).is_some() { + return CommandType::Function; + } else if self.shell.builtins().get(name).is_some() { + return CommandType::Builtin; + } + + // Short-circuit if the cursor is still in this token (inclusive of its end, so a + // command still being typed isn't prematurely flagged as not-found). + if self.cursor >= token_range.start && self.cursor <= token_range.end { + return CommandType::Unknown; + } + + if brush_core::sys::fs::contains_path_separator(name) { + // TODO(highlighting): Should check for executable-ness. + let candidate_path = self.shell.absolute_path(std::path::Path::new(name)); + if candidate_path.exists() { + CommandType::External + } else { + CommandType::NotFound + } + } else { + if self.shell.find_first_executable_in_path(name).is_some() { + CommandType::External + } else { + CommandType::NotFound + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_highlight_simple_command() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "somecommand hello"; + // Use cursor position at the end so we get final highlighting + let highlighted = highlight_command(&shell, line, line.len()); + + // Should have at least 2 spans + assert_ne!(highlighted.spans(), []); + + // Verify highlighting produces spans that cover the input + let total_covered: usize = highlighted.spans().iter().map(|s| s.range.len()).sum(); + assert_eq!(total_covered, line.len(), "Spans should cover entire input"); + + // The command should be classified as something (NotFound, External, etc.) + let cmd_span = highlighted + .spans() + .iter() + .find(|s| highlighted.text(s) == "somecommand"); + assert!(cmd_span.is_some(), "Should have a span for the command"); + } + + #[tokio::test] + async fn test_highlight_quoted_string() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = r#"echo "hello world""#; + let highlighted = highlight_command(&shell, line, 0); + + // Should have spans for: echo, space, "hello world" + assert_ne!(highlighted.spans(), []); + + // Check that quoted parts are marked as Quoted + assert!( + highlighted + .spans() + .iter() + .any(|s| s.kind == HighlightKind::Quoted) + ); + } + + #[tokio::test] + async fn test_highlight_parameter_expansion() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "echo $HOME"; + let highlighted = highlight_command(&shell, line, 0); + + // Should have spans including a parameter expansion + assert!( + highlighted + .spans() + .iter() + .any(|s| s.kind == HighlightKind::Parameter) + ); + } + + #[tokio::test] + async fn test_highlight_covers_entire_input() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "echo hello world"; + let highlighted = highlight_command(&shell, line, 0); + + // Verify that spans cover the entire input (no gaps) + let mut covered = vec![false; line.len()]; + for span in highlighted.spans() { + for item in covered + .iter_mut() + .take(span.range.end) + .skip(span.range.start) + { + *item = true; + } + } + + assert!(covered.iter().all(|&c| c), "Not all characters are covered"); + } + + /// Asserts the invariants every highlighter output must satisfy (mirrors + /// `fuzz/fuzz_targets/fuzz_highlight.rs`). + fn assert_spans_are_valid(highlighted: &Highlighted<'_>) { + let line = highlighted.line(); + + // 1. Each span is in-range and lands on UTF-8 char boundaries. + for span in highlighted.spans() { + assert!( + span.range.start <= span.range.end, + "span has start > end: {span:?} (line={line:?})", + ); + assert!( + span.range.end <= line.len(), + "span end exceeds line length: {span:?} (line.len()={})", + line.len(), + ); + assert!( + line.is_char_boundary(span.range.start), + "span start not on char boundary: {span:?} (line={line:?}, bytes={:?})", + line.as_bytes(), + ); + assert!( + line.is_char_boundary(span.range.end), + "span end not on char boundary: {span:?} (line={line:?}, bytes={:?})", + line.as_bytes(), + ); + } + + // 2. Spans are ordered and contiguous, covering the entire input. + let mut next_expected_start = 0usize; + for span in highlighted.spans() { + assert_eq!( + span.range.start, next_expected_start, + "spans are not contiguous: {span:?} (expected start={next_expected_start}, line={line:?})", + ); + next_expected_start = span.range.end; + } + assert_eq!( + next_expected_start, + line.len(), + "spans do not cover entire input (covered {next_expected_start} of {}, line={line:?})", + line.len(), + ); + + // 3. Resolving each span's text must not panic. + for (_, _) in highlighted.iter() {} + } + + #[tokio::test] + async fn test_highlight_multibyte_chars_in_word_does_not_panic() { + // Regression: a multibyte word followed by another token used to panic from + // mixing char indices (tokenizer) with byte indices (word parser). + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = ": 爸爸 /"; + let highlighted = highlight_command(&shell, line, line.len()); + + assert_spans_are_valid(&highlighted); + } + + #[tokio::test] + async fn test_highlight_multibyte_chars_partial_input_does_not_panic() { + // Simulate intermediate keystrokes while a user is typing the line. + let shell = brush_core::Shell::builder().build().await.unwrap(); + let full = ": 爸爸 /"; + // Every char-boundary prefix must highlight without panicking. + for (boundary, _) in full + .char_indices() + .chain(std::iter::once((full.len(), ' '))) + { + // `boundary` is sourced from char_indices(), so slicing is on a char boundary. + #[allow(clippy::string_slice)] + let line = &full[..boundary]; + let highlighted = highlight_command(&shell, line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + + #[tokio::test] + async fn test_highlight_multibyte_in_various_positions() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let cases = [ + "爸", + "爸 x", + "x 爸", + "echo 爸爸", + "爸爸=value", + "\"爸爸\" /", + "$爸", + "$(爸爸) /", + "`爸爸` /", + "# 爸爸 comment", + ]; + for line in cases { + let highlighted = highlight_command(&shell, line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + + #[tokio::test] + async fn test_highlight_issue_1128_multibyte_then_paren() { + // Regression for #1128: a 2-byte char immediately followed by `(` panicked + // because the operator's char index was sliced as a byte offset, landing + // mid-character. Exercise the reported chars, including the keystroke-by- + // keystroke sequence (the char alone, then the char + `(`). + let shell = brush_core::Shell::builder().build().await.unwrap(); + for prefix in ["£", "€", "é", "½", "§", "²", "ï", "¤", "…"] { + for line in [prefix.to_string(), format!("{prefix}(")] { + let highlighted = highlight_command(&shell, &line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + } +} diff --git a/userland/brush/source/brush-interactive/src/input_backend.rs b/userland/brush/source/brush-interactive/src/input_backend.rs new file mode 100644 index 0000000..97a4e3f --- /dev/null +++ b/userland/brush/source/brush-interactive/src/input_backend.rs @@ -0,0 +1,52 @@ +use crate::ShellError; + +/// Represents an input backend for reading lines of input. +pub trait InputBackend: Send { + /// Reads a line of input, using the given prompt. + /// + /// # Arguments + /// + /// * `shell` - The shell instance for which input is being read. + /// * `prompt` - The prompt to display to the user. + fn read_line( + &mut self, + shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> impl Future>; + + /// Returns the current contents of the read buffer and the current cursor + /// position within the buffer; None is returned if the read buffer is + /// empty or cannot be read by this implementation. + fn get_read_buffer(&self) -> Option<(String, usize)> { + None + } + + /// Updates the read buffer with the given string and cursor. Considered a + /// no-op if the implementation does not support updating read buffers. + fn set_read_buffer(&mut self, _buffer: String, _cursor: usize) { + // No-op by default. + } +} + +/// Result of a read operation. +pub enum ReadResult { + /// The user entered a line of input. + Input(String), + /// A bound key sequence yielded a registered command. + BoundCommand(String), + /// End of input was reached. + Eof, + /// The user interrupted the input operation. + Interrupted, +} + +/// Represents an interactive prompt. +#[derive(Default)] +pub struct InteractivePrompt { + /// Prompt to display. + pub prompt: String, + /// Alternate-side prompt (typically right) to display. + pub alt_side_prompt: String, + /// Prompt to display on a continuation line of input. + pub continuation_prompt: String, +} diff --git a/userland/brush/source/brush-interactive/src/interactive_shell.rs b/userland/brush/source/brush-interactive/src/interactive_shell.rs new file mode 100644 index 0000000..8163bae --- /dev/null +++ b/userland/brush/source/brush-interactive/src/interactive_shell.rs @@ -0,0 +1,461 @@ +use std::io::IsTerminal as _; +use std::io::Write as _; +use std::ops::ControlFlow; + +use crate::InputBackend; +use crate::InteractivePrompt; +use crate::ReadResult; +use crate::ShellError; + +/// Result of an interactive execution. +pub enum InteractiveExecutionResult { + /// The command was executed and returned the given result. + Executed(brush_core::ExecutionResult), + /// The command failed to execute. + Failed(brush_core::Error), + /// End of input was reached. + Eof, +} + +impl From<&InteractiveExecutionResult> for i32 { + /// Converts an `InteractiveExecutionResult` into a signed, 32-bit exit code. + fn from(value: &InteractiveExecutionResult) -> Self { + match value { + InteractiveExecutionResult::Executed(result) => u8::from(result.exit_code).into(), + InteractiveExecutionResult::Failed(_) => 1, + InteractiveExecutionResult::Eof => 0, + } + } +} + +/// Options for interactive shells. +#[derive(Clone, Default)] +pub struct InteractiveOptions { + /// Whether terminal shell integration is enabled. + pub terminal_shell_integration: bool, + /// Whether to run zsh-style `precmd_functions`/`preexec_functions` hooks. Inert on its + /// own: the embedder must also call [`init_zsh_style_hooks`](crate::init_zsh_style_hooks) + /// before the shell loads its profile and rc files. + pub zsh_style_hooks: bool, +} + +/// Reads `PROMPT_COMMAND` as bash does: a string is a single command, an indexed array is one +/// command per element, and any other type runs nothing at all. Deliberately *not* read as a +/// word list the way the hook registries are: bash gives `PROMPT_COMMAND` its own handling +/// rather than expanding it as `"${PROMPT_COMMAND[@]}"`. +fn read_prompt_commands( + shell: &brush_core::Shell, +) -> Vec { + match shell.env_var("PROMPT_COMMAND").map(|var| var.value()) { + Some(brush_core::ShellValue::String(cmd)) => vec![cmd.to_owned()], + Some(brush_core::ShellValue::IndexedArray(cmds)) => cmds.values().cloned().collect(), + _ => vec![], + } +} + +/// Represents an interactive shell that displays prompts, interactively reads user input, etc. +pub struct InteractiveShell<'a, IB: InputBackend, SE: brush_core::ShellExtensions> { + /// The underlying shell instance. + shell: crate::ShellRef, + /// The input backend to use. + input: &'a mut IB, + /// Terminal integration utility; inert if integration is off or unsupported. + terminal_integration: crate::term_integration::TerminalIntegration, + /// Terminal-control guard, held for the lifetime of the interactive shell. + _terminal_control: Option, + /// Options. + options: InteractiveOptions, +} + +impl<'a, IB: InputBackend, SE: brush_core::ShellExtensions> InteractiveShell<'a, IB, SE> { + /// Creates a new `InteractiveShell` wrapping the given shell instance. + /// + /// # Arguments + /// + /// * `shell` - The shell instance to wrap. + /// * `input` - The input backend to use. + /// * `options` - The user interface options to use. + pub fn new( + shell: &crate::ShellRef, + input: &'a mut IB, + options: &InteractiveOptions, + ) -> Result { + let stdin_is_terminal = std::io::stdin().is_terminal(); + + // Acquire terminal control if stdin is a terminal. + let terminal_control = if stdin_is_terminal { + Some(brush_core::terminal::TerminalControl::acquire()?) + } else { + None + }; + + // Set up terminal integration if enabled *and* if stdin is a terminal. Otherwise a + // switched-off one, which every call site below can report events to unconditionally. + let terminal_integration = if options.terminal_shell_integration && stdin_is_terminal { + let terminfo = crate::term_detection::get_terminal_info(&HostEnvironment); + crate::term_integration::TerminalIntegration::init(terminfo)? + } else { + crate::term_integration::TerminalIntegration::disabled() + }; + + Ok(Self { + shell: shell.clone(), + input, + terminal_integration, + _terminal_control: terminal_control, + options: options.clone(), + }) + } + + /// Runs the interactive shell loop, reading commands from standard input and writing + /// results to standard output and standard error. Continues until the shell + /// normally exits or until a fatal error occurs. + pub async fn run_interactively(&mut self) -> Result<(), ShellError> { + let mut shell = self.shell.lock().await; + + let mut announce_exit = shell.options().interactive; + + shell.start_interactive_session()?; + + drop(shell); + + // Run commands until the shell exits or input ends. The loop yields its result + // rather than returning early on an error, so that the session still ends below. + let looped = loop { + let result = match self.run_interactively_once().await { + Ok(result) => result, + Err(err) => break Err(err), + }; + match result { + InteractiveExecutionResult::Executed(result) if result.is_exit() => { + break Ok(()); + } + InteractiveExecutionResult::Executed(brush_core::ExecutionResult { + next_control_flow: + brush_core::results::ExecutionControlFlow::ReturnFromFunctionOrScript, + .. + }) => { + tracing::error!("return from non-function/script"); + } + InteractiveExecutionResult::Executed(_) => {} + InteractiveExecutionResult::Failed(err) => { + // Report the error, but continue to execute. + let shell = self.shell.lock().await; + let mut stderr = shell.stderr(); + let _ = shell.display_error(&mut stderr, &err); + + drop(shell); + } + InteractiveExecutionResult::Eof => { + break Ok(()); + } + } + + if self.shell.lock().await.options().exit_after_one_command { + announce_exit = false; + break Ok(()); + } + }; + + let mut shell = self.shell.lock().await; + + // End the session whether or not the loop failed. If both failed, the loop's error + // is the one to report. + let ended = shell.end_interactive_session(); + looped?; + ended?; + + if announce_exit { + writeln!(shell.stderr(), "exit")?; + } + + if let Err(e) = shell.save_history() { + // N.B. This seems like the sort of thing that's worth being noisy about, + // but bash doesn't do that -- and probably for a reason. + tracing::debug!("couldn't save history: {e}"); + } + + // Give the shell an opportunity to perform any on-exit operations. + shell.on_exit().await?; + + drop(shell); + + Ok(()) + } + + /// Runs the interactive shell loop once, reading a single command from standard input. + async fn run_interactively_once(&mut self) -> Result { + let mut shell = self.shell.lock().await; + + // Check for any completed jobs. + shell.check_for_completed_jobs()?; + + // Everything between here and reading input is prompt work, and a shell that displays + // no prompt does none of it: `script | brush -s` reads commands through this loop but + // isn't interactive in the `$-` sense, so bash runs no PROMPT_COMMAND and expands no + // PS1 there. + let prompt = if shell.options().interactive { + // Run any pre-prompt actions. + if let ControlFlow::Break(exit) = + Self::run_pre_prompt_actions(&mut shell, &self.options).await? + { + return Ok(InteractiveExecutionResult::Executed(exit)); + } + + // Compose the prompt. + Self::compose_prompt(&mut shell, &self.terminal_integration).await? + } else { + InteractivePrompt::default() + }; + + drop(shell); + + // Read input. + match self.input.read_line(&self.shell, prompt).await? { + ReadResult::Input(read_result) => { + // We got a line of input -- execute it. + self.execute_line(read_result, true /* user input */).await + } + ReadResult::BoundCommand(read_result) => { + // We got a line that was bound to keybindings; execute it. + self.execute_line(read_result, false /* user input */).await + } + ReadResult::Eof => { + // We're done! + Ok(InteractiveExecutionResult::Eof) + } + ReadResult::Interrupted => { + // We were interrupted; report that appropriately. + let result: brush_core::ExecutionResult = + brush_core::ExecutionExitCode::Interrupted.into(); + self.shell + .lock() + .await + .set_last_exit_status(result.exit_code.into()); + Ok(InteractiveExecutionResult::Executed(result)) + } + } + } + + async fn compose_prompt( + shell: &mut brush_core::Shell, + terminal_integration: &crate::term_integration::TerminalIntegration, + ) -> Result { + // Now that we've done that, compose the prompt. + let mut prompt = InteractivePrompt { + prompt: shell.compose_prompt().await?, + alt_side_prompt: shell.compose_alt_side_prompt().await?, + continuation_prompt: shell.compose_continuation_prompt().await?, + }; + + prompt.prompt = terminal_integration.decorate_prompt(prompt.prompt, shell.working_dir()); + + Ok(prompt) + } + + /// Executes the given line of input. + /// + /// # Arguments + /// + /// * `read_result` - The line of input to execute. + /// * `user_input` - Whether the line came from direct user input (as opposed to a key binding, + /// say). + async fn execute_line( + &mut self, + read_result: String, + user_input: bool, + ) -> Result { + let mut shell = self.shell.lock().await; + + // See if the the user interface has a non-empty read buffer. + let buffer_info = self.input.get_read_buffer(); + + // If the user interface has a read buffer -- even an empty one -- reflect it to the + // shell so that bound commands see READLINE_LINE/READLINE_POINT, as they do in bash. + let had_buffer = if let Some((buffer, cursor)) = buffer_info { + shell.set_edit_buffer(buffer, cursor)?; + true + } else { + false + }; + + // If the line came from direct user input (as opposed to a key binding, say), then we + // need to do a few more things before executing it. A hook that exited the shell + // stands in for the command: the line never runs, and neither does the bookkeeping + // below, which only matters to a shell that goes on to read another line. + if user_input + && let ControlFlow::Break(exit) = + Self::run_pre_exec_actions(&mut shell, read_result.as_str(), &self.options).await? + { + return Ok(InteractiveExecutionResult::Executed(exit)); + } + + // Count the command's lines. + let line_count = read_result.lines().count().max(1); + + // Terminal integration brackets the command with a matched pair of markers, so both + // are emitted here, around the one call that runs it: no failure between them can + // leave a started command unclosed, and nothing below this writes to the terminal. + self.terminal_integration + .on_pre_exec_command(&read_result)?; + + // Execute the command. + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from("main"); + let result = match shell.run_string(read_result, &source_info, ¶ms).await { + Ok(result) => Ok(InteractiveExecutionResult::Executed(result)), + Err(e) => Ok(InteractiveExecutionResult::Failed(e)), + }; + + self.terminal_integration + .on_post_exec_command(result.as_ref().map_or(1, i32::from))?; + + // Update cumulative line counter based on actual lines in the command. + shell.increment_interactive_line_offset(line_count); + + // See if the shell has input buffer state that we need to reflect back to + // the user interface. It may be state that originally came from the user + // interface, or it may be state that was programmatically generated by + // the command we just executed. + let mut buffer_and_cursor = shell.pop_edit_buffer()?; + + drop(shell); + + if buffer_and_cursor.is_none() && had_buffer { + buffer_and_cursor = Some((String::new(), 0)); + } + + if let Some((updated_buffer, updated_cursor)) = buffer_and_cursor { + self.input.set_read_buffer(updated_buffer, updated_cursor); + } + + result + } + + /// Runs pre-prompt actions. Breaks with the result of one that exited the shell. + async fn run_pre_prompt_actions( + shell: &mut brush_core::Shell, + options: &InteractiveOptions, + ) -> Result, ShellError> { + // precmd hooks first: bash-preexec prepends its dispatcher to PROMPT_COMMAND. + if let ControlFlow::Break(exit) = crate::zsh_hooks::run_precmd(shell, options).await? { + return Ok(ControlFlow::Break(exit)); + } + + // Next, if there's a variable called PROMPT_COMMAND, then run it. + for prompt_cmd in read_prompt_commands(shell) { + if let ControlFlow::Break(exit) = + Self::run_pre_prompt_command(shell, prompt_cmd).await? + { + return Ok(ControlFlow::Break(exit)); + } + } + + Ok(ControlFlow::Continue(())) + } + + /// Runs pre-exec actions. Breaks with the result of a hook that exited the shell. + /// + /// # Arguments + /// + /// * `shell` - The shell to run the actions in. + /// * `command_line` - The line as entered by the user. + /// * `options` - The options the interactive loop is running with. + async fn run_pre_exec_actions( + shell: &mut brush_core::Shell, + command_line: &str, + options: &InteractiveOptions, + ) -> Result, ShellError> { + // Display the pre-command prompt on stderr (if there is one). Like the other prompts, + // this is expanded only by a shell that's interactive in the `$-` sense. + if shell.options().interactive { + let precmd_prompt = shell.compose_precmd_prompt().await?; + if !precmd_prompt.is_empty() { + eprint!("{precmd_prompt}"); + std::io::stderr().flush()?; + } + } + + // Update history (if applicable). + shell.add_to_history(command_line.trim_end_matches('\n'))?; + + // preexec hooks get the line as entered; they are the last thing before it runs, so + // their break is this function's. + crate::zsh_hooks::run_preexec(shell, options, command_line).await + } + + /// Runs one `PROMPT_COMMAND` entry. Breaks with its result if it exited the shell. + async fn run_pre_prompt_command( + shell: &mut brush_core::Shell, + prompt_cmd: String, + ) -> Result, ShellError> { + let saved_status = shell.save_command_status(); + + // Run the command. + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from("PROMPT_COMMAND"); + let result = shell.run_string(prompt_cmd, &source_info, ¶ms).await?; + if result.is_exit() { + return Ok(ControlFlow::Break(result)); + } + + shell.restore_command_status(saved_status); + + Ok(ControlFlow::Continue(())) + } +} + +/// Represents the host environment; used for terminal detection in conjunction +/// with the `TerminalEnvironment` trait. +struct HostEnvironment; + +impl crate::term_detection::TerminalEnvironment for HostEnvironment { + /// Gets the value of the given environment variable from the host process's + /// OS environment variables. Returns `None` if the variable is not set. + /// + /// # Arguments + /// + /// * `name` - The name of the environment variable to get. + fn get_env_var(&self, name: &str) -> Option { + std::env::var(name).ok() + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + + /// An input backend whose every read fails. + struct FailingInput; + + impl InputBackend for FailingInput { + #[allow(clippy::unused_async_trait_impl, reason = "a test double")] + async fn read_line( + &mut self, + _shell: &crate::ShellRef, + _prompt: InteractivePrompt, + ) -> Result { + Err(std::io::Error::other("input failed").into()) + } + } + + /// An interactive session ends even when the loop fails, so the shell isn't left in it. + #[tokio::test] + async fn failed_interactive_loop_ends_its_session() -> Result<(), ShellError> { + let shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await?; + let shell = std::sync::Arc::new(tokio::sync::Mutex::new(shell)); + let mut input = FailingInput; + let mut interactive = + InteractiveShell::new(&shell, &mut input, &InteractiveOptions::default())?; + + assert!(interactive.run_interactively().await.is_err()); + assert!(shell.lock().await.call_stack().is_empty()); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-interactive/src/lib.rs b/userland/brush/source/brush-interactive/src/lib.rs new file mode 100644 index 0000000..78b218b --- /dev/null +++ b/userland/brush/source/brush-interactive/src/lib.rs @@ -0,0 +1,54 @@ +//! Library implementing interactive command input and completion for the brush shell. + +mod error; +pub use error::ShellError; + +mod interactive_shell; +pub use interactive_shell::{InteractiveExecutionResult, InteractiveOptions, InteractiveShell}; + +mod zsh_hooks; +pub use zsh_hooks::init as init_zsh_style_hooks; + +mod input_backend; +pub use input_backend::{InputBackend, InteractivePrompt, ReadResult}; + +mod options; +pub use options::UIOptions; + +mod refs; +pub use refs::ShellRef; + +mod completeness; +mod term_detection; +mod term_integration; +mod trace_categories; + +#[cfg(feature = "highlighting")] +pub mod highlighting; +#[cfg(feature = "highlighting")] +#[allow( + dead_code, + reason = "highlighting can be enabled without an input backend" +)] +mod styles; + +#[cfg(feature = "completion")] +mod completion; + +// Reedline-based shell +#[cfg(reedline_backend)] +mod reedline; +#[cfg(reedline_backend)] +pub use reedline::ReedlineInputBackend; + +// Minimal shell +#[cfg(feature = "minimal")] +mod minimal; +#[cfg(feature = "minimal")] +pub use minimal::MinimalInputBackend; + +// Line editing over plain bytes +#[cfg(feature = "vt")] +pub mod vt; +#[cfg(feature = "vt")] +pub use vt::VtInputBackend; diff --git a/userland/brush/source/brush-interactive/src/minimal/input_backend.rs b/userland/brush/source/brush-interactive/src/minimal/input_backend.rs new file mode 100644 index 0000000..c93b9f4 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/minimal/input_backend.rs @@ -0,0 +1,194 @@ +use std::io::{BufRead, IsTerminal, Write}; + +use crate::{ + InputBackend, ShellError, + input_backend::{InteractivePrompt, ReadResult}, +}; + +/// Represents a minimal shell input backend, capable of taking commands from standard input. +#[derive(Default)] +pub struct MinimalInputBackend; + +impl InputBackend for MinimalInputBackend { + #[allow( + clippy::unused_async_trait_impl, + reason = "this backend reads synchronously; the trait is async for those that don't" + )] + async fn read_line( + &mut self, + shell_ref: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + let stdin = std::io::stdin(); + let prompt = stdin.is_terminal().then_some(&prompt); + + Self::read_program_from(shell_ref, prompt, &mut stdin.lock(), &mut std::io::stderr()) + } +} + +impl MinimalInputBackend { + /// Reads lines from the given reader until they form a complete shell program. + /// Compound commands, here documents, and backslash continuations all span lines, + /// so a single line is often not a program on its own. + /// + /// # Arguments + /// + /// * `shell_ref` - The shell whose parsing options decide when input is complete. + /// * `prompt` - The prompt to display, or `None` to display nothing. + /// * `reader` - The source to read lines from. + /// * `writer` - Where prompts are written. + fn read_program_from( + shell_ref: &crate::ShellRef, + prompt: Option<&InteractivePrompt>, + reader: &mut R, + writer: &mut W, + ) -> Result { + let mut result = String::new(); + + loop { + if let Some(prompt) = prompt { + let to_display = if result.is_empty() { + &prompt.prompt + } else { + &prompt.continuation_prompt + }; + + write!(writer, "{to_display}")?; + writer.flush()?; + } + + // Nothing more is coming; run what we have and let the parser complain if + // it's still incomplete. + let Some(line) = Self::read_input_line(reader)? else { + break; + }; + + result.push_str(line.as_str()); + + if !crate::completeness::needs_more_input(shell_ref, result.as_str()) { + break; + } + } + + if result.is_empty() { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(result)) + } + } + + /// Reads a single line, returning `None` at end of input. + fn read_input_line(reader: &mut R) -> Result, ShellError> { + let mut input = String::new(); + let bytes_read = reader + .read_line(&mut input) + .map_err(ShellError::InputError)?; + + Ok((bytes_read > 0).then_some(input)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Cursor, sync::Arc}; + + async fn test_shell_ref() -> crate::ShellRef { + Arc::new(tokio::sync::Mutex::new( + brush_core::Shell::builder().build().await.unwrap(), + )) + } + + fn test_prompt() -> InteractivePrompt { + InteractivePrompt { + prompt: String::from("$ "), + alt_side_prompt: String::new(), + continuation_prompt: String::from("> "), + } + } + + /// Reads one program from `input`, returning what the backend accumulated. + async fn read_program(input: &str) -> ReadResult { + let shell_ref = test_shell_ref().await; + let mut reader = Cursor::new(input.as_bytes()); + + MinimalInputBackend::read_program_from(&shell_ref, None, &mut reader, &mut Vec::new()) + .unwrap() + } + + async fn assert_reads(input: &str, expected: &str) { + let actual = match read_program(input).await { + ReadResult::Input(s) => format!("Input({s:?})"), + ReadResult::BoundCommand(s) => format!("BoundCommand({s:?})"), + ReadResult::Eof => String::from("Eof"), + ReadResult::Interrupted => String::from("Interrupted"), + }; + + assert_eq!( + actual, + format!("Input({expected:?})"), + "for input {input:?}" + ); + } + + #[tokio::test] + async fn stops_at_a_complete_program() { + assert_reads("echo one\necho two\n", "echo one\n").await; + } + + #[tokio::test] + async fn accumulates_until_complete() { + assert_reads("true &&\necho yes\n", "true &&\necho yes\n").await; + assert_reads( + "if true\nthen\necho yes\nfi\necho after\n", + "if true\nthen\necho yes\nfi\n", + ) + .await; + assert_reads("cat < > > "); + } + + #[tokio::test] + async fn displays_no_prompts_without_one_to_display() { + let shell_ref = test_shell_ref().await; + let mut prompts = Vec::new(); + + MinimalInputBackend::read_program_from( + &shell_ref, + None, + &mut Cursor::new(b"if true\nthen\necho hi\nfi\n".as_slice()), + &mut prompts, + ) + .unwrap(); + + assert_eq!(prompts, Vec::::new()); + } +} diff --git a/userland/brush/source/brush-interactive/src/minimal/mod.rs b/userland/brush/source/brush-interactive/src/minimal/mod.rs new file mode 100644 index 0000000..b8cfed2 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/minimal/mod.rs @@ -0,0 +1,3 @@ +mod input_backend; + +pub use input_backend::MinimalInputBackend; diff --git a/userland/brush/source/brush-interactive/src/options.rs b/userland/brush/source/brush-interactive/src/options.rs new file mode 100644 index 0000000..6c3e547 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/options.rs @@ -0,0 +1,28 @@ +/// Options for a shell user interface. +#[derive(Default, bon::Builder)] +pub struct UIOptions { + /// Whether to disable bracketed paste mode. + #[builder(default)] + pub disable_bracketed_paste: bool, + /// Whether to disable color. + #[builder(default)] + pub disable_color: bool, + /// Whether to disable syntax highlighting. + #[builder(default)] + pub disable_highlighting: bool, + /// Whether to enable terminal integration. + #[builder(default)] + pub terminal_shell_integration: bool, + /// Whether to enable zsh-style hooks. + #[builder(default)] + pub zsh_style_hooks: bool, +} + +impl From<&UIOptions> for crate::InteractiveOptions { + fn from(options: &UIOptions) -> Self { + Self { + terminal_shell_integration: options.terminal_shell_integration, + zsh_style_hooks: options.zsh_style_hooks, + } + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/completer.rs b/userland/brush/source/brush-interactive/src/reedline/completer.rs new file mode 100644 index 0000000..dfb253e --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/completer.rs @@ -0,0 +1,92 @@ +use nu_ansi_term::{Color, Style}; +use std::borrow::BorrowMut; + +use crate::{completion, refs}; + +pub(crate) struct ReedlineCompleter { + pub shell: refs::ShellRef, +} + +impl reedline::Completer for ReedlineCompleter { + fn complete(&mut self, line: &str, pos: usize) -> reedline::CompletionResult { + let suggestions = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.complete_async(line, pos)) + }); + reedline::CompletionResult::fresh(suggestions) + } +} + +impl ReedlineCompleter { + async fn complete_async(&self, line: &str, pos: usize) -> Vec { + let mut shell_guard = self.shell.lock().await; + let shell = shell_guard.borrow_mut().as_mut(); + let completions = completion::complete_async(shell, line, pos).await; + + // We're done with the shell, so drop it eagerly. + drop(shell_guard); + + let insertion_index = completions.insertion_index; + let delete_count = completions.delete_count; + let options = completions.options; + + completions + .candidates + .into_iter() + .map(|candidate| { + Self::to_suggestion(line, candidate, insertion_index, delete_count, &options) + }) + .collect() + } + + #[allow( + clippy::string_slice, + reason = "all indices + counts are expected to be at char boundaries" + )] + fn to_suggestion( + line: &str, + mut candidate: String, + mut insertion_index: usize, + mut delete_count: usize, + options: &brush_core::completion::ProcessingOptions, + ) -> reedline::Suggestion { + let mut style = Style::new(); + + // Special handling for filename completions. + if options.treat_as_filenames { + if brush_core::sys::fs::ends_with_path_separator(&candidate) { + style = style.fg(Color::Green); + } + + if insertion_index + delete_count <= line.len() { + let removed = &line[insertion_index..insertion_index + delete_count]; + if let Some(last_sep_index) = brush_core::sys::fs::rfind_path_separator(removed) { + if candidate.starts_with(removed) { + candidate = candidate.split_off(last_sep_index + 1); + insertion_index += last_sep_index + 1; + delete_count -= last_sep_index + 1; + } + } + } + } + + // See if there's whitespace at the end. + let append_whitespace = candidate.ends_with(' '); + if append_whitespace { + candidate.pop(); + } + + reedline::Suggestion { + value: candidate, + description: None, + style: Some(style), + extra: None, + span: reedline::Span { + start: insertion_index, + end: insertion_index + delete_count, + }, + match_indices: None, + display_override: None, + append_whitespace, + } + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/edit_mode.rs b/userland/brush/source/brush-interactive/src/reedline/edit_mode.rs new file mode 100644 index 0000000..30f11ca --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/edit_mode.rs @@ -0,0 +1,695 @@ +use brush_core::{ + interfaces::{self, InputFunction, Key, KeyAction, KeyBindings as _, KeySequence, KeyStroke}, + trace_categories, +}; +use radix_trie::Trie; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +#[derive(thiserror::Error, Debug)] +pub enum KeyError { + /// Unsupported key sequence + #[error("unsupported key sequence: {0}")] + UnsupportedKeySequence(KeySequence), + + /// Unsupported key action + #[error("unsupported key action: {0}")] + UnsupportedKeyAction(KeyAction), +} + +pub(crate) struct MutableEditMode { + inner: Arc>, +} + +impl MutableEditMode { + pub fn new(bindings: reedline::Keybindings) -> Self { + Self { + inner: Arc::new(Mutex::new(UpdatableBindings::new(bindings))), + } + } + + pub fn bindings(&self) -> Arc> { + self.inner.clone() + } +} + +impl reedline::EditMode for MutableEditMode { + fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent { + let mut inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); + + inner.parse_event(event) + } + + fn edit_mode(&self) -> reedline::PromptEditMode { + let inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); + + inner.edit_mode() + } +} + +pub(crate) struct UpdatableBindings { + bindings: reedline::Keybindings, + edit_mode: Box, + /// Trie for raw byte sequences. Supports both exact lookups and prefix matching + /// during macro resolution. + raw_mappings: Trie, interfaces::KeyAction>, + /// Tracks defined macros. + macros: HashMap, +} + +impl UpdatableBindings { + pub fn new(bindings: reedline::Keybindings) -> Self { + // Clone the bindings so we can keep a copy for later updates. + let edit_mode = Self::rebuild_edit_mode(&bindings); + + Self { + bindings, + edit_mode, + raw_mappings: Trie::new(), + macros: HashMap::new(), + } + } + + pub fn update(&mut self, f: impl Fn(&mut reedline::Keybindings)) { + f(&mut self.bindings); + self.try_update_bindings_for_all_macros(); + self.edit_mode = Self::rebuild_edit_mode(&self.bindings); + } + + fn rebuild_edit_mode(bindings: &reedline::Keybindings) -> Box { + Box::new(reedline::Emacs::new(bindings.clone())) + } +} + +impl reedline::EditMode for UpdatableBindings { + fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent { + self.edit_mode.parse_event(event) + } + + fn edit_mode(&self) -> reedline::PromptEditMode { + self.edit_mode.edit_mode() + } +} + +impl interfaces::KeyBindings for UpdatableBindings { + fn get_current(&self) -> HashMap { + let mut results = HashMap::new(); + + for (key_combo, event) in self.bindings.get_keybindings() { + let action = translate_reedline_event_to_action(event); + if let Some(action) = action { + if let Some(key) = translate_reedline_keycode(key_combo.key_code) { + let mut stroke = KeyStroke::from(key); + + if key_combo.modifier.contains(reedline::KeyModifiers::CONTROL) { + stroke.control = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::ALT) { + stroke.alt = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::SHIFT) { + stroke.shift = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::HYPER) { + // TODO + } + if key_combo.modifier.contains(reedline::KeyModifiers::META) { + // TODO + } + if key_combo.modifier.contains(reedline::KeyModifiers::SUPER) { + // TODO + } + + let seq = KeySequence::from(stroke); + results.insert(seq, action); + } + } + } + + results + } + + fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction> { + self.raw_mappings.get(bytes) + } + + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error> { + self.do_bind(seq, action, true) + } + + fn try_unbind(&mut self, seq: KeySequence) -> bool { + self.try_unbind_impl(&seq, true) + } + + fn define_macro( + &mut self, + seq: KeySequence, + target: KeySequence, + ) -> Result<(), std::io::Error> { + self.macros.insert(seq, target); + self.update(|_| {}); + + Ok(()) + } + + fn get_macros(&self) -> HashMap { + self.macros.clone() + } +} + +impl UpdatableBindings { + /// Internal implementation that optionally removes from the macros map. + /// When updating bindings for macros, we don't want to remove the macro definition itself. + fn try_unbind_impl(&mut self, seq: &KeySequence, remove_from_macros: bool) -> bool { + // Optionally remove from macros. + let removed_macro = if remove_from_macros { + self.macros.remove(seq).is_some() + } else { + false + }; + + match seq { + interfaces::KeySequence::Strokes(_) => { + if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(seq) { + let found = self.bindings.find_binding(modifiers, key_code).is_some(); + + if found { + self.update(|bindings| { + let _ = bindings.remove_binding(modifiers, key_code); + }); + } + + found || removed_macro + } else { + removed_macro + } + } + interfaces::KeySequence::Bytes(bytes) => { + let flat_bytes: Vec = bytes.iter().flatten().copied().collect(); + let removed_raw = self.raw_mappings.remove(&flat_bytes).is_some(); + + removed_raw || removed_macro + } + } + } + + fn do_bind( + &mut self, + seq: KeySequence, + action: KeyAction, + rebuild_for_reedline: bool, + ) -> Result<(), std::io::Error> { + let Some(event) = translate_action_to_reedline_event(&action) else { + return Err(std::io::Error::other(KeyError::UnsupportedKeyAction( + action, + ))); + }; + + match seq { + interfaces::KeySequence::Strokes(_) => { + if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(&seq) { + if rebuild_for_reedline { + self.update(|bindings| { + bindings.add_binding(modifiers, key_code, event.clone()); + }); + } else { + self.bindings + .add_binding(modifiers, key_code, event.clone()); + } + + Ok(()) + } else { + Err(std::io::Error::other(KeyError::UnsupportedKeySequence(seq))) + } + } + interfaces::KeySequence::Bytes(ref bytes) => { + let flat_bytes: Vec = bytes.iter().flatten().copied().collect(); + self.raw_mappings.insert(flat_bytes, action); + + Ok(()) + } + } + } + + fn try_update_bindings_for_all_macros(&mut self) { + let macros = self.macros.clone(); + for (seq, target) in macros { + let _ = self.update_bindings_for_macro(seq, target); + } + } + + fn update_bindings_for_macro( + &mut self, + seq: KeySequence, + target: KeySequence, + ) -> Result<(), std::io::Error> { + match target { + // TODO(input): We acknowledge that this implementation eagerly resolves the macro + // and what it will do. Subsequent changes to other key binding might invalidate + // this. We also are *extremely* limited in what we support here. + interfaces::KeySequence::Strokes(key_strokes) => { + if key_strokes.is_empty() { + // Empty macro target - unbind any existing binding for this sequence. + self.try_unbind(seq); + } else { + return Err(std::io::Error::other( + "binding key sequence to readline macro with strokes", + )); + } + } + interfaces::KeySequence::Bytes(items) => { + // Flatten all byte sequences into one contiguous buffer for prefix matching. + let flat_bytes: Vec = items.iter().flatten().copied().collect(); + let actions = self.resolve_macro_body(&flat_bytes); + + if actions.is_empty() { + // No actions resolved - unbind any existing key binding for this sequence, + // but keep the macro definition so it shows up in `bind -s/-S`. + self.try_unbind_impl(&seq, false); + return Ok(()); + } + + // Create a single action: either the action itself (if just one), or a Sequence. + let action = if let [single] = actions.as_slice() { + single.clone() + } else { + KeyAction::Sequence(actions) + }; + + self.do_bind(seq, action, false)?; + } + } + + Ok(()) + } + + /// Resolve a macro body (byte sequence) into a sequence of actions using prefix matching. + /// Returns a flattened vector of actions (any nested Sequences are expanded). + fn resolve_macro_body(&self, bytes: &[u8]) -> Vec { + let mut actions = Vec::new(); + let mut remaining = bytes; + + while !remaining.is_empty() { + // Find the longest prefix match in the trie. + if let Some((matched_key, action)) = self.find_longest_prefix_match(remaining) { + // Flatten any nested Sequence actions. + Self::flatten_action_into(&mut actions, action.clone()); + remaining = &remaining[matched_key.len()..]; + } else { + // No match found - skip one byte and continue. + // This handles unbound byte sequences gracefully. + tracing::debug!( + target: trace_categories::INPUT, + "skipping unbound byte in macro resolution: 0x{:02x}", + remaining[0] + ); + remaining = &remaining[1..]; + } + } + + actions + } + + /// Find the longest prefix match in the trie for the given bytes. + /// Uses the trie's native `get_ancestor` method which efficiently finds + /// the longest matching prefix. + fn find_longest_prefix_match(&self, bytes: &[u8]) -> Option<(Vec, &KeyAction)> { + use radix_trie::TrieCommon; + + self.raw_mappings.get_ancestor(bytes).and_then(|subtrie| { + let key = subtrie.key()?.clone(); + let value = subtrie.value()?; + Some((key, value)) + }) + } + + /// Flatten an action into the actions vector, expanding any Sequence variants. + fn flatten_action_into(actions: &mut Vec, action: KeyAction) { + match action { + KeyAction::Sequence(inner_actions) => { + for inner in inner_actions { + Self::flatten_action_into(actions, inner); + } + } + other => actions.push(other), + } + } +} + +fn translate_key_sequence_to_reedline( + seq: &KeySequence, +) -> Option<(reedline::KeyModifiers, reedline::KeyCode)> { + let KeySequence::Strokes(strokes) = seq else { + // TODO(input): handle other kinds of key sequences + return None; + }; + + let [stroke] = &strokes.as_slice() else { + // TODO(input): handle multiple strokes + return None; + }; + + let mut modifiers = reedline::KeyModifiers::empty(); + modifiers.set(reedline::KeyModifiers::ALT, stroke.alt); + modifiers.set(reedline::KeyModifiers::CONTROL, stroke.control); + modifiers.set(reedline::KeyModifiers::SHIFT, stroke.shift); + + let key_code = match stroke.key { + Key::Character(c) => reedline::KeyCode::Char(c), + Key::Backspace => reedline::KeyCode::Backspace, + Key::Enter => reedline::KeyCode::Enter, + Key::Left => reedline::KeyCode::Left, + Key::Right => reedline::KeyCode::Right, + Key::Up => reedline::KeyCode::Up, + Key::Down => reedline::KeyCode::Down, + Key::Home => reedline::KeyCode::Home, + Key::End => reedline::KeyCode::End, + Key::PageUp => reedline::KeyCode::PageUp, + Key::PageDown => reedline::KeyCode::PageDown, + Key::Tab => reedline::KeyCode::Tab, + Key::BackTab => reedline::KeyCode::BackTab, + Key::Delete => reedline::KeyCode::Delete, + Key::Insert => reedline::KeyCode::Insert, + Key::F(n) => reedline::KeyCode::F(n), + Key::Escape => reedline::KeyCode::Esc, + }; + + Some((modifiers, key_code)) +} + +fn translate_action_to_reedline_event(action: &KeyAction) -> Option { + match action { + KeyAction::ShellCommand(cmd) => { + Some(reedline::ReedlineEvent::ExecuteHostCommand(cmd.to_owned())) + } + KeyAction::DoInputFunction(func) => translate_input_function_to_reedline_event(func), + KeyAction::Sequence(actions) => { + // Convert each action in the sequence to a reedline event. + let events: Vec<_> = actions + .iter() + .filter_map(translate_action_to_reedline_event) + .collect(); + + if events.is_empty() { + None + } else if events.len() == 1 { + events.into_iter().next() + } else { + Some(reedline::ReedlineEvent::Multiple(events)) + } + } + } +} + +fn translate_input_function_to_reedline_event( + func: &InputFunction, +) -> Option { + use reedline::{EditCommand, ReedlineEvent}; + + match func { + InputFunction::BackwardDeleteChar => { + Some(ReedlineEvent::Edit(vec![EditCommand::Backspace])) + } + InputFunction::BackwardKillWord => { + Some(ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])) + } + InputFunction::KillLine => Some(ReedlineEvent::Edit(vec![EditCommand::KillLine])), + InputFunction::KillWholeLine => Some(ReedlineEvent::Edit(vec![EditCommand::CutFromStart])), + InputFunction::KillWord => Some(ReedlineEvent::Edit(vec![EditCommand::CutWordRight])), + InputFunction::DeleteChar => Some(ReedlineEvent::Edit(vec![EditCommand::Delete])), + InputFunction::DowncaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::LowercaseWord])), + InputFunction::BackwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveLeft { + select: false, + }])), + InputFunction::ForwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveRight { + select: false, + }])), + InputFunction::EndOfLine => Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd { + select: false, + }])), + InputFunction::BeginningOfLine => { + Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { + select: false, + }])) + } + InputFunction::BackwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordLeft { + select: false, + }])), + InputFunction::ForwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordRight { + select: false, + }])), + InputFunction::Yank => Some(ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])), + InputFunction::ViRedo => Some(ReedlineEvent::Edit(vec![EditCommand::Redo])), + InputFunction::TransposeChars => { + Some(ReedlineEvent::Edit(vec![EditCommand::SwapGraphemes])) + } + InputFunction::UpcaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::UppercaseWord])), + InputFunction::Undo => Some(ReedlineEvent::Edit(vec![EditCommand::Undo])), + InputFunction::ClearScreen => Some(ReedlineEvent::ClearScreen), + InputFunction::AcceptLine => Some(ReedlineEvent::Enter), + InputFunction::HistorySearchBackward => Some(ReedlineEvent::SearchHistory), + InputFunction::RedrawCurrentLine => Some(ReedlineEvent::Repaint), + InputFunction::Complete => Some(ReedlineEvent::Edit(vec![EditCommand::Complete])), + InputFunction::BrushAcceptHint => Some(ReedlineEvent::HistoryHintComplete), + InputFunction::BrushAcceptHintWord => Some(ReedlineEvent::HistoryHintWordComplete), + _ => None, + } +} + +const fn translate_reedline_keycode(keycode: reedline::KeyCode) -> Option { + match keycode { + reedline::KeyCode::Backspace => Some(Key::Backspace), + reedline::KeyCode::Enter => Some(Key::Enter), + reedline::KeyCode::Left => Some(Key::Left), + reedline::KeyCode::Right => Some(Key::Right), + reedline::KeyCode::Up => Some(Key::Up), + reedline::KeyCode::Down => Some(Key::Down), + reedline::KeyCode::Home => Some(Key::Home), + reedline::KeyCode::End => Some(Key::End), + reedline::KeyCode::PageUp => Some(Key::PageUp), + reedline::KeyCode::PageDown => Some(Key::PageDown), + reedline::KeyCode::Tab => Some(Key::Tab), + reedline::KeyCode::BackTab => Some(Key::BackTab), + reedline::KeyCode::Delete => Some(Key::Delete), + reedline::KeyCode::Insert => Some(Key::Insert), + reedline::KeyCode::F(n) => Some(Key::F(n)), + reedline::KeyCode::Char(c) => Some(Key::Character(c)), + reedline::KeyCode::Null => None, + reedline::KeyCode::Esc => Some(Key::Escape), + reedline::KeyCode::CapsLock => None, + reedline::KeyCode::ScrollLock => None, + reedline::KeyCode::NumLock => None, + reedline::KeyCode::PrintScreen => None, + reedline::KeyCode::Pause => None, + reedline::KeyCode::Menu => None, + reedline::KeyCode::KeypadBegin => None, + reedline::KeyCode::Media(_media_key_code) => None, + reedline::KeyCode::Modifier(_modifier_key_code) => None, + } +} + +#[expect(clippy::too_many_lines)] +fn translate_reedline_event_to_action(event: &reedline::ReedlineEvent) -> Option { + match event { + reedline::ReedlineEvent::Edit(cmds) => { + match cmds.as_slice() { + [reedline::EditCommand::Backspace] => Some(KeyAction::DoInputFunction( + InputFunction::BackwardDeleteChar, + )), + [reedline::EditCommand::BackspaceWord] => { + // Not quite accurate, because it doesn't save the deleted text. + Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord)) + } + [reedline::EditCommand::CapitalizeChar] => None, + [reedline::EditCommand::ClearToLineEnd] => { + // Not quite accurate, because it doesn't save the deleted text. + Some(KeyAction::DoInputFunction(InputFunction::KillLine)) + } + [reedline::EditCommand::Complete] => { + Some(KeyAction::DoInputFunction(InputFunction::Complete)) + } + [reedline::EditCommand::CutFromStart] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWholeLine)) + } + [reedline::EditCommand::KillLine] => { + Some(KeyAction::DoInputFunction(InputFunction::KillLine)) + } + [reedline::EditCommand::CutWordLeft] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord)) + } + [reedline::EditCommand::CutWordRight] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWord)) + } + [reedline::EditCommand::Delete] => { + Some(KeyAction::DoInputFunction(InputFunction::DeleteChar)) + } + [reedline::EditCommand::DeleteWord] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWord)) + } + [reedline::EditCommand::InsertNewline] => None, + [reedline::EditCommand::LowercaseWord] => { + Some(KeyAction::DoInputFunction(InputFunction::DowncaseWord)) + } + [reedline::EditCommand::MoveLeft { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardChar)) + } + [reedline::EditCommand::MoveLeft { select: true }] => None, + [reedline::EditCommand::MoveRight { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardChar)) + } + [reedline::EditCommand::MoveRight { select: true }] => None, + [reedline::EditCommand::MoveToEnd { select: false }] => { + // TODO(input): Not quite accurate, because it doesn't just go to end of line. + Some(KeyAction::DoInputFunction(InputFunction::EndOfLine)) + } + [reedline::EditCommand::MoveToEnd { select: true }] => None, + [reedline::EditCommand::MoveToLineEnd { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::EndOfLine)) + } + [reedline::EditCommand::MoveToLineEnd { select: true }] => None, + [reedline::EditCommand::MoveToLineStart { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine)) + } + [reedline::EditCommand::MoveToLineStart { select: true }] => None, + [reedline::EditCommand::MoveToStart { select: false }] => { + // TODO(input): Not quite accurate, because it doesn't just go to beginning of + // line. + Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine)) + } + [reedline::EditCommand::MoveToStart { select: true }] => None, + [reedline::EditCommand::MoveWordLeft { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardWord)) + } + [reedline::EditCommand::MoveWordLeft { select: true }] => None, + [reedline::EditCommand::MoveWordRight { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardWord)) + } + [reedline::EditCommand::MoveWordRight { select: true }] => None, + [reedline::EditCommand::PasteCutBufferAfter] => { + Some(KeyAction::DoInputFunction(InputFunction::Yank)) + } + [reedline::EditCommand::PasteCutBufferBefore] => None, + [reedline::EditCommand::Redo] => { + Some(KeyAction::DoInputFunction(InputFunction::ViRedo)) + } + [reedline::EditCommand::SelectAll] => None, + [reedline::EditCommand::SwapGraphemes] => { + Some(KeyAction::DoInputFunction(InputFunction::TransposeChars)) + } + [reedline::EditCommand::UppercaseWord] => { + Some(KeyAction::DoInputFunction(InputFunction::UpcaseWord)) + } + [reedline::EditCommand::Undo] => { + Some(KeyAction::DoInputFunction(InputFunction::Undo)) + } + _ => { + // TODO(input): Handle more? + tracing::debug!(target: trace_categories::INPUT, "unhandled edit commands: {cmds:?}"); + None + } + } + } + reedline::ReedlineEvent::ClearScreen => { + Some(KeyAction::DoInputFunction(InputFunction::ClearScreen)) + } + reedline::ReedlineEvent::CtrlC => None, + reedline::ReedlineEvent::CtrlD => None, + reedline::ReedlineEvent::Enter => { + Some(KeyAction::DoInputFunction(InputFunction::AcceptLine)) + } + reedline::ReedlineEvent::Esc => None, + reedline::ReedlineEvent::MenuPrevious => None, + reedline::ReedlineEvent::OpenEditor => None, + reedline::ReedlineEvent::Left => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardChar)) + } + reedline::ReedlineEvent::Right => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardChar)) + } + reedline::ReedlineEvent::Up => Some(KeyAction::DoInputFunction( + InputFunction::PreviousScreenLine, + )), + reedline::ReedlineEvent::Down => { + Some(KeyAction::DoInputFunction(InputFunction::NextScreenLine)) + } + reedline::ReedlineEvent::SearchHistory => Some(KeyAction::DoInputFunction( + InputFunction::HistorySearchBackward, + )), + reedline::ReedlineEvent::Repaint => { + Some(KeyAction::DoInputFunction(InputFunction::RedrawCurrentLine)) + } + reedline::ReedlineEvent::HistoryHintComplete => { + Some(KeyAction::DoInputFunction(InputFunction::BrushAcceptHint)) + } + reedline::ReedlineEvent::HistoryHintWordComplete => Some(KeyAction::DoInputFunction( + InputFunction::BrushAcceptHintWord, + )), + reedline::ReedlineEvent::Multiple(evts) => { + if let &[ + reedline::ReedlineEvent::Edit(ref edit_cmds), + reedline::ReedlineEvent::Enter, + ] = evts.as_slice() + { + if let &[ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::InsertChar('#'), + ] = edit_cmds.as_slice() + { + return Some(KeyAction::DoInputFunction(InputFunction::InsertComment)); + } + } + + // TODO(input): Try to extract something from these? + tracing::debug!(target: trace_categories::INPUT, "unhandled composite event: {evts:?}"); + None + } + reedline::ReedlineEvent::UntilFound(uf_events) => { + let mut i = 0; + + if uf_events.is_empty() { + return None; + } + + while i < uf_events.len() { + match &uf_events[i] { + reedline::ReedlineEvent::HistoryHintComplete + | reedline::ReedlineEvent::HistoryHintWordComplete + | reedline::ReedlineEvent::Menu(_) + | reedline::ReedlineEvent::MenuDown + | reedline::ReedlineEvent::MenuUp + | reedline::ReedlineEvent::MenuLeft + | reedline::ReedlineEvent::MenuRight + | reedline::ReedlineEvent::MenuNext + | reedline::ReedlineEvent::MenuPrevious + | reedline::ReedlineEvent::MenuPageNext + | reedline::ReedlineEvent::MenuPagePrevious => { + i += 1; + } + _ => { + break; + } + } + } + + if i == uf_events.len() - 1 { + translate_reedline_event_to_action(&uf_events[i]) + } else { + // TODO(input): Try to extract something from these? + tracing::debug!(target: trace_categories::INPUT, "unhandled until-found event: {uf_events:?}"); + None + } + } + reedline::ReedlineEvent::ExecuteHostCommand(cmd) => { + Some(KeyAction::ShellCommand(cmd.to_owned())) + } + evt => { + // TODO(input): Handle more? + tracing::debug!(target: trace_categories::INPUT, "unhandled event: {evt:?}"); + None + } + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/highlighter.rs b/userland/brush/source/brush-interactive/src/reedline/highlighter.rs new file mode 100644 index 0000000..5d4ca9b --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/highlighter.rs @@ -0,0 +1,51 @@ +use crate::styles::kind_to_style; +use crate::{highlighting, refs}; +use nu_ansi_term::Style; + +pub(crate) struct ReedlineHighlighter { + pub shell: refs::ShellRef, +} + +pub(crate) struct PlainTextHighlighter; + +impl reedline::Highlighter for PlainTextHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> reedline::StyledText { + let mut styled = reedline::StyledText::new(); + styled.push((Style::new(), line.to_owned())); + styled + } +} + +impl reedline::Highlighter for ReedlineHighlighter { + #[expect(clippy::significant_drop_tightening)] + fn highlight(&self, line: &str, cursor: usize) -> reedline::StyledText { + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }); + + let highlighted = highlighting::highlight_command(shell.as_ref(), line, cursor); + + let mut styled = reedline::StyledText::new(); + for (kind, text) in highlighted.iter() { + styled.push((kind_to_style(kind), text.to_owned())); + } + + styled + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reedline::Highlighter; + + #[test] + fn plain_text_highlighter_uses_terminal_default_colors() { + let styled = PlainTextHighlighter.highlight("echo hello", 0); + + assert_eq!(styled.buffer.len(), 1); + assert_eq!(styled.buffer[0].0.foreground, None); + assert_eq!(styled.buffer[0].0.background, None); + assert_eq!(styled.buffer[0].1, "echo hello"); + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/history.rs b/userland/brush/source/brush-interactive/src/reedline/history.rs new file mode 100644 index 0000000..95fb5a0 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/history.rs @@ -0,0 +1,306 @@ +use crate::refs; + +pub(crate) struct ReedlineHistory { + pub shell: refs::ShellRef, +} + +impl ReedlineHistory { + fn lock_shell(&self) -> tokio::sync::MutexGuard<'_, brush_core::Shell> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }) + } +} + +impl reedline::History for ReedlineHistory { + /// Updates or adds a new item to the saved history. + /// + /// # Arguments + /// + /// * `item` - The history item to save. + fn save(&mut self, item: reedline::HistoryItem) -> reedline::Result { + // + // TODO(history): Evaluate a way to rationalize between this and the shared + // history saving. For now, we need to do nothing here to avoid + // duplicate history items since we are auto-updating the history + // in a non-reedline-specific way. + // + + // let brush_item = reedline_history_item_to_brush(&item); + // let mut shell = self.lock_shell(); + // let history = get_shell_history_mut(&mut shell)?; + // + // if let Some(id) = &item.id { + // history + // .update_by_id(id.0, brush_item) + // .map_err(brush_error_to_reedline)?; + // } else { + // let id = history.add(brush_item).map_err(brush_error_to_reedline)?; + // item.id = Some(reedline::HistoryItemId(id)); + // } + + Ok(item) + } + + /// Loads a history item by its ID. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to load. + fn load(&self, id: reedline::HistoryItemId) -> reedline::Result { + let shell = self.lock_shell(); + + // Get the history, retrieve the item, and translate the item it into reedline's format. + get_shell_history(&shell)? + .get_by_id(id.0) + .map_err(brush_error_to_reedline)? + .ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::OtherHistoryError( + "history item not found", + )) + }) + .map(brush_history_item_to_reedline) + } + + /// Counts all history items matching the given query. + /// + /// # Arguments + /// + /// * `query` - The search query to match against history items. + fn count(&self, query: reedline::SearchQuery) -> reedline::Result { + let query = reedline_history_query_into_brush(query)?; + + let shell = self.lock_shell(); + let count = get_shell_history(&shell)? + .search(query) + .map_err(brush_error_to_reedline)? + .count(); + drop(shell); + + #[expect(clippy::cast_possible_wrap)] + Ok(count as i64) + } + + /// Searches through history, returning all items matching the given query. + /// + /// # Arguments + /// + /// * `query` - The search query to match against history items. + #[expect(clippy::significant_drop_tightening)] + fn search(&self, query: reedline::SearchQuery) -> reedline::Result> { + let query = reedline_history_query_into_brush(query)?; + let shell = self.lock_shell(); + let items = get_shell_history(&shell)? + .search(query) + .map_err(brush_error_to_reedline)? + .map(|item| { + // Translate the item into reedline's format. + brush_history_item_to_reedline(item) + }) + .collect::>(); + + Ok(items) + } + + /// Update a history item. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to update. + /// * `updater` - A function that takes a history item and returns an updated history item. + fn update( + &mut self, + id: reedline::HistoryItemId, + updater: &dyn Fn(reedline::HistoryItem) -> reedline::HistoryItem, + ) -> reedline::Result<()> { + // TODO(history): Understand atomicity expectations of reedline. + let item = self.load(id)?; + let updated_item = updater(item); + self.save(updated_item)?; + + Ok(()) + } + + /// Delete all history items. + fn clear(&mut self) -> reedline::Result<()> { + let mut shell = self.lock_shell(); + + // Get the history, retrieve the item, and translate the item it into reedline's format. + get_shell_history_mut(&mut shell)? + .clear() + .map_err(brush_error_to_reedline) + } + + /// Delete the history item with the given ID. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to delete. + fn delete(&mut self, id: reedline::HistoryItemId) -> reedline::Result<()> { + let mut shell = self.lock_shell(); + + get_shell_history_mut(&mut shell)? + .delete_item_by_id(id.0) + .map_err(brush_error_to_reedline) + } + + /// Sync all history items to backing storage. + fn sync(&mut self) -> std::io::Result<()> { + let mut shell = self.lock_shell(); + shell.save_history().map_err(std::io::Error::other) + } + + /// Retrieves a unique ID for the current session. + fn session(&self) -> Option { + // Not implemented for now. + None + } +} + +fn brush_history_item_to_reedline(item: &brush_core::history::Item) -> reedline::HistoryItem { + let mut rl_item = reedline::HistoryItem::from_command_line(item.command_line.as_str()); + rl_item.id = Some(reedline::HistoryItemId(item.id)); + rl_item.start_timestamp = item.timestamp; + + rl_item +} + +#[expect(unused)] +fn reedline_history_item_to_brush(item: &reedline::HistoryItem) -> brush_core::history::Item { + // TODO(history): implement more fields when they are added to Item + brush_core::history::Item { + id: item.id.map_or(0, |id| id.0), + command_line: item.command_line.clone(), + timestamp: item.start_timestamp, + dirty: true, + } +} + +fn brush_error_to_reedline(error: brush_core::Error) -> reedline::ReedlineError { + reedline::ReedlineError::from(std::io::Error::other(error)) +} + +fn reedline_history_query_into_brush( + query: reedline::SearchQuery, +) -> reedline::Result { + let mut result = brush_core::history::Query { + direction: match query.direction { + reedline::SearchDirection::Forward => brush_core::history::Direction::Forward, + reedline::SearchDirection::Backward => brush_core::history::Direction::Backward, + }, + max_items: query.limit, + not_at_or_before_id: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.end_id.map(|id| id.0) + } else { + query.start_id.map(|id| id.0) + }, + not_at_or_after_id: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.start_id.map(|id| id.0) + } else { + query.end_id.map(|id| id.0) + }, + not_at_or_before_time: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.end_time + } else { + query.start_time + }, + not_at_or_after_time: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.start_time + } else { + query.end_time + }, + ..Default::default() + }; + + if let Some(cmdline_filter) = query.filter.command_line { + result.command_line_filter = match cmdline_filter { + reedline::CommandLineSearch::Exact(cmdline) => { + Some(brush_core::history::CommandLineFilter::Exact(cmdline)) + } + reedline::CommandLineSearch::Substring(cmdline) => { + Some(brush_core::history::CommandLineFilter::Contains(cmdline)) + } + reedline::CommandLineSearch::Prefix(cmdline) => { + Some(brush_core::history::CommandLineFilter::Prefix(cmdline)) + } + } + } + + if query.filter.cwd_exact.is_some() + || query.filter.cwd_prefix.is_some() + || query.filter.exit_successful.is_some() + || query.filter.hostname.is_some() + || query.filter.session.is_some() + { + return Err(reedline::ReedlineError( + reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "search filter", + }, + )); + } + + Ok(result) +} + +fn get_shell_history<'a, SE: brush_core::ShellExtensions>( + shell: &'a tokio::sync::MutexGuard<'_, brush_core::Shell>, +) -> Result<&'a brush_core::history::History, reedline::ReedlineError> { + shell.history().ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "load", + }) + }) +} + +fn get_shell_history_mut<'a, SE: brush_core::ShellExtensions>( + shell: &'a mut tokio::sync::MutexGuard<'_, brush_core::Shell>, +) -> Result<&'a mut brush_core::history::History, reedline::ReedlineError> { + shell.history_mut().ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "load", + }) + }) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + use reedline::History as _; + + /// A count is of the items that match the query. + #[tokio::test(flavor = "multi_thread")] + async fn count_counts_matching_items() -> Result<(), Box> { + // An interactive shell keeps history; with no HISTFILE, it's not loaded or saved. + let mut shell = brush_core::Shell::builder() + .interactive(true) + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .var("HISTFILE", brush_core::ShellVariable::new("")) + .build() + .await?; + let history = shell.history_mut().ok_or("no history")?; + for line in ["ls", "echo a", "echo b"] { + history.add(brush_core::history::Item::new(line))?; + } + let history = ReedlineHistory { + shell: std::sync::Arc::new(tokio::sync::Mutex::new(shell)), + }; + + let everything = + || reedline::SearchQuery::everything(reedline::SearchDirection::Backward, None); + assert_eq!(history.count(everything())?, 3); + + let mut echo = everything(); + echo.filter = reedline::SearchFilter::from_text_search( + reedline::CommandLineSearch::Prefix("echo".to_owned()), + None, + ); + assert_eq!(history.count(echo)?, 2); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/input_backend.rs b/userland/brush/source/brush-interactive/src/reedline/input_backend.rs new file mode 100644 index 0000000..0b8c74d --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/input_backend.rs @@ -0,0 +1,298 @@ +use nu_ansi_term::Style; +use reedline::MenuBuilder; + +use super::{completer, edit_mode, highlighter, history, validator}; +use crate::{InputBackend, ReadResult, ShellError, input_backend::InteractivePrompt, refs}; + +/// Represents an interactive shell capable of taking commands from standard input +/// and reporting results to standard output and standard error streams. +pub struct ReedlineInputBackend { + reedline: Option, +} + +const COMPLETION_MENU_NAME: &str = "completion_menu"; + +fn completion_menu_text_style() -> Style { + Style::new() +} + +fn completion_menu_selected_text_style() -> Style { + Style::new().bold().reverse() +} + +fn completion_menu_match_text_style() -> Style { + Style::new().underline() +} + +fn completion_menu_selected_match_text_style() -> Style { + completion_menu_selected_text_style().underline() +} + +fn history_hint_style() -> Style { + Style::new().italic().dimmed() +} + +impl ReedlineInputBackend { + /// Returns a new interactive shell instance, created with the provided options. + /// + /// # Arguments + /// + /// * `options` - Options for creating the input backend. + /// * `shell_ref` - Shell that the backend will be used with. + pub fn new( + options: &crate::UIOptions, + shell_ref: &refs::ShellRef, + ) -> Result { + // Set up key bindings. + let key_bindings = compose_key_bindings(COMPLETION_MENU_NAME); + + // Set up mutable edit mode. + let mutable_edit_mode = edit_mode::MutableEditMode::new(key_bindings); + let updatable_bindings = mutable_edit_mode.bindings(); + + // Create helper objects that implement reedline traits; each will + // hold a reference to the shell. + let completer = completer::ReedlineCompleter { + shell: shell_ref.clone(), + }; + let validator = validator::ReedlineValidator { + shell: shell_ref.clone(), + }; + let syntax_highlighter = highlighter::ReedlineHighlighter { + shell: shell_ref.clone(), + }; + let history = history::ReedlineHistory { + shell: shell_ref.clone(), + }; + + // Set up completion menu. Set an empty marker to avoid the + // line's text horizontally shifting around during/after completion. + // We set a max column count of 10 to ensure it's larger than the + // hard-coded default (4 last we checked); if there's not enough + // horizontal space in the terminal to fit that many columns, given + // the actual text to be displayed, it will get effectively dereased + // anyhow. + let completion_menu = Box::new( + reedline::ColumnarMenu::default() + .with_name(COMPLETION_MENU_NAME) + .with_marker("") + .with_columns(10) + // Like readline, hand completion the whole line, not just the text before + // the cursor: completion functions see the words after the cursor (e.g. in + // COMP_WORDS), and candidates completed mid-line don't get a trailing space. + .with_input_mode(reedline::InputMode::FullBuffer) + .with_text_style(completion_menu_text_style()) + .with_match_text_style(completion_menu_match_text_style()) + .with_selected_text_style(completion_menu_selected_text_style()) + .with_selected_match_text_style(completion_menu_selected_match_text_style()), + ); + + // Set up default history-based hinter. + let mut hinter = reedline::DefaultHinter::default(); + if !options.disable_color { + hinter = hinter.with_style(history_hint_style()); + } + + // Instantiate reedline with some defaults and hand it ownership of + // the helpers. + let mut reedline = reedline::Reedline::create() + .with_ansi_colors(!options.disable_color) + .use_bracketed_paste(!options.disable_bracketed_paste) + .with_completer(Box::new(completer)) + .with_quick_completions(true) + .with_validator(Box::new(validator)) + .with_hinter(Box::new(hinter)) + .with_menu(reedline::ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(Box::new(mutable_edit_mode)) + .with_history(Box::new(history)); + + // Override Reedline's default example highlighter, which hard-codes white as the + // neutral input color. When syntax highlighting is disabled we still install a plain + // highlighter so typed text follows the terminal's default foreground color. + if !options.disable_color { + reedline = if options.disable_highlighting { + reedline.with_highlighter(Box::new(highlighter::PlainTextHighlighter)) + } else { + reedline.with_highlighter(Box::new(syntax_highlighter)) + }; + } + + let mut shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(shell_ref.lock()) + }); + + shell.set_key_bindings(Some(updatable_bindings)); + drop(shell); + + Ok(Self { + reedline: Some(reedline), + }) + } +} + +impl Drop for ReedlineInputBackend { + fn drop(&mut self) { + // It's unpleasant to need to do so, but if we detect a panic in the process of being + // unwound, then we arrange for our reedline::Reedline instance to *not* get dropped. + // Without this, then there's a chance that our panic handler emitted important + // diagnostics to stdout but dropping the Reedline object will end up erasing it + // when the latter object's internal Painter gets dropped and, in turn, may flush + // some not-yet-flushed terminal control sequences. This isn't theoretical; we've + // actively seen this in various cases where a panic occurs with Reedline::read_line() + // on the stack. + if std::thread::panicking() { + let reedline = std::mem::take(&mut self.reedline); + std::mem::forget(reedline); + } + } +} + +impl InputBackend for ReedlineInputBackend { + /// Reads a line of input, using the given prompt. + /// + /// # Arguments + /// + /// * `prompt` - The prompt to display to the user. + #[allow( + clippy::unused_async_trait_impl, + reason = "this backend reads synchronously; the trait is async for those that don't" + )] + async fn read_line( + &mut self, + _shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + let Some(reedline) = &mut self.reedline else { + return Ok(ReadResult::Eof); + }; + + match reedline.read_line(&prompt) { + Ok(reedline::Signal::Success(s)) => Ok(ReadResult::Input(s)), + Ok(reedline::Signal::CtrlC) => Ok(ReadResult::Interrupted), + Ok(reedline::Signal::CtrlD) => Ok(ReadResult::Eof), + Ok(reedline::Signal::HostCommand(cmd)) => Ok(ReadResult::BoundCommand(cmd)), + Ok(_) => Err(ShellError::UnexpectedInputFailure), + Err(err) => Err(ShellError::InputError(err)), + } + } + + fn get_read_buffer(&self) -> Option<(String, usize)> { + self.reedline.as_ref().map(|r| { + ( + r.current_buffer_contents().to_owned(), + r.current_insertion_point(), + ) + }) + } + + fn set_read_buffer(&mut self, buffer: String, cursor: usize) { + if let Some(reedline) = &mut self.reedline { + reedline.run_edit_commands(&[ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::ClearToLineEnd, + reedline::EditCommand::InsertString(buffer), + reedline::EditCommand::MoveToPosition { + position: cursor, + select: false, + }, + ]); + } + } +} + +fn compose_key_bindings(completion_menu_name: &str) -> reedline::Keybindings { + let mut key_bindings = reedline::default_emacs_keybindings(); + + // Wire up tab to completion. + key_bindings.add_binding( + reedline::KeyModifiers::NONE, + reedline::KeyCode::Tab, + reedline::ReedlineEvent::UntilFound(vec![ + reedline::ReedlineEvent::Menu(completion_menu_name.to_string()), + reedline::ReedlineEvent::MenuNext, + reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Complete]), + ]), + ); + // Wire up shift-tab for completion. + key_bindings.add_binding( + reedline::KeyModifiers::SHIFT, + reedline::KeyCode::BackTab, + reedline::ReedlineEvent::MenuPrevious, + ); + + // Add undo. + // NOTE: To match readline, we bind Ctrl+_ to undo; in practice, the only way + // to get that to work out is to specify Ctrl+7 for the binding. It's not clear + // that this is terribly portable across terminals/environments. + key_bindings.add_binding( + reedline::KeyModifiers::CONTROL, + reedline::KeyCode::Char('7'), + reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Undo]), + ); + + // Capitalize. + key_bindings.add_binding( + reedline::KeyModifiers::ALT, + reedline::KeyCode::Char('c'), + reedline::ReedlineEvent::Edit(vec![ + reedline::EditCommand::CapitalizeChar, + reedline::EditCommand::MoveWordRight { select: false }, + ]), + ); + + // Add comment. + key_bindings.add_binding( + reedline::KeyModifiers::ALT, + reedline::KeyCode::Char('#'), + reedline::ReedlineEvent::Multiple(vec![ + reedline::ReedlineEvent::Edit(vec![ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::InsertChar('#'), + ]), + reedline::ReedlineEvent::Enter, + ]), + ); + + key_bindings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_hint_style_is_theme_adaptive() { + let style = history_hint_style(); + + assert_eq!(style.foreground, None); + assert_eq!(style.background, None); + assert!(style.is_italic); + assert!(style.is_dimmed); + } + + #[test] + fn completion_menu_styles_are_theme_adaptive() { + let text_style = completion_menu_text_style(); + let match_style = completion_menu_match_text_style(); + let selected_text_style = completion_menu_selected_text_style(); + let selected_match_text_style = completion_menu_selected_match_text_style(); + + assert_eq!(text_style.foreground, None); + assert_eq!(text_style.background, None); + + assert_eq!(match_style.foreground, None); + assert_eq!(match_style.background, None); + assert!(match_style.is_underline); + + assert_eq!(selected_text_style.foreground, None); + assert_eq!(selected_text_style.background, None); + assert!(selected_text_style.is_bold); + assert!(selected_text_style.is_reverse); + + assert_eq!(selected_match_text_style.foreground, None); + assert_eq!(selected_match_text_style.background, None); + assert!(selected_match_text_style.is_bold); + assert!(selected_match_text_style.is_reverse); + assert!(selected_match_text_style.is_underline); + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/mod.rs b/userland/brush/source/brush-interactive/src/reedline/mod.rs new file mode 100644 index 0000000..0b2667f --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/mod.rs @@ -0,0 +1,9 @@ +mod completer; +mod edit_mode; +mod highlighter; +mod history; +mod input_backend; +mod prompt; +mod validator; + +pub use input_backend::ReedlineInputBackend; diff --git a/userland/brush/source/brush-interactive/src/reedline/prompt.rs b/userland/brush/source/brush-interactive/src/reedline/prompt.rs new file mode 100644 index 0000000..df8a9dc --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/prompt.rs @@ -0,0 +1,69 @@ +use crate::input_backend::InteractivePrompt; + +impl reedline::Prompt for InteractivePrompt { + fn render_prompt_left(&self) -> std::borrow::Cow<'_, str> { + // [Workaround: see https://github.com/nushell/reedline/issues/707] + // If the prompt starts with a newline character, then there's a chance + // that it won't be rendered correctly. For this specific case, insert + // an extra space character before the newline. + if self.prompt.starts_with('\n') { + std::format!(" {}", self.prompt).into() + } else { + self.prompt.as_str().into() + } + } + + fn render_prompt_right(&self) -> std::borrow::Cow<'_, str> { + self.alt_side_prompt.as_str().into() + } + + // N.B. For now, we don't support prompt indicators. + fn render_prompt_indicator( + &self, + _prompt_mode: reedline::PromptEditMode, + ) -> std::borrow::Cow<'_, str> { + "".into() + } + + fn render_prompt_multiline_indicator(&self) -> std::borrow::Cow<'_, str> { + self.continuation_prompt.as_str().into() + } + + fn render_prompt_history_search_indicator( + &self, + history_search: reedline::PromptHistorySearch, + ) -> std::borrow::Cow<'_, str> { + match history_search.status { + reedline::PromptHistorySearchStatus::Passing => { + if history_search.term.is_empty() { + "(rev search) ".into() + } else { + std::format!("(rev search: {}) ", history_search.term).into() + } + } + reedline::PromptHistorySearchStatus::Failing => { + std::format!("(failing rev search: {}) ", history_search.term).into() + } + } + } + + fn get_prompt_color(&self) -> reedline::Color { + reedline::Color::Default + } + + fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color { + nu_ansi_term::Color::LightBlue + } + + fn get_indicator_color(&self) -> reedline::Color { + reedline::Color::Cyan + } + + fn get_prompt_right_color(&self) -> reedline::Color { + reedline::Color::Fixed(5) + } + + fn right_prompt_on_last_line(&self) -> bool { + false + } +} diff --git a/userland/brush/source/brush-interactive/src/reedline/validator.rs b/userland/brush/source/brush-interactive/src/reedline/validator.rs new file mode 100644 index 0000000..e78d0be --- /dev/null +++ b/userland/brush/source/brush-interactive/src/reedline/validator.rs @@ -0,0 +1,15 @@ +use crate::refs; + +pub(crate) struct ReedlineValidator { + pub shell: refs::ShellRef, +} + +impl reedline::Validator for ReedlineValidator { + fn validate(&self, line: &str) -> reedline::ValidationResult { + if crate::completeness::needs_more_input(&self.shell, line) { + reedline::ValidationResult::Incomplete + } else { + reedline::ValidationResult::Complete + } + } +} diff --git a/userland/brush/source/brush-interactive/src/refs.rs b/userland/brush/source/brush-interactive/src/refs.rs new file mode 100644 index 0000000..de092e4 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/refs.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +/// A reference-counted, thread-safe reference to a `brush_core::Shell`. +#[allow(type_alias_bounds)] +pub type ShellRef< + SE: brush_core::ShellExtensions = brush_core::extensions::DefaultShellExtensions, +> = Arc>>; diff --git a/userland/brush/source/brush-interactive/src/styles.rs b/userland/brush/source/brush-interactive/src/styles.rs new file mode 100644 index 0000000..9a61122 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/styles.rs @@ -0,0 +1,89 @@ +//! The default syntax highlighting palette shared by input backends. + +use crate::highlighting; +use nu_ansi_term::{Color, Style}; + +fn default() -> Style { + Style::new().fg(Color::Default) +} + +fn comment() -> Style { + Style::new().fg(Color::DarkGray) +} + +fn arithmetic() -> Style { + Style::new().fg(Color::LightBlue) +} + +fn parameter() -> Style { + Style::new().fg(Color::LightMagenta) +} + +fn command_substitution() -> Style { + Style::new().fg(Color::LightBlue) +} + +fn quoted() -> Style { + Style::new().fg(Color::Yellow) +} + +fn operator() -> Style { + Style::new().fg(Color::Default).italic() +} + +fn assignment() -> Style { + Style::new().fg(Color::LightGray).dimmed() +} + +fn hyphen_option() -> Style { + Style::new().fg(Color::Default).italic() +} + +fn function() -> Style { + Style::new().bold().fg(Color::Yellow) +} + +fn keyword() -> Style { + Style::new().bold().fg(Color::LightYellow).italic() +} + +fn builtin() -> Style { + Style::new().bold().fg(Color::Green) +} + +fn alias() -> Style { + Style::new().bold().fg(Color::Cyan) +} + +fn external_command() -> Style { + Style::new().bold().fg(Color::Green) +} + +fn not_found_command() -> Style { + Style::new().bold().fg(Color::Red) +} + +fn unknown_command() -> Style { + Style::new().bold().fg(Color::Default) +} + +pub(crate) fn kind_to_style(kind: highlighting::HighlightKind) -> Style { + match kind { + highlighting::HighlightKind::Default => default(), + highlighting::HighlightKind::Comment => comment(), + highlighting::HighlightKind::Arithmetic => arithmetic(), + highlighting::HighlightKind::Parameter => parameter(), + highlighting::HighlightKind::CommandSubstitution => command_substitution(), + highlighting::HighlightKind::Quoted => quoted(), + highlighting::HighlightKind::Operator => operator(), + highlighting::HighlightKind::Assignment => assignment(), + highlighting::HighlightKind::HyphenOption => hyphen_option(), + highlighting::HighlightKind::Function => function(), + highlighting::HighlightKind::Keyword => keyword(), + highlighting::HighlightKind::Builtin => builtin(), + highlighting::HighlightKind::Alias => alias(), + highlighting::HighlightKind::ExternalCommand => external_command(), + highlighting::HighlightKind::NotFoundCommand => not_found_command(), + highlighting::HighlightKind::UnknownCommand => unknown_command(), + } +} diff --git a/userland/brush/source/brush-interactive/src/term_detection.rs b/userland/brush/source/brush-interactive/src/term_detection.rs new file mode 100644 index 0000000..c3299a3 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/term_detection.rs @@ -0,0 +1,412 @@ +/// Holds information about the hosting terminal. +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] +pub struct TerminalInfo { + /// The detected terminal, if any. + pub terminal: Option, + + /// If applicable, a session nonce assigned by the terminal. + pub session_nonce: Option, + + /// Whether the terminal's OSC support is unknown. + pub osc_support_unknown: bool, + + /// Whether the terminal supports OSC 0 sequences: setting terminal title and icon. + pub supports_osc_0: bool, + + /// Whether the terminal supports OSC 1 sequences: setting icon name. + pub supports_osc_1: bool, + + /// Whether the terminal supports OSC 2 sequences: setting terminal title. + pub supports_osc_2: bool, + + /// Whether the terminal supports OSC 3 sequences: setting X11 window properties. + pub supports_osc_3: bool, + + /// Whether the terminal supports OSC 4 sequences: setting color palette. + pub supports_osc_4: bool, + + /// Whether the terminal supports OSC 5 sequences: setting/querying special color number. + pub supports_osc_5: bool, + + /// Whether the terminal supports OSC 6 sequences: setting title tab color (iTerm2) + pub supports_osc_6: bool, + + /// Whether the terminal supports OSC 7 sequences: setting current working directory. + pub supports_osc_7: bool, + + /// Whether the terminal supports OSC 8 sequences: hyperlinks. + pub supports_osc_8: bool, + + /// Whether the terminal supports OSC 9 sequences: showing system notification (iTerm2). + pub supports_osc_9: bool, + + /// Whether the terminal supports OSC 10 sequences: setting default foreground color. + pub supports_osc_10: bool, + + /// Whether the terminal supports OSC 11 sequences: setting default background color. + pub supports_osc_11: bool, + + /// Whether the terminal supports OSC 12 sequences: setting cursor color. + pub supports_osc_12: bool, + + /// Whether the terminal supports OSC 13 sequences: setting pointer foreground color. + pub supports_osc_13: bool, + + /// Whether the terminal supports OSC 14 sequences: setting pointer background color. + pub supports_osc_14: bool, + + /// Whether the terminal supports OSC 15 sequences: setting Tektronix foreground color. + pub supports_osc_15: bool, + + /// Whether the terminal supports OSC 16 sequences: setting Tektronix background color. + pub supports_osc_16: bool, + + /// Whether the terminal supports OSC 17 sequences: setting highlight background color. + pub supports_osc_17: bool, + + /// Whether the terminal supports OSC 18 sequences: setting Tektronix cursor color. + pub supports_osc_18: bool, + + /// Whether the terminal supports OSC 19 sequences: setting highlight foreground color. + pub supports_osc_19: bool, + + /// Whether the terminal supports OSC 21 sequences: color control (Kitty extension). + pub supports_osc_21: bool, + + /// Whether the terminal supports OSC 22 sequences: setting mouse pointer. + pub supports_osc_22: bool, + + /// Whether the terminal supports OSC 50 sequences: setting font. + pub supports_osc_50: bool, + + /// Whether the terminal supports OSC 52 sequences: clipboard and primary selection. + pub supports_osc_52: bool, + + /// Whether the terminal supports OSC 66 sequences: scoped text size. + pub supports_osc_66: bool, + + /// Whether the terminal supports OSC 99 sequences: desktop notifications. + pub supports_osc_99: bool, + + /// Whether the terminal supports OSC 104 sequences: resetting color palette. + pub supports_osc_104: bool, + + /// Whether the terminal supports OSC 105 sequences: resetting special colors. + pub supports_osc_105: bool, + + /// Whether the terminal supports OSC 110 sequences: resetting default foreground color. + pub supports_osc_110: bool, + + /// Whether the terminal supports OSC 111 sequences: resetting default background color. + pub supports_osc_111: bool, + + /// Whether the terminal supports OSC 112 sequences: resetting cursor color. + pub supports_osc_112: bool, + + /// Whether the terminal supports OSC 113 sequences: resetting pointer foreground color. + pub supports_osc_113: bool, + + /// Whether the terminal supports OSC 114 sequences: resetting pointer background color. + pub supports_osc_114: bool, + + /// Whether the terminal supports OSC 115 sequences: resetting Tektronix foreground color. + pub supports_osc_115: bool, + + /// Whether the terminal supports OSC 116 sequences: resetting Tektronix background color. + pub supports_osc_116: bool, + + /// Whether the terminal supports OSC 117 sequences: resetting highlight background color + pub supports_osc_117: bool, + + /// Whether the terminal supports OSC 118 sequences: resetting Tektronix cursor color. + pub supports_osc_118: bool, + + /// Whether the terminal supports OSC 119 sequences: resetting highlight foreground color + pub supports_osc_119: bool, + + /// Whether the terminal supports OSC 133 sequences: shell integration (input, output, and + /// prompt zones). + pub supports_osc_133: bool, + + /// Whether the terminal supports OSC 176 sequences: setting app ID. + pub supports_osc_176: bool, + + /// Whether the terminal supports OSC 555 sequences: flashing screen (foot-specific) + pub supports_osc_555: bool, + + /// Whether the terminal supports OSC 633 sequences: shell integration (`VSCode` extension). + pub supports_osc_633: bool, + + /// Whether the terminal supports OSC 777 sequences: desktop notifications / rxvt extensions. + pub supports_osc_777: bool, + + /// Whether the terminal supports OSC 1337 sequences: custom iTerm2 sequences. + pub supports_osc_1337: bool, + + /// Whether the terminal supports OSC 5113 sequences: file transfer (Kitty extension). + pub supports_osc_5113: bool, + + /// Whether the terminal supports OSC 5522 sequences: advanced clipboard interaction (Kitty + /// extension). + pub supports_osc_5522: bool, + + /// Whether the terminal supports OSC 9001 sequences: Windows Terminal extensions. + pub supports_osc_9001: bool, +} + +/// Identifies a known terminal emulator hosting this process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KnownTerminal { + /// Alacritty + Alacritty, + /// Apple terminal + AppleTerminal, + /// Ghostty + Ghostty, + /// GNOME Terminal + GnomeTerminal, + /// iTerm2 + ITerm2, + /// Kitty + Kitty, + /// Konsole + Konsole, + /// `VSCode` Terminal + VSCode, + /// Other VTE-based terminal + Vte, + /// Warp Terminal + WarpTerminal, + /// `WezTerm` + WezTerm, + /// Windows Terminal + WindowsTerminal, +} + +/// Abstracts access to environment variables used for terminal detection. +pub(crate) trait TerminalEnvironment { + /// Gets the value of an environment variable. Returns `None` if the variable is not set. + fn get_env_var(&self, key: &str) -> Option; +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn get_terminal_info(env: &impl TerminalEnvironment) -> TerminalInfo { + let mut info = TerminalInfo { + terminal: try_detect_terminal(env), + ..Default::default() + }; + + if let Some(terminal) = &info.terminal { + match terminal { + KnownTerminal::Alacritty => { + // https://github.com/alacritty/alacritty/blob/master/docs/escape_support.md + info.supports_osc_0 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_8 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_50 = true; // only cursor shape supported + info.supports_osc_52 = true; // only clipboard and primary selection supported + info.supports_osc_104 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + } + KnownTerminal::Ghostty => { + // https://ghostty.org/docs/vt/osc/0 + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_5 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_21 = true; + info.supports_osc_22 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_105 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + } + KnownTerminal::ITerm2 => { + // https://iterm2.com/documentation-escape-codes.html + info.supports_osc_4 = true; + info.supports_osc_6 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_133 = true; + info.supports_osc_1337 = true; + } + KnownTerminal::Kitty => { + // https://sw.kovidgoyal.net/kitty/protocol-extensions/ + info.supports_osc_21 = true; + info.supports_osc_22 = true; + info.supports_osc_66 = true; + info.supports_osc_5113 = true; + info.supports_osc_5522 = true; + } + KnownTerminal::VSCode => { + // https://code.visualstudio.com/docs/terminal/shell-integration + // https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/browser/terminalEscapeSequences.ts + info.supports_osc_7 = true; + info.supports_osc_9 = true; + info.supports_osc_133 = true; + info.supports_osc_633 = true; + info.supports_osc_1337 = true; + info.session_nonce = env.get_env_var("VSCODE_NONCE"); + } + KnownTerminal::WezTerm => { + // https://wezterm.org/escape-sequences.html + // https://wezterm.org/shell-integration.html + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_133 = true; + info.supports_osc_777 = true; + info.supports_osc_1337 = true; + } + KnownTerminal::WindowsTerminal => { + // https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration + // https://github.com/microsoft/terminal/blob/main/src/terminal/parser/OutputStateMachineEngine.hpp + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_17 = true; + info.supports_osc_21 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + info.supports_osc_117 = true; + info.supports_osc_133 = true; + info.supports_osc_633 = true; + info.supports_osc_1337 = true; + info.supports_osc_9001 = true; + } + _ => { + info.osc_support_unknown = true; + } + } + } else { + info.osc_support_unknown = true; + } + + info +} + +/// Tries to detect the hosting terminal. +/// +/// # Arguments +/// +/// * `env` - An implementation of `TerminalEnvironment` to access environment variables. +pub(crate) fn try_detect_terminal(env: &impl TerminalEnvironment) -> Option { + if let Some(detected) = try_detect_terminal_from_prog_var(env) { + Some(detected) + } else if env.get_env_var("WT_SESSION").is_some() { + Some(KnownTerminal::WindowsTerminal) + } else { + None + } +} + +fn try_detect_terminal_from_prog_var(env: &impl TerminalEnvironment) -> Option { + let term_prog = env.get_env_var("TERM_PROGRAM")?; + + // Remove punctuation and normalize. + let term_prog: String = term_prog + .chars() + .filter(|c| c.is_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + + match term_prog.as_str() { + "alacritty" => Some(KnownTerminal::Alacritty), + "appleterminal" => Some(KnownTerminal::AppleTerminal), + "ghostty" => Some(KnownTerminal::Ghostty), + "gnometerminal" => Some(KnownTerminal::GnomeTerminal), + "iterm" | "iterm2" | "itermapp" => Some(KnownTerminal::ITerm2), + "kitty" => Some(KnownTerminal::Kitty), + "konsole" => Some(KnownTerminal::Konsole), + "vscode" => Some(KnownTerminal::VSCode), + "vte" => Some(KnownTerminal::Vte), + "warp" | "warpterminal" => Some(KnownTerminal::WarpTerminal), + "wezterm" => Some(KnownTerminal::WezTerm), + "windowsterminal" => Some(KnownTerminal::WindowsTerminal), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_matches; + use std::collections::HashMap; + + impl TerminalEnvironment for HashMap<&str, &str> { + fn get_env_var(&self, key: &str) -> Option { + self.get(key).map(|v| (*v).to_string()) + } + } + + #[test] + fn no_term_program() { + let test_env = HashMap::new(); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, None); + assert!(term_info.osc_support_unknown); + } + + #[test] + fn unknown_term_program() { + let test_env = HashMap::from([("TERM_PROGRAM", "unknown_terminal")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, None); + assert!(term_info.osc_support_unknown); + } + + #[test] + fn vscode_recognition() { + let test_env = HashMap::from([("TERM_PROGRAM", "vscode"), ("VSCODE_NONCE", "test_nonce")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, Some(KnownTerminal::VSCode)); + assert!(term_info.supports_osc_633); + assert_eq!(term_info.session_nonce, Some("test_nonce".to_string())); + } + + #[test] + fn windows_terminal_recognition() { + let test_env = HashMap::from([("WT_SESSION", "some_value")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, Some(KnownTerminal::WindowsTerminal)); + assert!(term_info.supports_osc_9001); + } +} diff --git a/userland/brush/source/brush-interactive/src/term_integration.rs b/userland/brush/source/brush-interactive/src/term_integration.rs new file mode 100644 index 0000000..822a424 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/term_integration.rs @@ -0,0 +1,300 @@ +use std::borrow::Cow; +use std::fmt::Write; +use std::io::Write as _; + +use crate::term_detection; + +/// Utility for integrating with terminal emulators. +pub(crate) struct TerminalIntegration { + /// Info about the hosting terminal. + term: term_detection::TerminalInfo, +} + +#[allow(dead_code)] +impl TerminalIntegration { + /// Starts terminal integration, emitting the sequence that announces it, and returns the + /// utility the interactive loop reports events to. + /// + /// # Arguments + /// + /// * `term_info` - Information about the terminal capabilities. + pub fn init(term_info: term_detection::TerminalInfo) -> std::io::Result { + let integration = Self { term: term_info }; + Self::write(integration.initialize().as_ref())?; + Ok(integration) + } + + /// Returns a utility that integrates with nothing: it reports no capabilities, so every + /// event handler below does nothing and every sequence it composes is empty. This is what + /// a shell gets when integration is switched off, so it need not ask whether it has one. + pub fn disabled() -> Self { + Self { + term: term_detection::TerminalInfo::default(), + } + } + + // + // Event handlers: these are called at the points in the interactive loop that the + // terminal wants to know about; each does what the event calls for. A terminal that + // reports no support has nothing to say at any of them, so every handler is inert. + // + + /// Called after a command line has been read and before it runs. + /// + /// # Arguments + /// + /// * `command` - The command that is about to be executed. + pub fn on_pre_exec_command(&self, command: &str) -> std::io::Result<()> { + Self::write(self.pre_exec_command(command).as_ref()) + } + + /// Called after a command has run. + /// + /// # Arguments + /// + /// * `exit_code` - The exit code the command left behind. + pub fn on_post_exec_command(&self, exit_code: i32) -> std::io::Result<()> { + Self::write(self.post_exec_command(exit_code).as_ref()) + } + + /// Writes a sequence to standard output, flushing so the terminal sees it before whatever + /// the shell does next. Writing nothing still flushes; that costs nothing and keeps the + /// inert case from being a separate path. + fn write(seq: &str) -> std::io::Result<()> { + let mut stdout = std::io::stdout(); + stdout.write_all(seq.as_bytes())?; + stdout.flush() + } + + /// Returns the terminal escape sequence that should be emitted to initialize terminal + /// integration. + pub fn initialize(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;P;HasRichCommandDetection=True\x1b\\".into() + } else { + "".into() + } + } + + /// Returns a composed prompt bracketed with the sequences that mark where a prompt starts + /// and ends, and that report the working directory -- or the prompt as given, when there is + /// nothing to add. Returned rather than composed in place so the untouched prompt costs + /// nothing, not even the copy that concatenating three empty strings onto it would make. + /// + /// # Arguments + /// + /// * `prompt` - The prompt as composed by the shell. + /// * `working_dir` - The shell's current working directory. + pub fn decorate_prompt(&self, prompt: String, working_dir: &std::path::Path) -> String { + if !self.term.supports_osc_633 { + return prompt; + } + + [ + self.pre_prompt().as_ref(), + self.report_cwd(working_dir).as_ref(), + prompt.as_str(), + self.post_prompt().as_ref(), + ] + .concat() + } + + /// Returns the terminal escape sequence that should be emitted before the prompt. + pub fn pre_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;A\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence to report the current working directory. + pub fn report_cwd(&self, cwd: &std::path::Path) -> Cow<'_, str> { + if self.term.supports_osc_633 { + let escaped_cwd_str = osc_633_escape(cwd.to_string_lossy().as_ref()); + format!("\x1b]633;P;Cwd={escaped_cwd_str}\x1b\\").into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before executing a command, + /// but after the prompt and the user has finished entering input. + /// + /// # Arguments + /// + /// * `command` - The command that is about to be executed. + pub fn pre_exec_command(&self, command: &str) -> Cow<'_, str> { + if self.term.supports_osc_633 { + let mut escaped_command = osc_633_escape(command); + escaped_command.insert_str(0, "\x1b]633;E;"); + + if let Some(session_nonce) = &self.term.session_nonce { + escaped_command.push(';'); + escaped_command.push_str(session_nonce); + } + + escaped_command.push_str("\x1b\\\x1b]633;C\x1b\\"); + + escaped_command.into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after executing a command. + pub fn post_exec_command(&self, exit_code: i32) -> Cow<'_, str> { + if self.term.supports_osc_633 { + std::format!("\x1b]633;D;{exit_code}\x1b\\").into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the prompt. + pub fn post_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;B\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before the continuation prompt. + pub fn pre_input_line_continuation(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;F\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the input line + /// continuation. + pub fn post_input_line_continuation(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;G\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before the right-side prompt. + pub fn pre_right_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;H\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the right-side prompt. + pub fn post_right_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;I\x1b\\".into() + } else { + "".into() + } + } +} + +/// Escapes a string for safe inclusion in an OSC 633 escape sequence. +/// Reference: +fn osc_633_escape(command: &str) -> String { + let mut result = String::new(); + + for c in command.chars() { + match c { + // Escape ASCII control characters (< 0x1f, i.e., < 31) + '\x00'..='\x1e' => { + let _ = write!(result, r"\x{:02x}", c as u8); + } + // Escape backslash with an extra prefixed backslash + '\\' => result.push_str(r"\\"), + // Escape semicolon via \xNN syntax (like control chars) + ';' => result.push_str(r"\x3b"), + // Keep other characters as-is + _ => result.push(c), + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc_633_escape_basic() { + // Test simple alphanumeric string + assert_eq!(osc_633_escape("echo hello"), "echo hello"); + assert_eq!(osc_633_escape("ls -la"), "ls -la"); + } + + #[test] + fn osc_633_escape_semicolon() { + // Semicolons should be escaped + assert_eq!(osc_633_escape("cmd1; cmd2"), r"cmd1\x3b cmd2"); + assert_eq!(osc_633_escape(";"), r"\x3b"); + assert_eq!(osc_633_escape("a;b;c"), r"a\x3bb\x3bc"); + } + + #[test] + fn osc_633_escape_backslash() { + // Backslashes should be escaped + assert_eq!(osc_633_escape(r"echo \n"), r"echo \\n"); + assert_eq!(osc_633_escape(r"\"), r"\\"); + assert_eq!(osc_633_escape(r"C:\path\to\file"), r"C:\\path\\to\\file"); + } + + #[test] + fn osc_633_escape_control_chars() { + // ASCII control characters (0x00-0x1e, i.e., 0-30) should be escaped + assert_eq!(osc_633_escape("\x00"), r"\x00"); + assert_eq!(osc_633_escape("\x01"), r"\x01"); + assert_eq!(osc_633_escape("\t"), r"\x09"); // tab + assert_eq!(osc_633_escape("\n"), r"\x0a"); // newline + assert_eq!(osc_633_escape("\r"), r"\x0d"); // carriage return + assert_eq!(osc_633_escape("\x1e"), r"\x1e"); // last control char (30) + + // 0x1f (31) should NOT be escaped as a control char (not < 31) + assert_eq!(osc_633_escape("\x1f"), "\x1f"); + + // Space (0x20, 32) should NOT be escaped + assert_eq!(osc_633_escape(" "), " "); + } + + #[test] + fn osc_633_escape_mixed() { + // Test combinations of different escape scenarios + assert_eq!( + osc_633_escape("echo\nhello; world\\n"), + r"echo\x0ahello\x3b world\\n" + ); + + assert_eq!(osc_633_escape("cmd\t\t; \\path"), r"cmd\x09\x09\x3b \\path"); + + // Test with null bytes + assert_eq!(osc_633_escape("a\x00b\x01c"), r"a\x00b\x01c"); + + // Test all three special cases together + assert_eq!(osc_633_escape("\\;\n"), r"\\\x3b\x0a"); + } + + #[test] + fn osc_633_escape_empty() { + assert_eq!(osc_633_escape(""), ""); + } + + #[test] + fn osc_633_escape_unicode() { + // Unicode characters should pass through unchanged + assert_eq!(osc_633_escape("echo 你好"), "echo 你好"); + assert_eq!(osc_633_escape("café"), "café"); + assert_eq!(osc_633_escape("🦀"), "🦀"); + + // But should still escape special chars + assert_eq!(osc_633_escape("你好;世界"), r"你好\x3b世界"); + } +} diff --git a/userland/brush/source/brush-interactive/src/trace_categories.rs b/userland/brush/source/brush-interactive/src/trace_categories.rs new file mode 100644 index 0000000..b4fab32 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/trace_categories.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +pub(crate) const COMPLETION: &str = "completion"; diff --git a/userland/brush/source/brush-interactive/src/vt/completion_list.rs b/userland/brush/source/brush-interactive/src/vt/completion_list.rs new file mode 100644 index 0000000..e8e4ac9 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/completion_list.rs @@ -0,0 +1,211 @@ +//! Completing the word at the cursor, and listing the candidates. + +use std::io::Write; + +use super::line_editor::LineEditor; +use super::render::display_width; +use super::style::Style; +use crate::{ShellError, completion}; + +/// Completes the word at the cursor, as readline's `complete` does: a single +/// candidate replaces the word, and several are handled as `style` has them be. +/// +/// # Arguments +/// +/// * `shell` - The shell, which generates the candidates. +/// * `editor` - The line being edited. +/// * `out` - The terminal. +/// * `columns` - The terminal's width. +/// * `style` - How the backend draws. +pub(crate) async fn complete( + shell: &crate::ShellRef, + editor: &mut LineEditor<'_>, + out: &mut (impl Write + Send), + columns: usize, + style: Style, +) -> Result<(), ShellError> { + let completions = { + let mut shell = shell.lock().await; + completion::complete_async(&mut shell, editor.buffer.text(), editor.buffer.cursor()).await + }; + + let text = editor.buffer.text(); + let range = completions.insertion_index..completions.insertion_index + completions.delete_count; + let Some(word) = text.get(range.clone()) else { + return Ok(()); + }; + + let names = |candidates: &[String]| -> Vec { + candidates + .iter() + .map(|c| display_name(c, &completions.options)) + .collect() + }; + + match (completions.candidates.as_slice(), style) { + ([], _) => editor.bell(out)?, + ([candidate], _) => editor.buffer.replace(range, candidate), + // As readline does by default: extend the word as far as the candidates + // agree; if it can't, ring the bell, and list them on a second try. + (candidates, Style::Vt) => { + let common = common_prefix(candidates); + // Don't insert a prefix ending in a lone escape (e.g., `a\` from `a\ b` + // and `a\'b`), which would change how the rest of the line parses. + let dangling = (common.len() - common.trim_end_matches('\\').len()) % 2 == 1; + if common.len() > word.len() && !dangling { + editor.buffer.replace(range, &common); + } else if editor.completed_last { + editor.finish(out, columns)?; + out.write_all(format_in_columns(&names(candidates), columns).as_bytes())?; + editor.show_prompt_above(out)?; + } else { + editor.bell(out)?; + } + } + // List them straight away, one per line. + (candidates, Style::Basic) => { + editor.finish(out, columns)?; + for name in names(candidates) { + out.write_all(name.as_bytes())?; + out.write_all(b"\n")?; + } + editor.show_prompt_above(out)?; + } + } + + Ok(()) +} + +/// Returns the longest prefix that all of `candidates` share. +fn common_prefix(candidates: &[String]) -> String { + let Some((first, rest)) = candidates.split_first() else { + return String::new(); + }; + + let mut len = first.len(); + for candidate in rest { + len = first + .char_indices() + .zip(candidate.chars()) + .take_while(|((_, a), b)| a == b) + .last() + .map_or(0, |((i, a), _)| i + a.len_utf8()) + .min(len); + } + + first.get(..len).unwrap_or_default().to_owned() +} + +/// Returns how a completion candidate is shown in a list of candidates, as bash +/// shows it: file names unquoted and without their directory, and without the +/// space that would follow them. +fn display_name(candidate: &str, options: &brush_core::completion::ProcessingOptions) -> String { + let mut word = candidate; + // Drop the space completion puts after a word, but not an escaped space that + // ends the word itself (e.g., in `foo\ `, for a file named `foo `). + if let Some(trimmed) = word.strip_suffix(' ') { + let backslashes = trimmed.len() - trimmed.trim_end_matches('\\').len(); + if backslashes % 2 == 0 { + word = trimmed; + } + } + if !options.treat_as_filenames { + return word.to_owned(); + } + + // File names were quoted for the command line; list them as they're named. + let name = if options.no_autoquote_filenames { + word.to_owned() + } else { + brush_parser::unquote_str(word) + }; + let trimmed = brush_core::sys::fs::strip_path_separator_suffix(&name); + match brush_core::sys::fs::rfind_path_separator(trimmed) { + Some(index) => name.get(index + 1..).unwrap_or(&name).to_owned(), + None => name, + } +} + +/// Lays out `names` in columns, ordered down then across, as `ls` does. +fn format_in_columns(names: &[String], columns: usize) -> String { + let width = names.iter().map(|n| display_width(n)).max().unwrap_or(0) + 2; + let per_row = (columns / width).max(1); + let rows = names.len().div_ceil(per_row); + + let mut output = String::new(); + for row in 0..rows { + let mut line = String::new(); + for name in names.iter().skip(row).step_by(rows) { + line.push_str(name); + line.push_str(&" ".repeat(width - display_width(name))); + } + output.push_str(line.trim_end()); + output.push('\n'); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn finds_common_prefixes() { + assert_eq!(common_prefix(&strings(&["foo", "foobar", "fox"])), "fo"); + assert_eq!(common_prefix(&strings(&["été", "étage"])), "ét"); + assert_eq!(common_prefix(&strings(&["a", "b"])), ""); + assert_eq!(common_prefix(&strings(&["same", "same"])), "same"); + assert_eq!(common_prefix(&[]), ""); + } + + #[test] + fn lays_out_candidates_in_columns() { + let names = strings(&["a", "bb", "ccc", "d", "e"]); + assert_eq!(format_in_columns(&names, 12), "a d\nbb e\nccc\n"); + assert_eq!(format_in_columns(&names, 4), "a\nbb\nccc\nd\ne\n"); + } + + #[test] + fn lays_out_names_wider_than_the_terminal_one_per_row() { + let names = strings(&["abcdef", "ghijkl"]); + assert_eq!(format_in_columns(&names, 4), "abcdef\nghijkl\n"); + } + + #[test] + fn lays_out_wide_names_by_the_cells_they_take() { + let names = strings(&["日本", "ab", "c"]); + assert_eq!(format_in_columns(&names, 12), "日本 c\nab\n"); + } + + #[test] + fn shows_file_names_without_their_directories() { + let filenames = brush_core::completion::ProcessingOptions::default(); + assert_eq!(display_name("dir/sub/file ", &filenames), "file"); + assert_eq!(display_name("dir/sub/", &filenames), "sub/"); + assert_eq!(display_name("plain ", &filenames), "plain"); + assert_eq!(display_name("dossier/été ", &filenames), "été"); + assert_eq!(display_name("dir/my\\ file ", &filenames), "my file"); + assert_eq!(display_name("dir/my\\ sub/", &filenames), "my sub/"); + assert_eq!(display_name("'dir/my file' ", &filenames), "my file"); + assert_eq!(display_name("dir/foo\\ ", &filenames), "foo "); + assert_eq!(display_name("dir/foo\\ ", &filenames), "foo "); + assert_eq!(display_name("dir/foo\\\\ ", &filenames), "foo\\"); + + let unquoted = brush_core::completion::ProcessingOptions { + no_autoquote_filenames: true, + ..Default::default() + }; + assert_eq!(display_name("dir/it's ", &unquoted), "it's"); + + let words = brush_core::completion::ProcessingOptions { + treat_as_filenames: false, + ..Default::default() + }; + assert_eq!(display_name("a/b ", &words), "a/b"); + assert_eq!(display_name("a\\ b ", &words), "a\\ b"); + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/input_backend.rs b/userland/brush/source/brush-interactive/src/vt/input_backend.rs new file mode 100644 index 0000000..38fb9dd --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/input_backend.rs @@ -0,0 +1,276 @@ +use super::line_reader::LineReader; +use super::style::Style; +use super::terminal::{RawMode, StdTerminal, Terminal}; +use crate::{ + InputBackend, ShellError, + input_backend::{InteractivePrompt, ReadResult}, +}; + +/// An input backend with readline-style line editing, history, and completion. +/// +/// It talks to the terminal purely through bytes: it decodes keys from standard +/// input and draws on standard error, with VT100-style escape sequences or, in +/// the basic style ([`Self::with_basic_style`]), with backspaces alone. It needs +/// no terminal library, so it works wherever the terminal understands those +/// sequences, including hosts where the shell can't control the terminal through +/// the platform and instead supplies a [`Terminal`] of its own. +/// +/// Its default terminal, [`StdTerminal`], works only on Unix: the Windows console +/// sends keys as bytes only in a mode that it doesn't set, and WASI has no raw +/// mode. Elsewhere, reading a line from a terminal fails, unless the host +/// supplies a [`Terminal`] that can switch to raw mode. +/// +/// Key bindings start as readline's Emacs-mode defaults, and can be changed with +/// `bind`: keys and sequences of keys can be bound to readline functions (those +/// it performs), to shell commands (`bind -x`, with the line being edited +/// available in `READLINE_LINE` and `READLINE_POINT`), and to macros; see +/// [`Self::install_key_bindings`]. +pub struct VtInputBackend { + terminal: T, + reader: LineReader, +} + +impl Default for VtInputBackend { + fn default() -> Self { + Self::new(StdTerminal::default()) + } +} + +impl VtInputBackend { + /// Returns a new backend that edits on the given terminal. + /// + /// # Arguments + /// + /// * `terminal` - The terminal to edit on. + pub fn new(terminal: T) -> Self { + Self { + terminal, + reader: LineReader::default(), + } + .with_ui_options(&crate::UIOptions::default()) + } + + /// Applies the shared UI settings for syntax highlighting and color. + /// Highlighting is enabled unless either setting disables it. The basic + /// drawing style always uses plain text. + #[must_use] + pub const fn with_ui_options(mut self, options: &crate::UIOptions) -> Self { + self.reader.highlighting = !options.disable_highlighting && !options.disable_color; + self + } + + /// Has this backend draw and respond to Tab in a way that's simple for + /// programs that drive the shell (e.g., test harnesses) to follow: the line is + /// drawn with backspaces alone, so a line that wraps across rows isn't redrawn + /// correctly; ambiguous completions are listed straight away, one per line; + /// and the bell never rings. + #[must_use] + pub const fn with_basic_style(mut self) -> Self { + self.reader.style = Style::Basic; + self + } + + /// Makes this backend the keeper of `shell`'s key bindings, so that bindings + /// made with `bind` (e.g., in startup files) take effect while it reads input. + /// Call this before running any of the shell's startup files. + /// + /// # Arguments + /// + /// * `shell` - The shell whose key bindings this backend should keep. + pub fn install_key_bindings( + &self, + shell: &mut brush_core::Shell, + ) { + shell.set_key_bindings(Some(self.reader.bindings.clone())); + } +} + +impl InputBackend for VtInputBackend { + async fn read_line( + &mut self, + shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + self.reader.bound_line = None; + + // Without a terminal, there's nothing to edit on; just read lines. + if !self.terminal.is_terminal() { + return crate::MinimalInputBackend.read_line(shell, prompt).await; + } + + let mut terminal = RawMode::enable(&mut self.terminal)?; + let result = self + .reader + .read_program(shell, &prompt, &mut *terminal, &mut std::io::stderr()) + .await; + let restored = terminal.restore(); + + let result = result?; + restored?; + Ok(result) + } + + fn get_read_buffer(&self) -> Option<(String, usize)> { + self.reader.bound_line.clone() + } + + fn set_read_buffer(&mut self, buffer: String, cursor: usize) { + self.reader.next_line = Some((buffer, cursor)); + } +} + +#[cfg(test)] +mod tests { + use std::pin::pin; + use std::sync::{Arc, PoisonError}; + use std::task::{Context, Waker}; + + use brush_core::interfaces::{Key, KeyAction, KeyBindings as _, KeySequence, KeyStroke}; + use tokio::sync::Mutex; + + use super::*; + + async fn shell() -> crate::ShellRef { + let shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await + .unwrap(); + Arc::new(Mutex::new(shell)) + } + + /// A terminal that sends the given input, and logs how its mode is switched. + struct LoggingTerminal { + input: Vec, + log: Arc>>, + } + + impl Terminal for LoggingTerminal { + fn enable_raw_mode(&mut self) -> std::io::Result<()> { + self.log + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push("enable"); + Ok(()) + } + + fn disable_raw_mode(&mut self) -> std::io::Result<()> { + self.log + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push("disable"); + Ok(()) + } + + fn columns(&self) -> Option { + Some(80) + } + + fn is_terminal(&self) -> bool { + true + } + + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let count = self.input.len().min(buf.len()); + buf[..count].copy_from_slice(&self.input[..count]); + self.input.drain(..count); + Ok(count) + } + } + + #[tokio::test] + async fn restores_the_terminal_when_reading_is_cancelled() { + let shell = shell().await; + let log = Arc::default(); + let mut backend = VtInputBackend::new(LoggingTerminal { + input: b"\x01".to_vec(), + log: Arc::clone(&log), + }); + + // With the shell held, reading stops at Ctrl-A, which reads the history; + // then it's cancelled. + { + let _locked = shell.lock().await; + let read = pin!(backend.read_line(&shell, InteractivePrompt::default())); + let mut context = Context::from_waker(Waker::noop()); + assert!(read.poll(&mut context).is_pending()); + } + assert_eq!(*log.lock().unwrap(), ["enable", "disable"]); + + // The backend reads as usual afterwards. + backend.terminal.input = b"x\r".to_vec(); + let result = backend + .read_line(&shell, InteractivePrompt::default()) + .await + .unwrap(); + assert!(matches!(result, ReadResult::Input(text) if text == "x\n")); + assert_eq!( + *log.lock().unwrap(), + ["enable", "disable", "enable", "disable"] + ); + } + + #[test] + fn edits_the_terminal_by_default() { + let backend = VtInputBackend::default(); + assert_eq!(backend.reader.style, Style::Vt); + assert_eq!(backend.with_basic_style().reader.style, Style::Basic); + } + + #[test] + fn respects_highlighting_and_color_options() { + for disable_highlighting in [false, true] { + for disable_color in [false, true] { + let options = crate::UIOptions { + disable_color, + disable_highlighting, + ..Default::default() + }; + let backend = VtInputBackend::default().with_ui_options(&options); + assert_eq!( + backend.reader.highlighting, + !disable_highlighting && !disable_color + ); + } + } + } + + #[test] + fn hands_the_line_to_and_from_bound_commands() { + let mut backend = VtInputBackend::default(); + assert_eq!(backend.get_read_buffer(), None); + backend.reader.bound_line = Some(("ab".to_owned(), 1)); + assert_eq!(backend.get_read_buffer(), Some(("ab".to_owned(), 1))); + backend.set_read_buffer("cd".to_owned(), 2); + assert_eq!(backend.reader.next_line, Some(("cd".to_owned(), 2))); + } + + #[tokio::test] + async fn keeps_the_shell_key_bindings() { + let shell = shell().await; + let backend = VtInputBackend::default(); + backend.install_key_bindings(&mut *shell.lock().await); + + let ctrl_x = KeySequence::Strokes(vec![KeyStroke { + control: true, + ..Key::Character('x').into() + }]); + let bindings = shell.lock().await.key_bindings().cloned().unwrap(); + bindings + .lock() + .await + .bind(ctrl_x.clone(), KeyAction::ShellCommand("cmd".to_owned())) + .unwrap(); + assert_eq!( + backend + .reader + .bindings + .lock() + .await + .get_current() + .get(&ctrl_x), + Some(&KeyAction::ShellCommand("cmd".to_owned())) + ); + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/keymap.rs b/userland/brush/source/brush-interactive/src/vt/keymap.rs new file mode 100644 index 0000000..18e83b6 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/keymap.rs @@ -0,0 +1,605 @@ +//! Key bindings: readline's Emacs-mode defaults, and those made with `bind`. + +use std::collections::HashMap; + +use brush_core::interfaces::{self, InputFunction, Key, KeyAction, KeySequence, KeyStroke}; +use indexmap::IndexMap; + +use super::keys::{self, Keypress}; + +/// The key bindings of a [`super::VtInputBackend`]: keys (or sequences of keys) +/// bound to readline functions, to shell commands (`bind -x`), and to macros +/// (keys to process as if typed). They start as readline's Emacs-mode defaults, +/// which `bind` can change like any others. +pub(crate) struct VtKeyBindings { + /// What each sequence is bound to, in the order bound. Where more than one + /// matches the keys pressed (e.g., Tab, and Ctrl-I, which sends the same + /// byte), the latest wins. + targets: IndexMap, +} + +/// What a sequence is bound to, as `bind` has it. +enum Target { + Action(KeyAction), + Macro(KeySequence), +} + +/// What a sequence of keys does. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Binding { + /// A readline function to perform. + Function(InputFunction), + /// A shell command to run. + Command(String), + /// Keys to process as if typed, as the bytes the terminal would send. + Macro(Vec), +} + +/// What the keys pressed so far amount to. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Lookup { + /// What they're bound to, if anything. + pub bound: Option, + /// Whether a longer bound sequence starts with them. + pub prefix: bool, +} + +impl Default for VtKeyBindings { + fn default() -> Self { + let mut bindings = Self { + targets: IndexMap::new(), + }; + for (stroke, function) in default_bindings() { + bindings.set( + KeySequence::Strokes(vec![stroke]), + Target::Action(KeyAction::DoInputFunction(function)), + ); + } + bindings + } +} + +impl VtKeyBindings { + /// Looks up what `keys` are bound to. + pub fn lookup(&self, keys: &[Keypress]) -> Lookup { + let mut lookup = Lookup::default(); + for (seq, target) in &self.targets { + // Sequences of actions aren't supported. + if matches!(target, Target::Action(KeyAction::Sequence(_))) { + continue; + } + match compare(seq, keys) { + Comparison::Equal => lookup.bound = binding_of(target), + Comparison::Prefix => lookup.prefix = true, + Comparison::Different => {} + } + } + lookup + } + + /// Binds `seq` to `target`, replacing what it was bound to; it's then the + /// latest binding. + fn set(&mut self, seq: KeySequence, target: Target) { + self.remove(&seq); + self.targets.insert(seq, target); + } + + /// Removes the binding of `seq`, and of any other way of writing it (e.g., + /// `\C-m` for Enter); returns whether there was one. + fn remove(&mut self, seq: &KeySequence) -> bool { + let bytes = only_bytes(seq); + let count = self.targets.len(); + self.targets + .retain(|other, _| other != seq && (bytes.is_none() || only_bytes(other) != bytes)); + self.targets.len() != count + } +} + +fn binding_of(target: &Target) -> Option { + match target { + Target::Action(KeyAction::DoInputFunction(function)) => { + Some(Binding::Function(function.clone())) + } + Target::Action(KeyAction::ShellCommand(command)) => Some(Binding::Command(command.clone())), + Target::Action(KeyAction::Sequence(_)) => None, + Target::Macro(keys) => Some(Binding::Macro(sequence_bytes(keys))), + } +} + +/// Readline's Emacs-mode bindings, of the functions this backend performs. +fn default_bindings() -> Vec<(KeyStroke, InputFunction)> { + use InputFunction as F; + + let plain = KeyStroke::from; + let ctrl = |c| KeyStroke { + control: true, + ..Key::Character(c).into() + }; + let alt = |c| KeyStroke { + alt: true, + ..Key::Character(c).into() + }; + let ctrl_key = |key| KeyStroke { + control: true, + ..KeyStroke::from(key) + }; + let alt_key = |key| KeyStroke { + alt: true, + ..KeyStroke::from(key) + }; + + vec![ + (plain(Key::Enter), F::AcceptLine), + (ctrl('j'), F::AcceptLine), + (ctrl('a'), F::BeginningOfLine), + (plain(Key::Home), F::BeginningOfLine), + (ctrl('e'), F::EndOfLine), + (plain(Key::End), F::EndOfLine), + (ctrl('b'), F::BackwardChar), + (plain(Key::Left), F::BackwardChar), + (ctrl('f'), F::ForwardChar), + (plain(Key::Right), F::ForwardChar), + (alt('b'), F::BackwardWord), + (ctrl_key(Key::Left), F::BackwardWord), + (alt_key(Key::Left), F::BackwardWord), + (alt('f'), F::ForwardWord), + (ctrl_key(Key::Right), F::ForwardWord), + (alt_key(Key::Right), F::ForwardWord), + (plain(Key::Backspace), F::BackwardDeleteChar), + (ctrl('h'), F::BackwardDeleteChar), + (ctrl('d'), F::DeleteChar), + (plain(Key::Delete), F::DeleteChar), + (alt_key(Key::Backspace), F::BackwardKillWord), + ( + KeyStroke { + alt: true, + ..ctrl('h') + }, + F::BackwardKillWord, + ), + (alt('d'), F::KillWord), + (ctrl('k'), F::KillLine), + (ctrl('u'), F::UnixLineDiscard), + (ctrl('w'), F::UnixWordRubout), + (ctrl('y'), F::Yank), + (ctrl('t'), F::TransposeChars), + (ctrl('p'), F::PreviousHistory), + (plain(Key::Up), F::PreviousHistory), + (ctrl('n'), F::NextHistory), + (plain(Key::Down), F::NextHistory), + (alt('<'), F::BeginningOfHistory), + (alt('>'), F::EndOfHistory), + (ctrl('r'), F::ReverseSearchHistory), + (ctrl('l'), F::ClearScreen), + (plain(Key::Tab), F::Complete), + (ctrl('g'), F::Abort), + ] +} + +#[derive(Debug, PartialEq, Eq)] +enum Comparison { + Equal, + /// The keys are the start of the sequence. + Prefix, + Different, +} + +/// Compares a bound sequence with the keys pressed. +fn compare(seq: &KeySequence, keys: &[Keypress]) -> Comparison { + match seq { + // A sequence of keys matches key by key. + KeySequence::Strokes(strokes) => { + let matches = keys.len() <= strokes.len() + && keys + .iter() + .zip(strokes) + .all(|(key, stroke)| is_stroke(key, stroke)); + if !matches { + Comparison::Different + } else if keys.len() == strokes.len() { + Comparison::Equal + } else { + Comparison::Prefix + } + } + // `bind` records a sequence it can't interpret as keys as the bytes the + // terminal sends for it (not necessarily grouped by key), so match those as + // a whole. + KeySequence::Bytes(bytes) => { + let expected = bytes.concat(); + let typed: Vec = keys + .iter() + .flat_map(|key| key.bytes.iter().copied()) + .collect(); + if typed == expected { + Comparison::Equal + } else if !typed.is_empty() && expected.starts_with(&typed) { + Comparison::Prefix + } else { + Comparison::Different + } + } + } +} + +/// Whether `key` is `stroke`: the same key, or one the terminal sends the same +/// bytes for (e.g., Tab for Ctrl-I, or Backspace for Ctrl-?). +fn is_stroke(key: &Keypress, stroke: &KeyStroke) -> bool { + key.stroke.as_ref() == Some(stroke) + || (!key.bytes.is_empty() && key.bytes == keys::encode(stroke)) +} + +/// Returns the bytes `seq` matches, if there's only one way to send it: as bytes, +/// or keys that each send a single byte (e.g., Enter, as CR). A key that can be +/// sent several ways (e.g., Up, as `\e[A` or `\eOA`) has no such bytes. +fn only_bytes(seq: &KeySequence) -> Option> { + match seq { + KeySequence::Bytes(bytes) => Some(bytes.concat()), + KeySequence::Strokes(strokes) => strokes + .iter() + .map(|stroke| match keys::encode(stroke).as_slice() { + [byte] => Some(*byte), + _ => None, + }) + .collect(), + } +} + +/// Returns the bytes a terminal would send for the keys in `seq`. +fn sequence_bytes(seq: &KeySequence) -> Vec { + match seq { + KeySequence::Bytes(bytes) => bytes.concat(), + KeySequence::Strokes(strokes) => strokes.iter().flat_map(keys::encode).collect(), + } +} + +impl interfaces::KeyBindings for VtKeyBindings { + fn get_current(&self) -> HashMap { + self.targets + .iter() + .filter_map(|(seq, target)| match target { + Target::Action(action) => Some((seq.clone(), action.clone())), + Target::Macro(_) => None, + }) + .collect() + } + + fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction> { + match self.targets.get(&KeySequence::Bytes(vec![bytes.to_vec()])) { + Some(Target::Action(action)) => Some(action), + _ => None, + } + } + + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error> { + self.set(seq, Target::Action(action)); + Ok(()) + } + + fn try_unbind(&mut self, seq: KeySequence) -> bool { + self.remove(&seq) + } + + fn define_macro( + &mut self, + seq: KeySequence, + target: KeySequence, + ) -> Result<(), std::io::Error> { + self.set(seq, Target::Macro(target)); + Ok(()) + } + + fn get_macros(&self) -> HashMap { + self.targets + .iter() + .filter_map(|(seq, target)| match target { + Target::Macro(keys) => Some((seq.clone(), keys.clone())), + Target::Action(_) => None, + }) + .collect() + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in tests")] +mod tests { + use super::*; + use interfaces::KeyBindings as _; + + fn ctrl(c: char) -> KeyStroke { + KeyStroke { + control: true, + ..Key::Character(c).into() + } + } + + /// Returns the keys a terminal sends as `bytes`. + fn typed(bytes: &[u8]) -> Vec { + keys::decode_all(bytes) + .into_iter() + .map(|key| Keypress { + from_macro: false, + ..key + }) + .collect() + } + + fn command(command: &str) -> KeyAction { + KeyAction::ShellCommand(command.into()) + } + + fn bound_command(command: &str) -> Lookup { + Lookup { + bound: Some(Binding::Command(command.into())), + prefix: false, + } + } + + fn unbound() -> Lookup { + Lookup::default() + } + + #[test] + fn starts_with_readline_emacs_bindings() { + let bindings = VtKeyBindings::default(); + assert_eq!( + bindings.lookup(&typed(b"\x01")).bound, + Some(Binding::Function(InputFunction::BeginningOfLine)) + ); + assert_eq!( + bindings.lookup(&typed(b"\x1b[1;5D")).bound, + Some(Binding::Function(InputFunction::BackwardWord)) + ); + // `bind` sees them as it sees any other bindings. + assert_eq!( + bindings + .get_current() + .get(&KeySequence::Strokes(vec![ctrl('r')])), + Some(&KeyAction::DoInputFunction( + InputFunction::ReverseSearchHistory + )) + ); + // A plain character isn't bound; it inserts itself. + assert_eq!(bindings.lookup(&typed(b"a")), unbound()); + } + + #[test] + fn finds_bindings_of_keys_as_strokes_or_bytes() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + bindings.bind(KeySequence::Strokes(vec![ctrl('x')]), command("search"))?; + bindings.bind( + KeySequence::Bytes(vec![b"\x1b[A".to_vec()]), + KeyAction::DoInputFunction(InputFunction::AcceptLine), + )?; + + assert_eq!(bindings.lookup(&typed(b"\x18")), bound_command("search")); + assert_eq!( + bindings.lookup(&typed(b"\x1b[A")).bound, + Some(Binding::Function(InputFunction::AcceptLine)) + ); + // The same key, sent differently, is the default binding's. + assert_eq!( + bindings.lookup(&typed(b"\x1bOA")).bound, + Some(Binding::Function(InputFunction::PreviousHistory)) + ); + + Ok(()) + } + + #[test] + fn binds_keys_that_send_the_same_bytes_as_others() -> Result<(), std::io::Error> { + // `bind` records \C-i, \C-m, and \C-? as Ctrl strokes, but terminals send + // them as Tab, Enter, and Backspace; and \C-h is 0x08. + let mut bindings = VtKeyBindings::default(); + for (c, name) in [ + ('i', "tab"), + ('m', "enter"), + ('?', "rubout"), + ('h', "ctrl-h"), + ] { + bindings.bind(KeySequence::Strokes(vec![ctrl(c)]), command(name))?; + } + for (bytes, name) in [ + (&b"\t"[..], "tab"), + (b"\r", "enter"), + (b"\x7f", "rubout"), + (b"\x08", "ctrl-h"), + ] { + assert_eq!( + bindings.lookup(&typed(bytes)), + bound_command(name), + "{name}" + ); + } + Ok(()) + } + + #[test] + fn binds_keys_beyond_the_editing_keys() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + let keys = [ + (ctrl(']'), &b"\x1d"[..]), + (ctrl('_'), b"\x1f"), + (ctrl('@'), b"\x00"), + (Key::BackTab.into(), b"\x1b[Z"), + (Key::PageUp.into(), b"\x1b[5~"), + (Key::Insert.into(), b"\x1b[2~"), + (Key::F(5).into(), b"\x1b[15~"), + ]; + for (index, (stroke, _)) in keys.iter().enumerate() { + bindings.bind( + KeySequence::Strokes(vec![stroke.clone()]), + command(&index.to_string()), + )?; + } + for (index, (stroke, bytes)) in keys.iter().enumerate() { + assert_eq!( + bindings.lookup(&typed(bytes)), + bound_command(&index.to_string()), + "{stroke:?}" + ); + } + Ok(()) + } + + #[test] + fn uses_the_latest_of_a_binding_and_a_macro() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + let ctrl_o = KeySequence::Strokes(vec![ctrl('o')]); + + bindings.bind(ctrl_o.clone(), command("cmd"))?; + bindings.define_macro(ctrl_o.clone(), KeySequence::Bytes(vec![b"zz".to_vec()]))?; + assert_eq!( + bindings.lookup(&typed(b"\x0f")).bound, + Some(Binding::Macro(b"zz".to_vec())) + ); + assert!(!bindings.get_current().contains_key(&ctrl_o)); + + bindings.bind(ctrl_o, command("again"))?; + assert_eq!(bindings.lookup(&typed(b"\x0f")), bound_command("again")); + assert!(bindings.get_macros().is_empty()); + + // A later binding of a key that sends the same bytes wins too. + bindings.bind(KeySequence::Strokes(vec![ctrl('i')]), command("tab"))?; + assert_eq!(bindings.lookup(&typed(b"\t")), bound_command("tab")); + bindings.bind( + KeySequence::Strokes(vec![Key::Tab.into()]), + KeyAction::DoInputFunction(InputFunction::Complete), + )?; + assert_eq!( + bindings.lookup(&typed(b"\t")).bound, + Some(Binding::Function(InputFunction::Complete)) + ); + + Ok(()) + } + + #[test] + fn unbinds_defaults_too() { + let mut bindings = VtKeyBindings::default(); + assert!(bindings.try_unbind(KeySequence::Strokes(vec![ctrl('a')]))); + assert!(!bindings.try_unbind(KeySequence::Strokes(vec![ctrl('a')]))); + assert_eq!(bindings.lookup(&typed(b"\x01")), unbound()); + } + + #[test] + fn rebinds_and_unbinds_a_key_however_its_written() -> Result<(), std::io::Error> { + // Enter is bound by default; \C-m is another way to write it. + let mut bindings = VtKeyBindings::default(); + let ctrl_m = KeySequence::Strokes(vec![ctrl('m')]); + bindings.bind(ctrl_m.clone(), command("enter"))?; + assert!( + !bindings + .get_current() + .contains_key(&KeySequence::Strokes(vec![Key::Enter.into()])) + ); + + assert!(bindings.try_unbind(ctrl_m)); + assert_eq!(bindings.lookup(&typed(b"\r")), unbound()); + + // Up can be sent other ways too, so binding one of them leaves the rest. + bindings.bind(KeySequence::Bytes(vec![b"\x1b[A".to_vec()]), command("up"))?; + assert_eq!( + bindings.lookup(&typed(b"\x1bOA")).bound, + Some(Binding::Function(InputFunction::PreviousHistory)) + ); + Ok(()) + } + + #[test] + fn keeps_bindings_and_macros_for_bind() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + let ctrl_x = KeySequence::Strokes(vec![ctrl('x')]); + let up = KeySequence::Bytes(vec![b"\x1b[A".to_vec()]); + + bindings.bind(ctrl_x.clone(), command("cmd"))?; + bindings.bind(up, command("cmd"))?; + bindings.define_macro(KeySequence::Strokes(vec![ctrl('o')]), ctrl_x.clone())?; + + assert_eq!(bindings.get_current().get(&ctrl_x), Some(&command("cmd"))); + assert_eq!(bindings.get_untranslated(b"\x1b[A"), Some(&command("cmd"))); + assert_eq!(bindings.get_untranslated(b"\x1b[B"), None); + assert_eq!( + bindings.get_macros(), + HashMap::from([(KeySequence::Strokes(vec![ctrl('o')]), ctrl_x)]) + ); + Ok(()) + } + + #[test] + fn finds_sequences_and_macros() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + // As atuin's bash integration binds them. + let chained = KeySequence::Bytes(vec![ + vec![0x18], + vec![0x1f], + b"A".to_vec(), + b"1".to_vec(), + vec![0x07], + ]); + bindings.bind(chained.clone(), command("widget"))?; + bindings.define_macro(KeySequence::Strokes(vec![ctrl('r')]), chained)?; + + assert_eq!( + bindings.lookup(&typed(b"\x12")).bound, + Some(Binding::Macro(b"\x18\x1fA1\x07".to_vec())) + ); + assert_eq!( + bindings.lookup(&typed(b"\x18\x1f")), + Lookup { + bound: None, + prefix: true + } + ); + assert_eq!( + bindings.lookup(&typed(b"\x18\x1fA1\x07")), + bound_command("widget") + ); + assert_eq!(bindings.lookup(&typed(b"\x18e")), unbound()); + Ok(()) + } + + #[test] + fn reports_keys_both_bound_and_starting_a_longer_sequence() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + bindings.bind(KeySequence::Strokes(vec![ctrl('x')]), command("short"))?; + bindings.bind( + KeySequence::Strokes(vec![ctrl('x'), ctrl('e')]), + command("long"), + )?; + assert_eq!( + bindings.lookup(&typed(b"\x18")), + Lookup { + bound: Some(Binding::Command("short".into())), + prefix: true + } + ); + assert_eq!(bindings.lookup(&typed(b"\x18\x05")), bound_command("long")); + // More keys than a sequence has don't match it. + assert_eq!(bindings.lookup(&typed(b"\x18\x05a")), unbound()); + Ok(()) + } + + #[test] + fn ignores_sequences_of_actions() -> Result<(), std::io::Error> { + let mut bindings = VtKeyBindings::default(); + bindings.bind( + KeySequence::Strokes(vec![ctrl('x')]), + KeyAction::Sequence(vec![command("cmd")]), + )?; + assert_eq!(bindings.lookup(&typed(b"\x18")), unbound()); + Ok(()) + } + + #[test] + fn expands_macros_to_the_bytes_a_terminal_sends() { + assert_eq!( + sequence_bytes(&KeySequence::Strokes(vec![ctrl('a'), Key::Home.into()])), + b"\x01\x1b[H" + ); + assert_eq!( + sequence_bytes(&KeySequence::Bytes(vec![b"\x18".to_vec(), b"e".to_vec()])), + b"\x18e" + ); + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/keys.rs b/userland/brush/source/brush-interactive/src/vt/keys.rs new file mode 100644 index 0000000..8ced6df --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/keys.rs @@ -0,0 +1,708 @@ +//! Keys: decoding them from the bytes a terminal sends, and encoding them back. + +use std::collections::VecDeque; +use std::time::Duration; + +use brush_core::interfaces::{Key, KeyStroke}; + +use super::terminal::Terminal; + +/// How long to wait for the rest of a key sequence: readline's default +/// `keyseq-timeout`. +pub(crate) const KEYSEQ_TIMEOUT: Duration = Duration::from_millis(500); + +const ESC: u8 = 0x1b; + +/// A key pressed at the terminal (or typed by a macro). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Keypress { + /// The key, or `None` for a sequence this backend doesn't recognize. + pub stroke: Option, + /// The bytes it arrived as. + pub bytes: Vec, + /// Whether a macro typed it. + pub from_macro: bool, +} + +impl Keypress { + /// Returns the char this key types, if it's a plain character key. + pub const fn typed_char(&self) -> Option { + match &self.stroke { + Some(KeyStroke { + alt: false, + control: false, + key: Key::Character(c), + .. + }) => Some(*c), + _ => None, + } + } + + /// Whether this is the Escape key on its own. + pub fn is_escape(&self) -> bool { + self.stroke.as_ref() == Some(&KeyStroke::from(Key::Escape)) + } +} + +/// Reads keys from a terminal. Bytes arrive in arbitrary chunks, so an escape +/// sequence or UTF-8 character may be split between them; the reader holds on to +/// an incomplete one until the rest arrives, waiting only briefly for the rest of +/// an escape sequence before taking what it has as keys of their own. +#[derive(Default)] +pub(crate) struct KeyReader { + /// Bytes read but not yet decoded. + pending: Vec, + /// Keys to return before decoding any more (e.g., those a macro types). + queued: VecDeque, +} + +impl KeyReader { + /// Returns the next key, waiting for one if need be; `None` at the end of + /// input. + pub fn next(&mut self, terminal: &mut impl Terminal) -> std::io::Result> { + loop { + if let Some(key) = self.queued.pop_front() { + return Ok(Some(key)); + } + if let Some(key) = self.decode(true) { + return Ok(Some(key)); + } + + // Part of a key sequence may have arrived without the rest. Wait a little + // for it, as readline does, before taking what's there on its own (e.g., + // a lone Escape as the Escape key). + if !self.pending.is_empty() + && !terminal.wait_for_input(KEYSEQ_TIMEOUT)? + && let Some(key) = self.decode(false) + { + return Ok(Some(key)); + } + + let mut bytes = [0; 256]; + let count = loop { + match terminal.read(&mut bytes) { + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + result => break result?, + } + }; + if count == 0 { + return Ok(self.decode(false)); + } + self.pending + .extend_from_slice(bytes.get(..count).unwrap_or_default()); + } + } + + /// Returns the next key if it arrives within `timeout`. + pub fn next_within( + &mut self, + terminal: &mut impl Terminal, + timeout: Duration, + ) -> std::io::Result> { + if self.queued.is_empty() && self.pending.is_empty() && !terminal.wait_for_input(timeout)? { + return Ok(None); + } + self.next(terminal) + } + + /// Puts keys back, to be returned, in order, before any others. + pub fn push_front(&mut self, keys: Vec) { + for key in keys.into_iter().rev() { + self.queued.push_front(key); + } + } + + /// Decodes the next key from the bytes read, if they hold a whole one. + /// `more_coming` is as for [`decode`]. + fn decode(&mut self, more_coming: bool) -> Option { + let (stroke, len) = decode(&self.pending, more_coming)?; + Some(Keypress { + stroke, + bytes: self.pending.drain(..len).collect(), + from_macro: false, + }) + } +} + +/// Decodes all the keys in `bytes` (e.g., a macro's), as if typed. +pub(crate) fn decode_all(mut bytes: &[u8]) -> Vec { + let mut keys = vec![]; + while let Some((stroke, len)) = decode(bytes, false) { + let (key, rest) = bytes.split_at(len); + keys.push(Keypress { + stroke, + bytes: key.to_vec(), + from_macro: true, + }); + bytes = rest; + } + keys +} + +/// Decodes the key at the start of `bytes`, returning it (or `None` if it isn't +/// one this backend recognizes) and how many bytes it took. Returns `None` if +/// `bytes` is empty or holds only the start of a key. +/// +/// # Arguments +/// +/// * `bytes` - The bytes to decode. +/// * `more_coming` - Whether more bytes may be about to arrive. When `false`, the +/// start of an escape sequence is taken to be keys of its own (e.g., a lone +/// Escape is the Escape key). +fn decode(bytes: &[u8], more_coming: bool) -> Option<(Option, usize)> { + let &first = bytes.first()?; + let stroke = match first { + ESC => return decode_escape(bytes, more_coming), + b'\r' => Key::Enter.into(), + b'\t' => Key::Tab.into(), + 0x7f => Key::Backspace.into(), + // Ctrl-A through Ctrl-Z (including Ctrl-H, Ctrl-J, and so on, which readline + // binds as such). + 0x01..=0x1a => ctrl(char::from(first - 1 + b'a')), + // Ctrl-@, Ctrl-\, Ctrl-], Ctrl-^, and Ctrl-_. + 0x00..=0x1f => ctrl(char::from(first + 0x40)), + _ => { + return decode_char(bytes).map(|(c, len)| (Some(Key::Character(c).into()), len)); + } + }; + Some((Some(stroke), 1)) +} + +/// Decodes a UTF-8 character at the start of `bytes`. +fn decode_char(bytes: &[u8]) -> Option<(char, usize)> { + let len = match bytes.first()? { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + // Not the start of a character; skip it. + _ => return Some((char::REPLACEMENT_CHARACTER, 1)), + }; + // Give up on a character as soon as a byte arrives that can't continue it, + // rather than holding that byte back until the rest of the character would + // have arrived. + if bytes.iter().take(len).skip(1).any(|&b| b & 0xc0 != 0x80) { + return Some((char::REPLACEMENT_CHARACTER, 1)); + } + let encoded = bytes.get(..len)?; + match std::str::from_utf8(encoded) { + Ok(s) => s.chars().next().map(|c| (c, len)), + Err(_) => Some((char::REPLACEMENT_CHARACTER, 1)), + } +} + +/// Decodes a key that starts with Escape. +fn decode_escape(bytes: &[u8], more_coming: bool) -> Option<(Option, usize)> { + let Some(&second) = bytes.get(1) else { + return (!more_coming).then(|| (Some(Key::Escape.into()), 1)); + }; + + match second { + b'[' => decode_csi(bytes, more_coming), + b'O' => decode_ss3(bytes, more_coming), + ESC => Some((Some(Key::Escape.into()), 1)), + // Escape and then a key is that key with Alt (or Meta) held. + _ => { + let (stroke, len) = decode(bytes.get(1..)?, more_coming)?; + Some(( + stroke.map(|stroke| KeyStroke { + alt: true, + ..stroke + }), + len + 1, + )) + } + } +} + +/// Decodes a control sequence: `ESC [`, parameter bytes (0x30–0x3f), +/// intermediate bytes (0x20–0x2f), and a final byte (0x40–0x7e). If what follows +/// `ESC [` can't be one, it's Alt-[. +fn decode_csi(bytes: &[u8], more_coming: bool) -> Option<(Option, usize)> { + let alt_bracket = || Some((Some(alt('[')), 2)); + let body = bytes.get(2..)?; + let Some(final_index) = body.iter().position(|b| !(0x20..=0x3f).contains(b)) else { + return if more_coming { None } else { alt_bracket() }; + }; + let final_byte = body.get(final_index).copied()?; + if !(0x40..=0x7e).contains(&final_byte) { + return alt_bracket(); + } + + let params = std::str::from_utf8(body.get(..final_index)?).unwrap_or_default(); + let mut params = params.split(';').map(|p| p.parse::().ok()); + let number = params.next().flatten(); + // A modifier, if present, is the second parameter (e.g., `ESC [ 1 ; 5 C`): 1 + // plus 2 for Alt and 4 for Ctrl (and 1 for Shift, which `bind` can't express, + // so it's left out). + let modifier = params.next().flatten().unwrap_or(1).saturating_sub(1); + + let key = match (final_byte, number) { + (b'A', _) => Some(Key::Up), + (b'B', _) => Some(Key::Down), + (b'C', _) => Some(Key::Right), + (b'D', _) => Some(Key::Left), + (b'H', _) => Some(Key::Home), + (b'F', _) => Some(Key::End), + (b'Z', _) => Some(Key::BackTab), + (b'P', _) => Some(Key::F(1)), + (b'Q', _) => Some(Key::F(2)), + (b'R', _) => Some(Key::F(3)), + (b'S', _) => Some(Key::F(4)), + (b'~', Some(number)) => tilde_key(number), + _ => None, + }; + let stroke = key.map(|key| KeyStroke { + alt: modifier & 2 != 0, + control: modifier & 4 != 0, + shift: false, + key, + }); + Some((stroke, 2 + final_index + 1)) +} + +/// Decodes a single-shift sequence: `ESC O` and a final byte, as cursor and +/// function keys send in application mode. If what follows `ESC O` can't be one, +/// it's Alt-O. +fn decode_ss3(bytes: &[u8], more_coming: bool) -> Option<(Option, usize)> { + let Some(&third) = bytes.get(2) else { + return (!more_coming).then(|| (Some(alt('O')), 2)); + }; + if !(0x40..=0x7e).contains(&third) { + return Some((Some(alt('O')), 2)); + } + + let key = match third { + b'A' => Some(Key::Up), + b'B' => Some(Key::Down), + b'C' => Some(Key::Right), + b'D' => Some(Key::Left), + b'H' => Some(Key::Home), + b'F' => Some(Key::End), + b'P' => Some(Key::F(1)), + b'Q' => Some(Key::F(2)), + b'R' => Some(Key::F(3)), + b'S' => Some(Key::F(4)), + _ => None, + }; + Some((key.map(KeyStroke::from), 3)) +} + +/// The keys that send `ESC [ ~`, by number, as xterm sends them. +const TILDE_KEYS: [(u32, Key); 16] = [ + (1, Key::Home), + (2, Key::Insert), + (3, Key::Delete), + (4, Key::End), + (5, Key::PageUp), + (6, Key::PageDown), + (15, Key::F(5)), + (17, Key::F(6)), + (18, Key::F(7)), + (19, Key::F(8)), + (20, Key::F(9)), + (21, Key::F(10)), + (23, Key::F(11)), + (24, Key::F(12)), + // Other terminals' Home and End. + (7, Key::Home), + (8, Key::End), +]; + +fn tilde_key(number: u32) -> Option { + TILDE_KEYS + .iter() + .find(|(n, _)| *n == number) + .map(|(_, key)| key.clone()) +} + +fn tilde_number(key: &Key) -> Option { + TILDE_KEYS.iter().find(|(_, k)| k == key).map(|(n, _)| *n) +} + +/// Returns the bytes a terminal (xterm, or one like it) sends for `stroke`, or +/// nothing if it doesn't send any this backend knows. +pub(crate) fn encode(stroke: &KeyStroke) -> Vec { + let alt_prefix = if stroke.alt { &[ESC][..] } else { &[] }; + let modifier = 1 + 2 * u32::from(stroke.alt) + 4 * u32::from(stroke.control); + // Cursor and function keys carry their modifiers as a parameter. + let csi = |final_byte: char| { + if modifier == 1 { + format!("\x1b[{final_byte}").into_bytes() + } else { + format!("\x1b[1;{modifier}{final_byte}").into_bytes() + } + }; + + match &stroke.key { + Key::Character(c) if stroke.control && c.is_ascii() => { + // Ctrl-? is Delete; other Ctrl characters keep the character's low bits. + let byte = if *c == '?' { 0x7f } else { *c as u8 & 0x1f }; + [alt_prefix, &[byte]].concat() + } + Key::Character(c) => [alt_prefix, c.encode_utf8(&mut [0; 4]).as_bytes()].concat(), + Key::Enter => [alt_prefix, b"\r"].concat(), + Key::Tab => [alt_prefix, b"\t"].concat(), + Key::Backspace => [alt_prefix, b"\x7f"].concat(), + Key::Escape => [alt_prefix, &[ESC]].concat(), + Key::BackTab => b"\x1b[Z".to_vec(), + Key::Up => csi('A'), + Key::Down => csi('B'), + Key::Right => csi('C'), + Key::Left => csi('D'), + Key::Home => csi('H'), + Key::End => csi('F'), + Key::F(n @ 1..=4) => { + let final_byte = char::from(b'P' + n - 1); + if modifier == 1 { + format!("\x1bO{final_byte}").into_bytes() + } else { + csi(final_byte) + } + } + key => match tilde_number(key) { + Some(number) if modifier == 1 => format!("\x1b[{number}~").into_bytes(), + Some(number) => format!("\x1b[{number};{modifier}~").into_bytes(), + None => vec![], + }, + } +} + +const fn ctrl(c: char) -> KeyStroke { + KeyStroke { + alt: false, + control: true, + shift: false, + key: Key::Character(c), + } +} + +const fn alt(c: char) -> KeyStroke { + KeyStroke { + alt: true, + control: false, + shift: false, + key: Key::Character(c), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[allow( + clippy::unnecessary_wraps, + reason = "what's decoded is compared as an Option" + )] + fn stroke(alt: bool, control: bool, key: Key) -> Option { + Some(KeyStroke { + alt, + control, + shift: false, + key, + }) + } + + fn plain(key: Key) -> Option { + stroke(false, false, key) + } + + fn ch(c: char) -> Option { + plain(Key::Character(c)) + } + + fn keys(bytes: &[u8]) -> Vec> { + decode_all(bytes) + .into_iter() + .map(|key| key.stroke) + .collect() + } + + #[test] + fn decodes_characters_and_controls() { + assert_eq!( + keys("aé界\r\t\x7f\x01\x12".as_bytes()), + [ + ch('a'), + ch('é'), + ch('界'), + plain(Key::Enter), + plain(Key::Tab), + plain(Key::Backspace), + Some(ctrl('a')), + Some(ctrl('r')), + ] + ); + } + + #[test] + fn decodes_every_control_character_as_a_ctrl_key() { + // Bytes that readline treats as Ctrl keys, rather than as other keys: \C-h + // (0x08), \C-j (0x0a), and the controls past \C-z. + assert_eq!( + keys(b"\x08\n\x00\x1c\x1d\x1e\x1f"), + [ + Some(ctrl('h')), + Some(ctrl('j')), + Some(ctrl('@')), + Some(ctrl('\\')), + Some(ctrl(']')), + Some(ctrl('^')), + Some(ctrl('_')), + ] + ); + } + + #[test] + fn decodes_escape_sequences() { + assert_eq!( + keys(b"\x1b[A\x1b[B\x1bOC\x1b[D\x1b[H\x1b[4~\x1b[3~\x1bb\x1b\x7f\x1b\x08"), + [ + plain(Key::Up), + plain(Key::Down), + plain(Key::Right), + plain(Key::Left), + plain(Key::Home), + plain(Key::End), + plain(Key::Delete), + Some(alt('b')), + stroke(true, false, Key::Backspace), + stroke(true, true, Key::Character('h')), + ] + ); + } + + #[test] + fn decodes_modified_cursor_and_function_keys() { + assert_eq!( + keys(b"\x1b[1;5C\x1b[1;3D\x1b[1;7A\x1b[1;2B\x1b[5;5~\x1b[Z\x1b[2~\x1b[6~"), + [ + stroke(false, true, Key::Right), + stroke(true, false, Key::Left), + stroke(true, true, Key::Up), + // Shift isn't something `bind` can express, so it's left out. + plain(Key::Down), + stroke(false, true, Key::PageUp), + plain(Key::BackTab), + plain(Key::Insert), + plain(Key::PageDown), + ] + ); + assert_eq!( + keys(b"\x1bOP\x1b[1;5Q\x1b[15~\x1b[24~\x1b[1~\x1b[7~\x1b[8~"), + [ + plain(Key::F(1)), + stroke(false, true, Key::F(2)), + plain(Key::F(5)), + plain(Key::F(12)), + plain(Key::Home), + plain(Key::Home), + plain(Key::End), + ] + ); + } + + #[test] + fn decodes_every_application_mode_key() { + assert_eq!( + keys(b"\x1bOA\x1bOB\x1bOC\x1bOD\x1bOH\x1bOF\x1bOP\x1bOQ\x1bOR\x1bOS"), + [ + plain(Key::Up), + plain(Key::Down), + plain(Key::Right), + plain(Key::Left), + plain(Key::Home), + plain(Key::End), + plain(Key::F(1)), + plain(Key::F(2)), + plain(Key::F(3)), + plain(Key::F(4)), + ] + ); + assert_eq!( + keys(b"\x1b[1;3R\x1b[1;5S\x1b[1X\x1b[99~"), + [ + stroke(true, false, Key::F(3)), + stroke(false, true, Key::F(4)), + None, + None + ] + ); + } + + #[test] + fn decodes_escape_then_any_key_as_that_key_with_alt() { + assert_eq!(keys(b"\x1b\x1b"), [plain(Key::Escape), plain(Key::Escape)]); + assert_eq!(keys(b"\x1b\x01"), [stroke(true, true, Key::Character('a'))]); + assert_eq!( + keys("\x1bé".as_bytes()), + [stroke(true, false, Key::Character('é'))] + ); + assert_eq!(keys(b"\x1b\r"), [stroke(true, false, Key::Enter)]); + } + + #[test] + fn consumes_unknown_sequences_whole() { + assert_eq!(keys(b"\x1b[200~x\x1bOZy"), [None, ch('x'), None, ch('y')]); + } + + #[test] + fn ends_a_control_sequence_at_a_byte_that_cant_be_in_one() { + // `ESC [` then Enter is Alt-[ then Enter, not a sequence that swallows Enter + // and whatever follows. + assert_eq!( + keys(b"\x1b[\rls"), + [Some(alt('[')), plain(Key::Enter), ch('l'), ch('s')] + ); + assert_eq!(keys(b"\x1bO\x01"), [Some(alt('O')), Some(ctrl('a'))]); + } + + #[test] + fn waits_for_the_rest_of_an_escape_sequence_only_while_more_may_come() { + for (bytes, alone) in [ + (&b"\x1b"[..], plain(Key::Escape)), + (b"\x1b[", Some(alt('['))), + (b"\x1b[1;", Some(alt('['))), + (b"\x1bO", Some(alt('O'))), + ] { + assert_eq!(decode(bytes, true), None, "{bytes:?}"); + assert_eq!( + decode(bytes, false).map(|(key, _)| key), + Some(alone), + "{bytes:?}" + ); + } + } + + #[test] + fn replaces_bytes_that_arent_utf8() { + // A stray continuation byte, a lead byte followed by something else, and a + // byte that never starts a character. + let replacement = ch(char::REPLACEMENT_CHARACTER); + assert_eq!( + keys(b"\x80a\xe7bc\xff"), + [ + replacement.clone(), + ch('a'), + replacement.clone(), + ch('b'), + ch('c'), + replacement + ] + ); + assert_eq!(keys("🦀".as_bytes()), [ch('🦀')]); + } + + #[test] + fn replaces_overlong_and_surrogate_encodings() { + // An overlong `/`, and an encoded surrogate: neither is UTF-8, so each byte + // is replaced. + let replaced = keys(b"\xc0\xaf\xed\xa0\x80x"); + assert_eq!(replaced.len(), 6); + assert!( + replaced + .iter() + .take(5) + .all(|key| *key == ch(char::REPLACEMENT_CHARACTER)) + ); + assert_eq!(replaced.last(), Some(&ch('x'))); + } + + #[test] + fn doesnt_hold_back_a_key_after_a_stray_lead_byte() { + // A lead byte of a 3-byte character, then a plain `a`: the `a` can't + // continue the character, so it's decoded straight away. + assert_eq!( + decode(b"\xe7a", true), + Some((ch(char::REPLACEMENT_CHARACTER), 1)) + ); + // The start of a character that may yet be completed is held. + assert_eq!(decode("界".as_bytes().get(..2).unwrap(), true), None); + } + + #[test] + fn encodes_keys_as_the_terminal_sends_them() { + for (stroke, bytes) in [ + (ch('é'), "é".as_bytes()), + (Some(ctrl('a')), b"\x01"), + (Some(ctrl('?')), b"\x7f"), + (Some(alt('b')), b"\x1bb"), + (stroke(true, true, Key::Character('a')), b"\x1b\x01"), + (plain(Key::Enter), b"\r"), + (plain(Key::Tab), b"\t"), + (plain(Key::Backspace), b"\x7f"), + (plain(Key::Escape), b"\x1b"), + (plain(Key::Up), b"\x1b[A"), + (stroke(false, true, Key::Left), b"\x1b[1;5D"), + (stroke(true, false, Key::Right), b"\x1b[1;3C"), + (plain(Key::Home), b"\x1b[H"), + (plain(Key::End), b"\x1b[F"), + (plain(Key::Delete), b"\x1b[3~"), + (stroke(false, true, Key::Delete), b"\x1b[3;5~"), + (plain(Key::Insert), b"\x1b[2~"), + (plain(Key::PageUp), b"\x1b[5~"), + (plain(Key::PageDown), b"\x1b[6~"), + (plain(Key::BackTab), b"\x1b[Z"), + (plain(Key::F(1)), b"\x1bOP"), + (stroke(false, true, Key::F(4)), b"\x1b[1;5S"), + (plain(Key::F(5)), b"\x1b[15~"), + (plain(Key::F(12)), b"\x1b[24~"), + (plain(Key::F(13)), b""), + ] { + let stroke = stroke.unwrap(); + assert_eq!(encode(&stroke), bytes, "{stroke:?}"); + } + } + + #[test] + fn decodes_what_it_encodes() { + let named = [ + Key::Up, + Key::Down, + Key::Left, + Key::Right, + Key::Home, + Key::End, + Key::Delete, + Key::Insert, + Key::PageUp, + Key::PageDown, + Key::F(1), + Key::F(4), + Key::F(5), + Key::F(10), + Key::F(12), + ]; + let mut strokes = vec![]; + for key in named { + for (alt, control) in [(false, false), (true, false), (false, true), (true, true)] { + strokes.push(KeyStroke { + alt, + control, + shift: false, + key: key.clone(), + }); + } + } + strokes.extend([ + ctrl('x'), + alt('x'), + KeyStroke { + alt: true, + ..ctrl('x') + }, + KeyStroke { + alt: true, + ..Key::Enter.into() + }, + KeyStroke { + alt: true, + ..Key::Backspace.into() + }, + ]); + + for stroke in strokes { + assert_eq!(keys(&encode(&stroke)), [Some(stroke.clone())], "{stroke:?}"); + } + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/line_buffer.rs b/userland/brush/source/brush-interactive/src/vt/line_buffer.rs new file mode 100644 index 0000000..9bd63db --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/line_buffer.rs @@ -0,0 +1,356 @@ +//! The line being edited, and the edits that can be made to it. + +#![allow( + clippy::string_slice, + reason = "the cursor and the offsets derived from it are kept at char boundaries" +)] + +use std::ops::Range; + +/// A line of text being edited, with a cursor. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineBuffer { + text: String, + /// The cursor's byte offset in `text`; always at a character boundary. + cursor: usize, +} + +impl LineBuffer { + /// Returns a buffer holding `text`, with the cursor at its end. + pub fn new(text: impl Into) -> Self { + let text = text.into(); + let cursor = text.len(); + Self { text, cursor } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub const fn cursor(&self) -> usize { + self.cursor + } + + /// Replaces the buffer's text, putting the cursor at `cursor` (clamped to a + /// character boundary within the text). + pub fn set(&mut self, text: String, cursor: usize) { + self.text = text; + self.move_to(cursor); + } + + /// Moves the cursor to byte offset `cursor` (clamped to a character boundary + /// within the text). + pub fn move_to(&mut self, cursor: usize) { + let mut cursor = cursor.min(self.text.len()); + while !self.text.is_char_boundary(cursor) { + cursor -= 1; + } + self.cursor = cursor; + } + + pub fn insert(&mut self, s: &str) { + self.text.insert_str(self.cursor, s); + self.cursor += s.len(); + } + + /// Replaces the text in `range` with `replacement`, leaving the cursor just after + /// it. + pub fn replace(&mut self, range: Range, replacement: &str) { + self.text.replace_range(range.clone(), replacement); + self.cursor = range.start + replacement.len(); + } + + pub fn backspace(&mut self) { + let start = self.prev_char_boundary(self.cursor); + self.text.replace_range(start..self.cursor, ""); + self.cursor = start; + } + + pub fn delete(&mut self) { + let end = self.next_char_boundary(self.cursor); + self.text.replace_range(self.cursor..end, ""); + } + + pub fn left(&mut self) { + self.cursor = self.prev_char_boundary(self.cursor); + } + + pub fn right(&mut self) { + self.cursor = self.next_char_boundary(self.cursor); + } + + pub const fn home(&mut self) { + self.cursor = 0; + } + + pub const fn end(&mut self) { + self.cursor = self.text.len(); + } + + pub fn word_left(&mut self) { + self.cursor = self.word_start_before(is_word_char); + } + + pub fn word_right(&mut self) { + self.cursor = self.word_end_after(is_word_char); + } + + /// Deletes from the start of the line to the cursor, returning what was deleted. + pub fn kill_to_start(&mut self) -> String { + self.kill(0..self.cursor) + } + + /// Deletes from the cursor to the end of the line, returning what was deleted. + pub fn kill_to_end(&mut self) -> String { + self.kill(self.cursor..self.text.len()) + } + + /// Deletes the whitespace-delimited word before the cursor (like readline's + /// `unix-word-rubout`), returning what was deleted. + pub fn kill_big_word_before(&mut self) -> String { + let start = self.word_start_before(|c| !c.is_whitespace()); + self.kill(start..self.cursor) + } + + /// Deletes the word before the cursor, returning what was deleted. + pub fn kill_word_before(&mut self) -> String { + let start = self.word_start_before(is_word_char); + self.kill(start..self.cursor) + } + + /// Deletes the word after the cursor, returning what was deleted. + pub fn kill_word_after(&mut self) -> String { + let end = self.word_end_after(is_word_char); + self.kill(self.cursor..end) + } + + /// Swaps the characters before and at the cursor (or the two before it, at the + /// end of the line), and moves the cursor past them. Does nothing unless there + /// are two characters to swap. + pub fn transpose(&mut self) { + let b = if self.cursor == self.text.len() { + self.prev_char_boundary(self.cursor) + } else { + self.cursor + }; + let a = self.prev_char_boundary(b); + let c = self.next_char_boundary(b); + if a < b && b < c { + let swapped = std::format!("{}{}", &self.text[b..c], &self.text[a..b]); + self.text.replace_range(a..c, &swapped); + self.cursor = c; + } + } + + fn kill(&mut self, range: Range) -> String { + let killed = self.text[range.clone()].to_owned(); + self.text.replace_range(range.clone(), ""); + self.cursor = range.start; + killed + } + + /// Returns the start of the char before byte offset `pos`, or 0 at the start. + fn prev_char_boundary(&self, pos: usize) -> usize { + self.text[..pos] + .char_indices() + .next_back() + .map_or(0, |(i, _)| i) + } + + /// Returns the end of the char at byte offset `pos`, or `pos` at the end. + fn next_char_boundary(&self, pos: usize) -> usize { + self.text[pos..] + .chars() + .next() + .map_or(pos, |c| pos + c.len_utf8()) + } + + /// Finds the start of the word before the cursor: skips back over non-word + /// chars, then over word chars. + fn word_start_before(&self, in_word: impl Fn(char) -> bool) -> usize { + self.text[..self.cursor] + .trim_end_matches(|c| !in_word(c)) + .trim_end_matches(&in_word) + .len() + } + + /// Finds the end of the word after the cursor: skips over non-word chars, then + /// over word chars. + fn word_end_after(&self, in_word: impl Fn(char) -> bool) -> usize { + let rest = self.text[self.cursor..] + .trim_start_matches(|c| !in_word(c)) + .trim_start_matches(&in_word); + self.text.len() - rest.len() + } +} + +/// Whether `c` is part of a word, for word-wise movement and deletion. +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Returns a buffer for `marked`, with the cursor where `|` is. + fn buffer(marked: &str) -> LineBuffer { + let cursor = marked.find('|').unwrap_or_default(); + let mut buffer = LineBuffer::default(); + buffer.set(marked.replacen('|', "", 1), cursor); + buffer + } + + /// Shows a buffer with `|` at the cursor. + fn marked(buffer: &LineBuffer) -> String { + let mut s = buffer.text().to_owned(); + s.insert(buffer.cursor(), '|'); + s + } + + #[test] + fn moves_by_characters() { + let mut b = buffer("aé|界b"); + b.left(); + assert_eq!(marked(&b), "a|é界b"); + b.right(); + b.right(); + assert_eq!(marked(&b), "aé界|b"); + b.backspace(); + assert_eq!(marked(&b), "aé|b"); + b.delete(); + assert_eq!(marked(&b), "aé|"); + b.delete(); + assert_eq!(marked(&b), "aé|"); + } + + #[test] + fn moves_by_words() { + let mut b = buffer("echo foo-bar baz|"); + b.word_left(); + assert_eq!(marked(&b), "echo foo-bar |baz"); + b.word_left(); + assert_eq!(marked(&b), "echo foo-|bar baz"); + b.word_right(); + assert_eq!(marked(&b), "echo foo-bar| baz"); + b.home(); + b.word_left(); + assert_eq!(marked(&b), "|echo foo-bar baz"); + } + + #[test] + fn kills_text() { + let mut b = buffer("ls -l foo/bar|"); + assert_eq!(b.kill_word_before(), "bar"); + assert_eq!(marked(&b), "ls -l foo/|"); + assert_eq!(b.kill_big_word_before(), "foo/"); + assert_eq!(marked(&b), "ls -l |"); + b.home(); + assert_eq!(b.kill_word_after(), "ls"); + assert_eq!(marked(&b), "| -l "); + assert_eq!(b.kill_to_end(), " -l "); + assert_eq!(marked(&b), "|"); + + let mut b = buffer("abc|def"); + assert_eq!(b.kill_to_start(), "abc"); + assert_eq!(marked(&b), "|def"); + } + + #[test] + fn kills_whitespace_before_words() { + let mut b = buffer(" |"); + assert_eq!(b.kill_big_word_before(), " "); + assert_eq!(marked(&b), "|"); + } + + #[test] + fn transposes_only_when_there_are_two_characters() { + for marked_text in ["a|", "|a", "|"] { + let mut b = buffer(marked_text); + b.transpose(); + assert_eq!(marked(&b), marked_text); + } + let mut b = buffer("|ab"); + b.transpose(); + assert_eq!(marked(&b), "|ab"); + } + + #[test] + fn transposes_characters() { + let mut b = buffer("ab|c"); + b.transpose(); + assert_eq!(marked(&b), "acb|"); + b.transpose(); + assert_eq!(marked(&b), "abc|"); + } + + #[test] + fn replaces_ranges() { + let mut b = buffer("cat RE|"); + b.replace(4..6, "README.txt "); + assert_eq!(marked(&b), "cat README.txt |"); + } + + #[test] + fn keeps_the_cursor_on_a_character_boundary() { + let mut b = LineBuffer::default(); + // Offset 2 is inside `é`, and 9 is past the end. + b.set("aé".to_owned(), 2); + assert_eq!(marked(&b), "a|é"); + b.set("aé".to_owned(), 9); + assert_eq!(marked(&b), "aé|"); + } + + #[test] + fn moves_and_kills_by_words_of_any_script() { + let mut b = buffer("echo café naïve 日本語|"); + b.word_left(); + assert_eq!(marked(&b), "echo café naïve |日本語"); + b.word_left(); + assert_eq!(marked(&b), "echo café |naïve 日本語"); + b.word_right(); + assert_eq!(marked(&b), "echo café naïve| 日本語"); + assert_eq!(b.kill_word_before(), "naïve"); + assert_eq!(marked(&b), "echo café | 日本語"); + assert_eq!(b.kill_word_after(), " 日本語"); + assert_eq!(b.kill_big_word_before(), "café "); + assert_eq!(marked(&b), "echo |"); + } + + #[test] + fn transposes_multibyte_characters() { + let mut b = buffer("é|ñ"); + b.transpose(); + assert_eq!(marked(&b), "ñé|"); + b.transpose(); + assert_eq!(marked(&b), "éñ|"); + let mut b = buffer("a界|"); + b.transpose(); + assert_eq!(marked(&b), "界a|"); + } + + #[test] + fn edits_a_character_at_a_time_even_when_characters_combine() { + // Like readline, the cursor moves over, and backspace deletes, one + // character: an accent that combines with the letter before it is one too. + let mut b = buffer("e\u{301}|x"); + b.backspace(); + assert_eq!(marked(&b), "e|x"); + b.insert("\u{301}"); + b.left(); + assert_eq!(marked(&b), "e|\u{301}x"); + b.delete(); + assert_eq!(marked(&b), "e|x"); + } + + #[test] + fn kills_and_replaces_around_multibyte_characters() { + let mut b = buffer("日本|語"); + assert_eq!(b.kill_to_end(), "語"); + assert_eq!(b.kill_to_start(), "日本"); + b.insert("café"); + // `é` is the two bytes at 3..5. + b.replace(3..5, "e\u{301}"); + assert_eq!(marked(&b), "cafe\u{301}|"); + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/line_editor.rs b/userland/brush/source/brush-interactive/src/vt/line_editor.rs new file mode 100644 index 0000000..a23aa9c --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/line_editor.rs @@ -0,0 +1,841 @@ +//! Editing a line: readline's functions, history, and incremental search. + +use std::io::Write; + +use brush_core::interfaces::InputFunction; + +use super::line_buffer::LineBuffer; +use super::render::Renderer; +use super::style::Style; + +/// What a key does, once key bindings are taken into account. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Action { + /// Perform a readline function. + Function(InputFunction), + /// Insert a char (e.g., for an unbound key that types one). + Insert(char), + /// Run a shell command (bound with `bind -x`). + Command(String), + /// Nothing: an unbound key that doesn't type a char. Whether it's the Escape + /// key matters while searching, which it ends. + Unbound { + /// Whether it's the Escape key on its own. + escape: bool, + }, +} + +/// The history a [`LineEditor`] browses and searches: entries numbered from 0, +/// the oldest. It's consulted only as history is browsed or searched, an entry at +/// a time, rather than copied for each line. +pub(crate) trait History { + /// Returns how many entries there are. + fn len(&self) -> usize; + + /// Returns the given entry, if there is one. + fn get(&self, index: usize) -> Option<&str>; +} + +/// History that may be missing (as a shell that keeps none has), and is then +/// empty. +impl History for Option<&T> { + fn len(&self) -> usize { + self.map_or(0, |history| history.len()) + } + + fn get(&self, index: usize) -> Option<&str> { + self.and_then(|history| history.get(index)) + } +} + +#[cfg(test)] +impl History for [String] { + fn len(&self) -> usize { + <[String]>::len(self) + } + + fn get(&self, index: usize) -> Option<&str> { + <[String]>::get(self, index).map(String::as_str) + } +} + +impl History for brush_core::history::History { + fn len(&self) -> usize { + self.count() + } + + fn get(&self, index: usize) -> Option<&str> { + Self::get(self, index).map(|item| item.command_line.as_str()) + } +} + +/// What a kill command most recently deleted, to be yanked back. +#[derive(Default)] +pub(crate) struct KillBuffer { + text: String, + /// Whether the last action killed text, so that a kill now adds to what it + /// killed, as consecutive kills do in readline. + pub chained: bool, +} + +impl KillBuffer { + /// Keeps `killed`, which was deleted before the cursor if `backward`, or after + /// it otherwise. + fn kill(&mut self, killed: &str, backward: bool) { + // Killing nothing keeps what was killed before. + if killed.is_empty() { + return; + } + if !self.chained { + self.text.clear(); + } + if backward { + self.text.insert_str(0, killed); + } else { + self.text.push_str(killed); + } + } +} + +/// Whether `function` kills text (so that another kill straight after it adds to +/// what it killed). +pub(crate) const fn is_kill(function: &InputFunction) -> bool { + matches!( + function, + InputFunction::KillLine + | InputFunction::UnixLineDiscard + | InputFunction::UnixWordRubout + | InputFunction::BackwardKillWord + | InputFunction::KillWord + ) +} + +/// The state of editing one line. +pub(crate) struct LineEditor<'a> { + /// The prompt's last line, drawn with the line being edited. + prompt: &'a str, + /// The prompt's lines before its last, if any, as written to the terminal. + prompt_above: String, + pub buffer: LineBuffer, + /// Which history entry is shown; `None` for the line being entered. + history_index: Option, + /// The line being entered, set aside while a history entry is shown. + draft: Option, + search: Option, + renderer: Renderer, + style: Style, + /// Whether the last action was completion (so another lists the candidates). + pub completed_last: bool, +} + +/// The state of an incremental history search (Ctrl-R). +struct Search { + query: String, + /// The history entry last found, if there is one. + found: Option, + /// Whether the query, as it is, matches nothing more; what was last found is + /// still shown. + failed: bool, + /// What to go back to if the search is cancelled. + original: LineBuffer, + original_index: Option, +} + +/// What became of a search, given an action. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SearchStep { + /// The search took the action. + Taken, + /// The search ended, keeping what it found; the action is to take effect as + /// usual. + Ended, +} + +impl<'a> LineEditor<'a> { + /// Returns an editor for a new line. + /// + /// # Arguments + /// + /// * `prompt` - The prompt's last line, drawn with the line. + /// * `prompt_above` - The prompt's earlier lines, as written to the terminal + /// (shown again when the screen is cleared or candidates listed). + /// * `style` - How to draw. + pub fn new(prompt: &'a str, prompt_above: String, style: Style) -> Self { + Self { + prompt, + prompt_above, + buffer: LineBuffer::default(), + history_index: None, + draft: None, + search: None, + renderer: Renderer::new(style), + style, + completed_last: false, + } + } + + /// Rings the bell, if the style rings it. + pub fn bell(&self, out: &mut impl Write) -> std::io::Result<()> { + if self.style == Style::Vt { + out.write_all(b"\x07")?; + } + Ok(()) + } + + pub const fn searching(&self) -> bool { + self.search.is_some() + } + + /// Refreshes semantic highlighting for the current buffer and cursor. + pub fn highlight( + &mut self, + shell: &brush_core::Shell, + program: &str, + ) { + let line = if program.is_empty() { + std::borrow::Cow::Borrowed(self.buffer.text()) + } else { + std::borrow::Cow::Owned(format!("{program}{}", self.buffer.text())) + }; + let highlighted = crate::highlighting::highlight_command( + shell, + &line, + program.len() + self.buffer.cursor(), + ); + self.renderer.set_highlighting(&highlighted, program.len()); + } + + /// Draws the line, replacing what was drawn before. + pub fn draw(&mut self, out: &mut impl Write, columns: usize) -> std::io::Result<()> { + let prompt = self.search_prompt(); + let prompt = prompt.as_deref().unwrap_or(self.prompt); + self.renderer.draw( + out, + prompt, + self.buffer.text(), + self.buffer.cursor(), + columns, + ) + } + + /// Draws the line a last time, and moves to the next. + pub fn finish(&mut self, out: &mut impl Write, columns: usize) -> std::io::Result<()> { + let prompt = self.search_prompt(); + let prompt = prompt.as_deref().unwrap_or(self.prompt); + self.renderer + .finish(out, prompt, self.buffer.text(), columns) + } + + /// Takes the line off the terminal while a bound command runs. As bash does, it's + /// erased, to be drawn again where it was; returns `true`. Without cursor + /// addressing, there's no erasing it, so it's left in place and the next line + /// is drawn afresh below; returns `false`. + pub fn set_aside(&mut self, out: &mut impl Write, columns: usize) -> std::io::Result { + if let Renderer::Vt(renderer) = &mut self.renderer { + renderer.erase(out)?; + Ok(true) + } else { + self.finish(out, columns)?; + Ok(false) + } + } + + /// Moves to the start of a row of its own to start drawing, so the line doesn't + /// overwrite output that didn't end with a newline. + pub fn start_on_own_row(&self, out: &mut impl Write, columns: usize) -> std::io::Result<()> { + self.renderer.start_on_own_row(out, columns) + } + + /// Writes the prompt's lines before its last, to start drawing it afresh. + pub fn show_prompt_above(&self, out: &mut impl Write) -> std::io::Result<()> { + if !self.prompt_above.is_empty() { + out.write_all(self.prompt_above.as_bytes())?; + out.write_all(b"\n")?; + } + Ok(()) + } + + /// While searching, the prompt shows the search instead. + fn search_prompt(&self) -> Option { + self.search.as_ref().map(|search| { + let failed = if search.failed { "failed " } else { "" }; + std::format!("({failed}reverse-i-search)`{}': ", search.query) + }) + } + + /// Inserts `c` at the cursor. + pub fn insert(&mut self, c: char) { + self.buffer.insert(c.encode_utf8(&mut [0; 4])); + } + + /// Performs a readline function. Returns the line, if the function accepts it. + /// Functions this editor doesn't perform do nothing. + /// + /// # Arguments + /// + /// * `function` - The function to perform. + /// * `kills` - What's been killed, to add to or yank. + /// * `history` - The history to browse and search. + /// * `out` - The terminal. + /// * `columns` - The terminal's width. + pub fn perform( + &mut self, + function: &InputFunction, + kills: &mut KillBuffer, + history: &(impl History + ?Sized), + out: &mut impl Write, + columns: usize, + ) -> std::io::Result> { + use InputFunction as F; + + let b = &mut self.buffer; + match function { + F::AcceptLine => return Ok(Some(b.text().to_owned())), + F::BeginningOfLine => b.home(), + F::EndOfLine => b.end(), + F::BackwardChar => b.left(), + F::ForwardChar => b.right(), + F::BackwardWord => b.word_left(), + F::ForwardWord => b.word_right(), + F::BackwardDeleteChar => b.backspace(), + F::DeleteChar => b.delete(), + F::KillLine => kills.kill(&b.kill_to_end(), false), + F::UnixLineDiscard => kills.kill(&b.kill_to_start(), true), + F::UnixWordRubout => kills.kill(&b.kill_big_word_before(), true), + F::BackwardKillWord => kills.kill(&b.kill_word_before(), true), + F::KillWord => kills.kill(&b.kill_word_after(), false), + F::Yank => b.insert(&kills.text), + F::TransposeChars => b.transpose(), + F::PreviousHistory => self.history_previous(history, out)?, + F::NextHistory => self.history_next(history, out)?, + F::BeginningOfHistory => { + let oldest = (history.len() > 0).then_some(0); + self.show_history_entry(oldest, history); + } + F::EndOfHistory => self.show_history_entry(None, history), + F::ReverseSearchHistory => self.start_search(), + F::ClearScreen => self.clear_screen(out, columns)?, + F::Abort => self.bell(out)?, + _ => {} + } + Ok(None) + } + + /// Clears the screen, leaving the line to be drawn at the top. Without cursor + /// addressing there's no clearing it, so, as readline does, the line is drawn + /// afresh on the next. + fn clear_screen(&mut self, out: &mut impl Write, columns: usize) -> std::io::Result<()> { + match self.style { + Style::Vt => { + out.write_all(b"\x1b[H\x1b[2J")?; + self.renderer.reset(); + } + Style::Basic => self.finish(out, columns)?, + } + self.show_prompt_above(out) + } + + fn history_previous( + &mut self, + history: &(impl History + ?Sized), + out: &mut impl Write, + ) -> std::io::Result<()> { + let previous = self + .history_index + .unwrap_or_else(|| history.len()) + .checked_sub(1); + if previous.is_some() { + self.show_history_entry(previous, history); + Ok(()) + } else { + self.bell(out) + } + } + + fn history_next( + &mut self, + history: &(impl History + ?Sized), + out: &mut impl Write, + ) -> std::io::Result<()> { + if let Some(index) = self.history_index { + let next = Some(index + 1).filter(|&next| next < history.len()); + self.show_history_entry(next, history); + Ok(()) + } else { + self.bell(out) + } + } + + /// Shows the given history entry (or, for `None`, the line being entered), + /// setting that line aside whenever it's left. + fn show_history_entry(&mut self, index: Option, history: &(impl History + ?Sized)) { + if index == self.history_index { + return; + } + if self.history_index.is_none() { + self.draft = Some(self.buffer.clone()); + } + + self.history_index = index; + self.buffer = match index.and_then(|index| history.get(index)) { + Some(entry) => LineBuffer::new(entry), + None => self.draft.take().unwrap_or_default(), + }; + } + + fn start_search(&mut self) { + self.search = Some(Search { + query: String::new(), + found: None, + failed: false, + original: self.buffer.clone(), + original_index: self.history_index, + }); + } + + /// Takes an action while searching, as readline's incremental search does: + /// typing extends the query, Backspace shortens it, Ctrl-R finds an older + /// match, Ctrl-G cancels the search, and Escape ends it. Anything else ends the + /// search, and is then to take effect as usual. + pub fn search_step( + &mut self, + action: &Action, + history: &(impl History + ?Sized), + ) -> SearchStep { + let Some(mut search) = self.search.take() else { + return SearchStep::Ended; + }; + + match action { + Action::Insert(c) => { + search.query.push(*c); + // The current match may still match. + let start = search.found.map_or(search.original_index, |i| Some(i + 1)); + self.search_from(&mut search, start, history); + } + Action::Function(InputFunction::BackwardDeleteChar) => { + search.query.pop(); + let start = search.original_index; + self.search_from(&mut search, start, history); + } + Action::Function(InputFunction::ReverseSearchHistory) => { + let start = search.found.or(search.original_index); + self.search_from(&mut search, start, history); + } + Action::Function(InputFunction::Abort) => { + self.show_history_entry(search.original_index, history); + self.buffer = search.original; + return SearchStep::Taken; + } + Action::Unbound { escape: true } => return SearchStep::Taken, + _ => return SearchStep::Ended, + } + + self.search = Some(search); + SearchStep::Taken + } + + /// Searches for the query in history, back from (and not including) the given + /// entry, or from the newest for `None`, and shows the most recent match, with + /// the cursor at the start of what matched. If nothing matches, what was shown + /// stays, as it does for an empty query, which matches nothing. + fn search_from( + &mut self, + search: &mut Search, + start: Option, + history: &(impl History + ?Sized), + ) { + if search.query.is_empty() { + search.found = None; + search.failed = false; + return; + } + + let end = history.len(); + let end = start.map_or(end, |start| start.min(end)); + let found = (0..end).rev().find_map(|index| { + let offset = history.get(index)?.find(&search.query)?; + Some((index, offset)) + }); + + search.failed = found.is_none(); + if let Some((index, offset)) = found { + search.found = Some(index); + self.show_history_entry(Some(index), history); + self.buffer.move_to(offset); + } + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in tests")] +mod tests { + use super::*; + use InputFunction as F; + + fn strings(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + /// An editor, what it's killed, and what it's drawn. + struct Editing<'a> { + editor: LineEditor<'a>, + history: &'a [String], + kills: KillBuffer, + out: Vec, + } + + impl<'a> Editing<'a> { + fn new(history: &'a [String], style: Style) -> Self { + Self { + editor: LineEditor::new("$ ", String::new(), style), + history, + kills: KillBuffer::default(), + out: vec![], + } + } + + /// Takes actions as the line reader does, returning the line if accepted. + fn act(&mut self, actions: &[Action]) -> std::io::Result> { + for action in actions { + if self.editor.searching() + && self.editor.search_step(action, self.history) == SearchStep::Taken + { + continue; + } + match action { + Action::Function(function) => { + let accepted = self.editor.perform( + function, + &mut self.kills, + self.history, + &mut self.out, + 80, + )?; + if accepted.is_some() { + return Ok(accepted); + } + } + Action::Insert(c) => self.editor.insert(*c), + Action::Command(_) | Action::Unbound { .. } => {} + } + self.kills.chained = matches!(action, Action::Function(f) if is_kill(f)); + } + Ok(None) + } + + fn text(&self) -> &str { + self.editor.buffer.text() + } + } + + fn f(function: F) -> Action { + Action::Function(function) + } + + fn typing(text: &str) -> Vec { + text.chars().map(Action::Insert).collect() + } + + #[test] + fn performs_editing_functions() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Vt); + e.editor.buffer = LineBuffer::new("one two three"); + + e.act(&[f(F::BackwardWord), f(F::BackwardWord), f(F::BackwardChar)])?; + assert_eq!(e.editor.buffer.cursor(), 3); + e.act(&[f(F::ForwardChar), f(F::ForwardWord), f(F::ForwardWord)])?; + assert_eq!(e.editor.buffer.cursor(), "one two three".len()); + e.act(&[f(F::BeginningOfLine), f(F::KillWord)])?; + assert_eq!(e.text(), " two three"); + e.act(&[f(F::EndOfLine), f(F::BackwardKillWord)])?; + assert_eq!(e.text(), " two "); + e.act(&[f(F::BeginningOfLine), f(F::DeleteChar), f(F::KillLine)])?; + assert_eq!(e.text(), ""); + e.act(&[f(F::Yank)])?; + assert_eq!(e.text(), "two "); + e.act(&[f(F::BackwardDeleteChar), f(F::UnixLineDiscard)])?; + assert_eq!(e.text(), ""); + e.act(&[typing("ab"), vec![f(F::TransposeChars)]].concat())?; + assert_eq!(e.text(), "ba"); + + // Functions this editor doesn't perform do nothing. + e.act(&[f(F::Undo), f(F::UpcaseWord)])?; + assert_eq!(e.text(), "ba"); + assert_eq!(e.act(&[f(F::AcceptLine)])?, Some("ba".to_owned())); + + Ok(()) + } + + #[test] + fn kills_and_yanks() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Vt); + e.editor.buffer = LineBuffer::new("echo hello world"); + e.act(&[f(F::UnixWordRubout)])?; + assert_eq!(e.text(), "echo hello "); + e.act(&[f(F::BeginningOfLine), f(F::Yank)])?; + assert_eq!(e.text(), "worldecho hello "); + Ok(()) + } + + #[test] + fn adds_consecutive_kills_together() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Vt); + e.editor.buffer = LineBuffer::new("one two three four"); + + // Backward kills go before what was killed, forward kills after. + e.act(&[f(F::UnixWordRubout), f(F::UnixWordRubout)])?; + assert_eq!(e.text(), "one two "); + e.act(&[f(F::Yank)])?; + assert_eq!(e.text(), "one two three four"); + e.act(&[f(F::BeginningOfLine), f(F::KillWord), f(F::KillWord)])?; + assert_eq!(e.text(), " three four"); + e.act(&[f(F::EndOfLine), f(F::Yank)])?; + assert_eq!(e.text(), " three fourone two"); + + // Anything else in between starts afresh: only the last kill is yanked. + e.act(&[ + f(F::BeginningOfLine), + f(F::KillWord), + f(F::ForwardChar), + f(F::KillWord), + ])?; + assert_eq!(e.text(), " two"); + e.act(&[f(F::Yank)])?; + assert_eq!(e.text(), " fourone two"); + Ok(()) + } + + #[test] + fn killing_nothing_keeps_what_was_killed() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Vt); + e.editor.buffer = LineBuffer::new("echo word"); + e.act(&[f(F::UnixWordRubout)])?; + // At the end of the line, there's nothing to kill. + e.act(&[f(F::ForwardChar), f(F::KillLine), f(F::Yank)])?; + assert_eq!(e.text(), "echo word"); + Ok(()) + } + + #[test] + fn browses_history_and_keeps_the_draft() -> std::io::Result<()> { + let history = strings(&["first", "second"]); + let mut e = Editing::new(&history, Style::Vt); + + e.act(&typing("dra"))?; + e.act(&[f(F::PreviousHistory)])?; + assert_eq!(e.text(), "second"); + e.act(&[f(F::PreviousHistory), f(F::PreviousHistory)])?; + assert_eq!(e.text(), "first"); + assert_eq!(e.out, b"\x07"); + e.act(&[f(F::NextHistory), f(F::NextHistory)])?; + assert_eq!(e.text(), "dra"); + Ok(()) + } + + #[test] + fn jumps_to_either_end_of_history() -> std::io::Result<()> { + let history = strings(&["first", "second"]); + let mut e = Editing::new(&history, Style::Vt); + + e.act(&typing("x"))?; + e.act(&[f(F::BeginningOfHistory)])?; + assert_eq!(e.text(), "first"); + e.act(&[f(F::NextHistory)])?; + assert_eq!(e.text(), "second"); + e.act(&[f(F::EndOfHistory)])?; + assert_eq!(e.text(), "x"); + + // Past the end, there's nothing more to show. + e.act(&[f(F::NextHistory)])?; + assert_eq!(e.text(), "x"); + assert_eq!(e.out, b"\x07"); + Ok(()) + } + + #[test] + fn keeps_the_draft_when_already_at_the_end() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Vt); + e.act(&typing("foo"))?; + e.act(&[f(F::EndOfHistory), f(F::BeginningOfHistory)])?; + assert_eq!(e.text(), "foo"); + Ok(()) + } + + #[test] + fn keeps_the_draft_through_a_search() -> std::io::Result<()> { + let history = strings(&["one", "two"]); + let mut e = Editing::new(&history, Style::Vt); + e.act(&typing("foo"))?; + e.act(&[vec![f(F::ReverseSearchHistory)], typing("tw")].concat())?; + assert_eq!(e.text(), "two"); + // Moving on ends the search; going back to the end shows the draft. + e.act(&[f(F::ForwardChar), f(F::NextHistory)])?; + assert_eq!(e.text(), "foo"); + Ok(()) + } + + #[test] + fn rings_the_bell_only_in_the_vt_style() -> std::io::Result<()> { + let mut e = Editing::new(&[], Style::Basic); + e.act(&[f(F::PreviousHistory), f(F::NextHistory), f(F::Abort)])?; + assert_eq!(e.out, b""); + + let mut e = Editing::new(&[], Style::Vt); + e.act(&[f(F::Abort)])?; + assert_eq!(e.out, b"\x07"); + Ok(()) + } + + #[test] + fn searches_history_incrementally() -> std::io::Result<()> { + let history = strings(&["echo one", "ls -l", "echo two"]); + let mut e = Editing::new(&history, Style::Vt); + + e.act(&[vec![f(F::ReverseSearchHistory)], typing("echo")].concat())?; + assert_eq!(e.text(), "echo two"); + e.act(&[f(F::ReverseSearchHistory)])?; + assert_eq!(e.text(), "echo one"); + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(reverse-i-search)`echo': ") + ); + + // Another key ends the search, keeping the match, and takes effect. + e.act(&[f(F::EndOfLine)])?; + assert!(!e.editor.searching()); + e.act(&typing("!"))?; + assert_eq!(e.text(), "echo one!"); + + // Like readline, a search goes back from the entry being shown. + e.act(&[f(F::ReverseSearchHistory), Action::Insert('l')])?; + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(failed reverse-i-search)`l': ") + ); + + // Cancelling restores the line as it was. + e.act(&[f(F::Abort)])?; + assert_eq!(e.text(), "echo one!"); + Ok(()) + } + + #[test] + fn refines_ends_and_cancels_searches() -> std::io::Result<()> { + let history = strings(&["make test", "cargo build", "make all"]); + let mut e = Editing::new(&history, Style::Vt); + + e.act(&[Action::Insert('d'), f(F::ReverseSearchHistory)])?; + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(reverse-i-search)`': ") + ); + e.act(&typing("ma"))?; + assert_eq!(e.text(), "make all"); + // Searching again finds an older match, with the cursor on the match. + e.act(&[f(F::ReverseSearchHistory)])?; + assert_eq!(e.text(), "make test"); + assert_eq!(e.editor.buffer.cursor(), 0); + // With no older match, the last one stays. + e.act(&[f(F::ReverseSearchHistory)])?; + assert_eq!(e.text(), "make test"); + + // A query that matches nothing fails, until it's shortened. + e.act(&typing("z"))?; + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(failed reverse-i-search)`maz': ") + ); + e.act(&[vec![f(F::BackwardDeleteChar); 3], typing("b")].concat())?; + assert_eq!(e.text(), "cargo build"); + assert_eq!(e.editor.buffer.cursor(), "cargo ".len()); + + // Escape ends the search, keeping the match, as in readline. + e.act(&[Action::Unbound { escape: true }])?; + assert!(!e.editor.searching()); + assert_eq!(e.text(), "cargo build"); + + // Ctrl-G cancels, going back to the line as it was before searching. (This + // search goes back from the entry shown, so it finds the older `make`.) + e.act(&[vec![f(F::ReverseSearchHistory)], typing("make")].concat())?; + assert_eq!(e.text(), "make test"); + e.act(&[f(F::Abort)])?; + assert!(!e.editor.searching()); + assert_eq!(e.text(), "cargo build"); + Ok(()) + } + + #[test] + fn keeps_the_last_match_when_a_search_fails() -> std::io::Result<()> { + let history = strings(&["cargo test", "make build", "cargo doc"]); + let mut e = Editing::new(&history, Style::Vt); + + e.act(&[vec![f(F::ReverseSearchHistory)], typing("ca")].concat())?; + e.act(&[f(F::ReverseSearchHistory), f(F::ReverseSearchHistory)])?; + assert_eq!(e.text(), "cargo test"); + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(failed reverse-i-search)`ca': ") + ); + + // The search goes on from the match that's shown. + e.act(&typing("r"))?; + assert_eq!(e.text(), "cargo test"); + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(reverse-i-search)`car': ") + ); + Ok(()) + } + + #[test] + fn leaves_the_line_as_it_is_for_an_empty_search() -> std::io::Result<()> { + let history = strings(&["echo aaa", "echo bbb"]); + let mut e = Editing::new(&history, Style::Vt); + e.act(&typing("echo DRAFT"))?; + + // An empty query matches nothing, so the line stays. + e.act(&[f(F::ReverseSearchHistory), f(F::BackwardDeleteChar)])?; + assert_eq!(e.text(), "echo DRAFT"); + e.act(&[f(F::ReverseSearchHistory)])?; + assert_eq!(e.text(), "echo DRAFT"); + assert_eq!( + e.editor.search_prompt().as_deref(), + Some("(reverse-i-search)`': ") + ); + + // Nor does shortening the query to nothing go elsewhere. + e.act(&[Action::Insert('a'), f(F::BackwardDeleteChar)])?; + assert_eq!(e.text(), "echo aaa"); + Ok(()) + } + + #[test] + fn clears_the_screen() -> std::io::Result<()> { + let no_history: &[String] = &[]; + let mut editor = LineEditor::new("$ ", "above".to_owned(), Style::Vt); + let mut out = vec![]; + editor.perform( + &F::ClearScreen, + &mut KillBuffer::default(), + no_history, + &mut out, + 80, + )?; + assert_eq!(out, b"\x1b[H\x1b[2Jabove\n"); + + // Without cursor addressing, the line is drawn afresh below instead. + let mut editor = LineEditor::new("$ ", "above".to_owned(), Style::Basic); + editor.insert('x'); + let mut out = vec![]; + editor.perform( + &F::ClearScreen, + &mut KillBuffer::default(), + no_history, + &mut out, + 80, + )?; + assert_eq!(out, b"$ x\nabove\n"); + Ok(()) + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/line_reader.rs b/userland/brush/source/brush-interactive/src/vt/line_reader.rs new file mode 100644 index 0000000..44e20f8 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/line_reader.rs @@ -0,0 +1,1120 @@ +//! Reading a program, a line at a time: keys in, edited lines out. + +use std::io::Write; +use std::sync::Arc; + +use brush_core::interfaces::InputFunction; +use tokio::sync::Mutex; + +use super::completion_list; +use super::keymap::{Binding, VtKeyBindings}; +use super::keys::{self, KEYSEQ_TIMEOUT, KeyReader, Keypress}; +use super::line_editor::{Action, KillBuffer, LineEditor, SearchStep, is_kill}; +use super::render::printable; +use super::style::Style; +use super::terminal::Terminal; +use crate::{ + ShellError, completeness, + input_backend::{InteractivePrompt, ReadResult}, +}; + +/// Width to assume for a terminal that doesn't report one. +const DEFAULT_COLUMNS: usize = 80; + +/// How many macros may expand, one within another, before a key next comes from +/// the terminal, so a macro that (directly or not) expands to itself can't loop +/// forever. +const MAX_MACRO_EXPANSIONS: usize = 100; + +/// Reads programs a line at a time, editing each line as keys arrive, and keeps +/// what lasts from one line to the next: key bindings, what's been killed, and +/// the line (and the program) a bound command sets aside. +#[derive(Default)] +pub(crate) struct LineReader { + pub style: Style, + pub highlighting: bool, + pub bindings: Arc>, + /// The line being edited when a bound key was pressed, while its command runs. + pub bound_line: Option<(String, usize)>, + /// The line to start the next read with (e.g., as left by a bound command). + pub next_line: Option<(String, usize)>, + /// The lines of a program entered before a bound key was pressed, to go on + /// with once its command has run. + unfinished_program: String, + /// Whether the line was erased for a bound command, to be drawn again in its + /// place once the command has run. + redraw_in_place: bool, + keys: KeyReader, + kills: KillBuffer, + /// How many macros have expanded since a key last came from the terminal. + macro_expansions: usize, +} + +/// How reading a line ended. +enum LineResult { + /// The user entered the line. + Line(String), + /// Input ended. + Eof, + /// The user interrupted input. + Interrupted, + /// The user pressed a key bound to the given shell command. + Bound(String), +} + +impl LineReader { + /// Reads lines until they form a complete program, reading keys from + /// `terminal` and drawing on `out`. + pub async fn read_program( + &mut self, + shell: &crate::ShellRef, + prompt: &InteractivePrompt, + terminal: &mut impl Terminal, + out: &mut (impl Write + Send), + ) -> Result { + // A program that a bound command interrupted goes on where it left off. + let mut program = std::mem::take(&mut self.unfinished_program); + let mut prompt_text = if program.is_empty() { + prompt.prompt.as_str() + } else { + prompt.continuation_prompt.as_str() + }; + + loop { + match self + .read_one_line(shell, &program, prompt_text, terminal, out) + .await? + { + LineResult::Line(line) => { + program.push_str(&line); + program.push('\n'); + if !completeness::needs_more_input(shell, &program) { + return Ok(ReadResult::Input(program)); + } + prompt_text = prompt.continuation_prompt.as_str(); + } + LineResult::Eof if program.is_empty() => return Ok(ReadResult::Eof), + LineResult::Eof => return Ok(ReadResult::Input(program)), + LineResult::Interrupted => return Ok(ReadResult::Interrupted), + LineResult::Bound(command) => { + self.unfinished_program = program; + return Ok(ReadResult::BoundCommand(command)); + } + } + } + } + + /// Reads and edits one line of input. + async fn read_one_line( + &mut self, + shell: &crate::ShellRef, + program: &str, + prompt: &str, + terminal: &mut impl Terminal, + out: &mut (impl Write + Send), + ) -> Result { + // Only the prompt's last line is redrawn with the input; the rest is shown + // once, and again only when the line is drawn afresh. + let (prompt_above, prompt) = match prompt.rsplit_once('\n') { + Some((above, last)) => (printable(above), last), + None => (String::new(), prompt), + }; + // The terminal's width is read once per line, not on every redraw: a line + // being entered while the terminal is resized is drawn for the old width + // until the next line. + let columns = columns(terminal); + let mut editor = LineEditor::new(prompt, prompt_above, self.style); + // Output (e.g., from the last command) may not have ended with a newline. + editor.start_on_own_row(out, columns)?; + // A line erased for a bound command is drawn again in its place, below the + // rest of the prompt, which is still shown. + if !std::mem::take(&mut self.redraw_in_place) { + editor.show_prompt_above(out)?; + } + if let Some((text, cursor)) = self.next_line.take() { + editor.buffer.set(text, cursor); + } + self.highlight(shell, program, &mut editor).await; + editor.draw(out, columns)?; + + loop { + let Some(key) = self.keys.next(terminal)? else { + editor.finish(out, columns)?; + return Ok(LineResult::Eof); + }; + if !key.from_macro { + self.macro_expansions = 0; + } + + // As at a terminal's own line editing, whatever they're bound to, Ctrl-C + // interrupts, and Ctrl-D on an empty line ends input. + if key.bytes == [0x03] { + // Leave the interrupted line as it was, marked as such. + editor.buffer.end(); + editor.draw(out, columns)?; + out.write_all(b"^C\n")?; + return Ok(LineResult::Interrupted); + } + if key.bytes == [0x04] && editor.buffer.text().is_empty() && !editor.searching() { + editor.finish(out, columns)?; + return Ok(LineResult::Eof); + } + + let Some(action) = self.resolve(key, terminal).await? else { + // A macro expanded; its keys come next. + continue; + }; + + // History is read from the shell as it's browsed or searched. + let taken_by_search = editor.searching() && { + let locked = shell.lock().await; + editor.search_step(&action, &locked.history()) == SearchStep::Taken + }; + if !taken_by_search { + match &action { + Action::Command(command) => { + // The command runs with the line set aside for it. + self.redraw_in_place = editor.set_aside(out, columns)?; + self.bound_line = + Some((editor.buffer.text().to_owned(), editor.buffer.cursor())); + return Ok(LineResult::Bound(command.clone())); + } + Action::Function(InputFunction::Complete) => { + completion_list::complete(shell, &mut editor, out, columns, self.style) + .await?; + } + Action::Function(function) => { + let locked = shell.lock().await; + let accepted = editor.perform( + function, + &mut self.kills, + &locked.history(), + out, + columns, + )?; + drop(locked); + if let Some(line) = accepted { + self.highlight(shell, program, &mut editor).await; + editor.finish(out, columns)?; + return Ok(LineResult::Line(line)); + } + } + Action::Insert(c) => editor.insert(*c), + Action::Unbound { .. } => {} + } + } + + self.kills.chained = + !taken_by_search && matches!(&action, Action::Function(f) if is_kill(f)); + editor.completed_last = + !taken_by_search && action == Action::Function(InputFunction::Complete); + self.highlight(shell, program, &mut editor).await; + editor.draw(out, columns)?; + } + } + + /// Highlight outside the renderer, where the shell can be locked asynchronously. + async fn highlight( + &self, + shell: &crate::ShellRef, + program: &str, + editor: &mut LineEditor<'_>, + ) { + if self.highlighting && self.style == Style::Vt { + let shell = shell.lock().await; + editor.highlight(&shell, program); + } + } + + /// Works out what a pressed key does, given the key bindings, reading more keys + /// if it starts a bound sequence. Returns `None` if the keys were bound to a + /// macro, whose keys are then the next to act on. + async fn resolve( + &mut self, + first: Keypress, + terminal: &mut impl Terminal, + ) -> std::io::Result> { + let mut held = vec![first]; + // The longest bound sequence of keys held, and what it's bound to. + let mut bound: Option<(usize, Binding)> = None; + + loop { + let lookup = self.bindings.lock().await.lookup(&held); + if let Some(binding) = lookup.bound { + bound = Some((held.len(), binding)); + } + if !lookup.prefix { + break; + } + + // A longer bound sequence starts with the keys held. If they're bound + // themselves, wait only a little for the next, as readline does. + let next = if bound.as_ref().is_some_and(|(len, _)| *len == held.len()) { + self.keys.next_within(terminal, KEYSEQ_TIMEOUT)? + } else { + self.keys.next(terminal)? + }; + let Some(next) = next else { + break; + }; + held.push(next); + } + + // Keys held past the bound sequence (or past the first, if none is bound) + // are acted on next. + let used = bound.as_ref().map_or(1, |(len, _)| *len); + let rest = held.split_off(used); + self.keys.push_front(rest); + let first = held.swap_remove(0); + + let action = match bound.map(|(_, binding)| binding) { + Some(Binding::Command(command)) => Action::Command(command), + Some(Binding::Function(InputFunction::SelfInsert)) | None => { + first.typed_char().map_or_else( + || Action::Unbound { + escape: first.is_escape(), + }, + Action::Insert, + ) + } + Some(Binding::Function(function)) => Action::Function(function), + Some(Binding::Macro(bytes)) => { + if self.macro_expansions < MAX_MACRO_EXPANSIONS { + self.macro_expansions += 1; + self.keys.push_front(keys::decode_all(&bytes)); + } + return Ok(None); + } + }; + Ok(Some(action)) + } +} + +/// Returns `terminal`'s width, or a usual width if it doesn't report one. +fn columns(terminal: &impl Terminal) -> usize { + terminal + .columns() + .filter(|&c| c > 0) + .unwrap_or(DEFAULT_COLUMNS) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in tests")] +mod tests { + use std::collections::VecDeque; + use std::time::Duration; + + use brush_core::interfaces::{Key, KeyAction, KeyBindings as _, KeySequence, KeyStroke}; + + use super::*; + + fn strings(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + /// What a test terminal sends, in turn. + enum Chunk { + /// Bytes, arriving together. + Data(Vec), + /// A pause: nothing more arrives while the reader waits. + Pause, + /// A read interrupted by a signal. + Interrupted, + } + + fn data(bytes: impl AsRef<[u8]>) -> Chunk { + Chunk::Data(bytes.as_ref().to_vec()) + } + + /// A terminal that sends the given chunks, and reports a width. + struct FakeTerminal { + columns: Option, + input: VecDeque, + } + + impl FakeTerminal { + fn new(input: impl IntoIterator) -> Self { + Self { + columns: Some(80), + input: input.into_iter().collect(), + } + } + } + + impl Terminal for FakeTerminal { + fn enable_raw_mode(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn disable_raw_mode(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn columns(&self) -> Option { + self.columns + } + + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + while let Some(chunk) = self.input.pop_front() { + match chunk { + Chunk::Data(bytes) => { + let count = bytes.len().min(buf.len()); + buf[..count].copy_from_slice(&bytes[..count]); + if count < bytes.len() { + self.input.push_front(Chunk::Data(bytes[count..].to_vec())); + } + return Ok(count); + } + Chunk::Pause => {} + Chunk::Interrupted => { + return Err(std::io::ErrorKind::Interrupted.into()); + } + } + } + Ok(0) + } + + fn wait_for_input(&mut self, _timeout: Duration) -> std::io::Result { + Ok(match self.input.front() { + Some(Chunk::Pause) => { + self.input.pop_front(); + false + } + Some(_) => true, + None => false, + }) + } + } + + /// How reading a program ended, comparably. + #[derive(Debug, PartialEq, Eq)] + enum Outcome { + Input(String), + Bound(String), + Eof, + Interrupted, + } + + impl From for Outcome { + fn from(result: ReadResult) -> Self { + match result { + ReadResult::Input(text) => Self::Input(text), + ReadResult::BoundCommand(command) => Self::Bound(command), + ReadResult::Eof => Self::Eof, + ReadResult::Interrupted => Self::Interrupted, + } + } + } + + fn input(text: &str) -> Outcome { + Outcome::Input(text.to_owned()) + } + + fn reader() -> LineReader { + LineReader::default() + } + + fn basic_reader() -> LineReader { + LineReader { + style: Style::Basic, + ..LineReader::default() + } + } + + async fn shell() -> crate::ShellRef { + let shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await + .unwrap(); + Arc::new(Mutex::new(shell)) + } + + /// A shell with the given history, oldest first. + async fn shell_with_history(entries: &[&str]) -> crate::ShellRef { + let mut shell = brush_core::Shell::builder() + .interactive(true) + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await + .unwrap(); + for entry in entries { + shell + .history_mut() + .unwrap() + .add(brush_core::history::Item::new(*entry)) + .unwrap(); + } + Arc::new(Mutex::new(shell)) + } + + /// A shell that completes the arguments of `cmd` from `words`. + async fn shell_completing(words: &str) -> crate::ShellRef { + let shell = shell().await; + shell.lock().await.completion_config_mut().set( + brush_core::completion::SpecName::Command("cmd"), + brush_core::completion::Spec { + word_list: Some(words.to_owned()), + ..Default::default() + }, + ); + shell + } + + /// Reads a program from the terminal sending `chunks`, with the prompt + /// `prompt`; returns how reading ended and what was drawn. + async fn read_with( + reader: &mut LineReader, + shell: &crate::ShellRef, + prompt: &str, + chunks: Vec, + ) -> (Outcome, String) { + let prompt = InteractivePrompt { + prompt: prompt.to_owned(), + continuation_prompt: "> ".to_owned(), + ..Default::default() + }; + let mut terminal = FakeTerminal::new(chunks); + let mut out = vec![]; + let result = reader + .read_program(shell, &prompt, &mut terminal, &mut out) + .await + .unwrap(); + (result.into(), String::from_utf8(out).unwrap()) + } + + /// Reads a program from `keys`, with a `$ ` prompt. + async fn read( + reader: &mut LineReader, + shell: &crate::ShellRef, + keys: impl AsRef<[u8]>, + ) -> (Outcome, String) { + read_with(reader, shell, "$ ", vec![data(keys)]).await + } + + /// Plays what was drawn on an 80-column terminal, translating newlines to CR + /// LF as terminals do in raw mode; returns the rows it then shows, up to the + /// last that isn't blank, and the cursor's row and column. + fn shown(drawn: &str) -> (Vec, (u16, u16)) { + let mut terminal = vt100::Parser::default(); + terminal.process(drawn.replace('\n', "\r\n").as_bytes()); + let screen = terminal.screen(); + let mut rows: Vec = screen + .rows(0, 80) + .map(|row| row.trim_end().to_owned()) + .collect(); + while rows.last().is_some_and(String::is_empty) { + rows.pop(); + } + (rows, screen.cursor_position()) + } + + #[tokio::test] + async fn highlights_input_and_leaves_terminal_styles_reset() { + let shell = shell().await; + let mut reader = LineReader { + highlighting: true, + ..reader() + }; + let (result, output) = read(&mut reader, &shell, "cmd '界' $HOME\r").await; + assert_eq!(result, input("cmd '界' $HOME\n")); + assert_eq!( + shown(&output), + (vec!["$ cmd '界' $HOME".to_owned()], (1, 0)) + ); + + let mut terminal = vt100::Parser::default(); + terminal.process(output.replace('\n', "\r\n").as_bytes()); + // Quoted text and parameters use the same palette as Reedline. + assert_eq!( + terminal.screen().cell(0, 6).unwrap().fgcolor(), + vt100::Color::Idx(3) + ); + assert_eq!( + terminal.screen().cell(0, 11).unwrap().fgcolor(), + vt100::Color::Idx(13) + ); + terminal.process(b"next"); + let next = terminal.screen().cell(1, 0).unwrap(); + assert_eq!(next.fgcolor(), vt100::Color::Default); + assert!(!next.bold()); + } + + #[tokio::test] + async fn highlights_continuations_in_the_context_of_the_program() { + let shell = shell().await; + let mut reader = LineReader { + highlighting: true, + ..reader() + }; + let (result, output) = read(&mut reader, &shell, "cmd 'first\r界'\r").await; + assert_eq!(result, input("cmd 'first\n界'\n")); + let mut terminal = vt100::Parser::default(); + terminal.process(output.replace('\n', "\r\n").as_bytes()); + assert_eq!( + terminal.screen().cell(1, 2).unwrap().fgcolor(), + vt100::Color::Idx(3) + ); + } + + #[tokio::test] + async fn basic_style_stays_plain_when_highlighting_is_enabled() { + let shell = shell().await; + let mut reader = LineReader { + highlighting: true, + ..basic_reader() + }; + let (result, output) = read(&mut reader, &shell, "cmd '$HOME'\r").await; + assert_eq!(result, input("cmd '$HOME'\n")); + assert!(!output.contains('\x1b')); + } + + fn ctrl(c: char) -> KeyStroke { + KeyStroke { + control: true, + ..Key::Character(c).into() + } + } + + async fn bind(reader: &LineReader, keys: Vec, action: KeyAction) { + reader + .bindings + .lock() + .await + .bind(KeySequence::Strokes(keys), action) + .unwrap(); + } + + async fn bind_command(reader: &LineReader, keys: Vec, command: &str) { + bind(reader, keys, KeyAction::ShellCommand(command.to_owned())).await; + } + + async fn define_macro(reader: &LineReader, keys: Vec, target: &[u8]) { + reader + .bindings + .lock() + .await + .define_macro( + KeySequence::Strokes(keys), + KeySequence::Bytes(vec![target.to_vec()]), + ) + .unwrap(); + } + + #[tokio::test] + async fn reads_an_edited_line() { + let (outcome, drawn) = read(&mut reader(), &shell().await, b"ech\x01x\x05o\r").await; + assert_eq!(outcome, input("xecho\n")); + assert_eq!(shown(&drawn), (strings(&["$ xecho"]), (1, 0))); + } + + #[tokio::test] + async fn reads_lines_until_the_program_is_complete() { + let (outcome, drawn) = read(&mut reader(), &shell().await, b"if true\rthen :\rfi\r").await; + assert_eq!(outcome, input("if true\nthen :\nfi\n")); + assert_eq!(shown(&drawn).0, ["$ if true", "> then :", "> fi"]); + } + + #[tokio::test] + async fn ends_at_the_end_of_input() { + let shell = shell().await; + assert_eq!(read(&mut reader(), &shell, b"").await.0, Outcome::Eof); + assert_eq!(read(&mut reader(), &shell, b"\x04").await.0, Outcome::Eof); + // What's been entered of an incomplete program is still run. + assert_eq!( + read(&mut reader(), &shell, b"if true\r").await.0, + input("if true\n") + ); + } + + #[tokio::test] + async fn ctrl_d_deletes_on_a_line_that_is_not_empty() { + let (outcome, _) = read(&mut reader(), &shell().await, b"ab\x02\x04\r").await; + assert_eq!(outcome, input("a\n")); + } + + #[tokio::test] + async fn interrupts_leaving_the_line_marked() { + let (outcome, drawn) = read(&mut reader(), &shell().await, b"abc\x02\x02\x03").await; + assert_eq!(outcome, Outcome::Interrupted); + assert_eq!(shown(&drawn), (strings(&["$ abc^C"]), (1, 0))); + } + + #[tokio::test] + async fn shows_the_earlier_lines_of_a_prompt_once() { + let prompt = "\x01\x1b[1m\x02info\x01\x1b[0m\x02\n$ "; + let (outcome, drawn) = + read_with(&mut reader(), &shell().await, prompt, vec![data(b"ab\r")]).await; + assert_eq!(outcome, input("ab\n")); + // Without the markers of non-printing text, once on a row of its own. + let drawn = drawn.trim_start_matches([' ', '\r']); + assert!(drawn.starts_with("\x1b[1minfo\x1b[0m\n"), "{drawn:?}"); + assert_eq!(drawn.matches("info").count(), 1, "{drawn:?}"); + } + + #[tokio::test] + async fn shows_all_of_a_prompt_when_drawing_the_line_afresh() { + let shell = shell_completing("alpha1 alpha2").await; + let prompt = "info\n$ "; + + // After listing candidates. + let (_, drawn) = read_with( + &mut reader(), + &shell, + prompt, + vec![data(b"cmd alpha\t\t\r")], + ) + .await; + assert_eq!( + shown(&drawn).0, + [ + "info", + "$ cmd alpha", + "alpha1 alpha2", + "info", + "$ cmd alpha" + ] + ); + + // After clearing the screen. + let (_, drawn) = read_with(&mut reader(), &shell, prompt, vec![data(b"x\x0c\r")]).await; + assert_eq!(shown(&drawn).0, ["info", "$ x"]); + let (_, drawn) = + read_with(&mut basic_reader(), &shell, prompt, vec![data(b"x\x0c\r")]).await; + assert!(!drawn.contains('\x1b'), "{drawn:?}"); + assert_eq!(drawn, "info\n$ x\ninfo\n$ x\n"); + } + + #[tokio::test] + async fn shows_control_characters_in_caret_notation() { + let shell = shell_with_history(&["for i in 1 2\ndo echo $i\ndone"]).await; + + // A multi-line history entry takes the row it's measured to. + let (outcome, drawn) = read_with(&mut reader(), &shell, "$ ", vec![data(b"\x1b[A")]).await; + assert_eq!(outcome, Outcome::Eof); + assert_eq!(shown(&drawn).0, ["$ for i in 1 2^Jdo echo $i^Jdone"]); + let (outcome, drawn) = read_with( + &mut reader(), + &shell, + "$ ", + vec![data(b"\x1b[A\x1b[B\x1b[A\x1b[Bx\r")], + ) + .await; + assert_eq!(outcome, input("x\n")); + assert_eq!(shown(&drawn), (strings(&["$ x"]), (1, 0))); + let (outcome, _) = read_with(&mut reader(), &shell, "$ ", vec![data(b"\x1b[A\r")]).await; + assert_eq!(outcome, input("for i in 1 2\ndo echo $i\ndone\n")); + + // Escape sequences in the line are shown, not acted on by the terminal. + let mut reader = reader(); + reader.next_line = Some(("a\tb\x1b[31mred".to_owned(), 3)); + let (_, drawn) = read(&mut reader, &shell, b"").await; + assert_eq!(shown(&drawn).0, ["$ a^Ib^[[31mred"]); + assert!(!drawn.contains("\x1b[31m"), "{drawn:?}"); + } + + #[tokio::test] + async fn decodes_keys_split_across_reads() { + let shell = shell().await; + let (outcome, _) = read_with( + &mut reader(), + &shell, + "$ ", + vec![data(b"\xe7"), data(b"\x95\x8c"), data(b"\r")], + ) + .await; + assert_eq!(outcome, input("界\n")); + + // The rest of an escape sequence that arrives later is still part of it. + let (outcome, _) = read_with( + &mut reader(), + &shell, + "$ ", + vec![data(b"ab"), data(b"\x1b"), data(b"[D"), data(b"x\r")], + ) + .await; + assert_eq!(outcome, input("axb\n")); + + // But after a pause, Escape is the Escape key, which does nothing, and what + // follows is typed. + let (outcome, _) = read_with( + &mut reader(), + &shell, + "$ ", + vec![data(b"ab\x1b"), Chunk::Pause, data(b"[Dx\r")], + ) + .await; + assert_eq!(outcome, input("ab[Dx\n")); + let (outcome, _) = read_with( + &mut reader(), + &shell, + "$ ", + vec![data(b"ab\x1bO"), Chunk::Pause, data(b"x\r")], + ) + .await; + assert_eq!(outcome, input("abx\n")); + } + + #[tokio::test] + async fn doesnt_swallow_keys_after_alt_bracket() { + let (outcome, _) = read(&mut reader(), &shell().await, b"ab\x1b[\rls\r").await; + assert_eq!(outcome, input("ab\n")); + } + + #[tokio::test] + async fn retries_interrupted_reads() { + let (outcome, _) = read_with( + &mut reader(), + &shell().await, + "$ ", + vec![Chunk::Interrupted, data(b"x\r")], + ) + .await; + assert_eq!(outcome, input("x\n")); + } + + #[tokio::test] + async fn starts_with_the_line_a_bound_command_left() { + let mut reader = reader(); + reader.next_line = Some(("echo x".to_owned(), 4)); + let (outcome, _) = read(&mut reader, &shell().await, b"y\r").await; + assert_eq!(outcome, input("echoy x\n")); + } + + #[tokio::test] + async fn runs_commands_bound_to_keys_with_the_line_set_aside() { + let mut reader = reader(); + bind_command(&reader, vec![ctrl('x')], "cmd").await; + let shell = shell().await; + + let (outcome, drawn) = read(&mut reader, &shell, b"ab\x02\x18").await; + assert_eq!(outcome, Outcome::Bound("cmd".to_owned())); + assert_eq!(reader.bound_line, Some(("ab".to_owned(), 1))); + assert_eq!(shown(&drawn), (vec![], (0, 0))); + } + + #[tokio::test] + async fn redraws_the_line_in_place_after_a_bound_command() { + let shell = shell().await; + let prompt = "info\n$ "; + + // The line's erased while the command runs, then drawn again where it was, + // below what the command wrote, if anything, and the rest of the prompt. + for (written, rows) in [ + ("", vec!["info", "$ ab"]), + ("out\n", vec!["info", "out", "$ ab"]), + ] { + let mut reader = reader(); + bind_command(&reader, vec![ctrl('x')], "cmd").await; + let (outcome, mut drawn) = + read_with(&mut reader, &shell, prompt, vec![data(b"ab\x18")]).await; + assert_eq!(outcome, Outcome::Bound("cmd".to_owned())); + + drawn.push_str(written); + reader.next_line = reader.bound_line.take(); + let (outcome, redrawn) = + read_with(&mut reader, &shell, prompt, vec![data(b"\r")]).await; + drawn.push_str(&redrawn); + assert_eq!(outcome, input("ab\n")); + assert_eq!(shown(&drawn).0, rows); + } + + // Without cursor addressing, the line's left as it was, and drawn afresh + // below. + let mut reader = basic_reader(); + bind_command(&reader, vec![ctrl('x')], "cmd").await; + let (_, mut drawn) = read_with(&mut reader, &shell, prompt, vec![data(b"ab\x18")]).await; + reader.next_line = reader.bound_line.take(); + let (_, redrawn) = read_with(&mut reader, &shell, prompt, vec![data(b"\r")]).await; + drawn.push_str(&redrawn); + assert_eq!(drawn, "info\n$ ab\ninfo\n$ ab\n"); + } + + #[tokio::test] + async fn runs_bound_commands_on_continuation_lines() { + let mut reader = reader(); + bind_command(&reader, vec![ctrl('x')], "cmd").await; + let shell = shell().await; + + // The command sees just the line it was run from, and the program goes on + // from there once it's run. + let (outcome, mut drawn) = read(&mut reader, &shell, b"if true; then\rf\x18").await; + assert_eq!(outcome, Outcome::Bound("cmd".to_owned())); + assert_eq!(reader.bound_line, Some(("f".to_owned(), 1))); + + reader.next_line = Some(("fi".to_owned(), 2)); + let (outcome, redrawn) = read(&mut reader, &shell, b"\r").await; + drawn.push_str(&redrawn); + assert_eq!(outcome, input("if true; then\nfi\n")); + assert_eq!(shown(&drawn).0, ["$ if true; then", "> fi"]); + + // What follows is a new program. + let (outcome, _) = read(&mut reader, &shell, b"x\r").await; + assert_eq!(outcome, input("x\n")); + } + + #[tokio::test] + async fn bound_commands_end_a_search_and_run() { + let mut reader = reader(); + bind_command(&reader, vec![ctrl('x')], "cmd").await; + let (outcome, _) = read_with( + &mut reader, + &shell_with_history(&["echo one"]).await, + "$ ", + vec![data(b"\x12one\x18")], + ) + .await; + assert_eq!(outcome, Outcome::Bound("cmd".to_owned())); + assert_eq!(reader.bound_line, Some(("echo one".to_owned(), 5))); + } + + #[tokio::test] + async fn performs_functions_bound_to_keys() { + let mut reader = reader(); + bind( + &reader, + vec![ctrl('x')], + KeyAction::DoInputFunction(InputFunction::BeginningOfLine), + ) + .await; + // A function this backend doesn't perform leaves the key doing nothing. + bind( + &reader, + vec![ctrl('t')], + KeyAction::DoInputFunction(InputFunction::Undo), + ) + .await; + // A key bound to self-insert types itself. + bind( + &reader, + vec![ctrl('v')], + KeyAction::DoInputFunction(InputFunction::SelfInsert), + ) + .await; + + let (outcome, _) = read(&mut reader, &shell().await, b"bc\x18a\x05\x14\x16\r").await; + assert_eq!(outcome, input("abc\n")); + } + + #[tokio::test] + async fn binds_keys_that_send_the_same_bytes_as_others() { + let mut reader = reader(); + // `bind '"\C-]": ...'`, and a key readline knows as Ctrl-H, not Backspace. + bind_command(&reader, vec![ctrl(']')], "bracket").await; + bind( + &reader, + vec![ctrl('h')], + KeyAction::DoInputFunction(InputFunction::BeginningOfLine), + ) + .await; + let shell = shell().await; + + let (outcome, _) = read(&mut reader, &shell, b"\x1d").await; + assert_eq!(outcome, Outcome::Bound("bracket".to_owned())); + let (outcome, _) = read(&mut reader, &shell, b"bc\x08a\x7f\r").await; + assert_eq!(outcome, input("bc\n")); + } + + #[tokio::test] + async fn expands_macros() { + let mut reader = reader(); + define_macro(&reader, vec![ctrl('x')], b"hi").await; + // A macro of keys that send escape sequences. + define_macro(&reader, vec![ctrl('o')], b"\x1b[H").await; + + let (outcome, _) = read(&mut reader, &shell().await, b"\x18 there\x0f>\r").await; + assert_eq!(outcome, input(">hi there\n")); + } + + #[tokio::test] + async fn stops_expanding_a_macro_that_expands_to_itself() { + let mut reader = reader(); + define_macro(&reader, vec![ctrl('x')], b"\x18").await; + let (outcome, _) = read(&mut reader, &shell().await, b"\x18a\r").await; + assert_eq!(outcome, input("a\n")); + } + + #[tokio::test] + async fn limits_how_deep_macros_expand_not_how_often() { + let mut reader = reader(); + define_macro(&reader, vec![ctrl('x')], b"a").await; + let keys = [vec![0x18; 150], b"\r".to_vec()].concat(); + let (outcome, _) = read(&mut reader, &shell().await, keys).await; + assert_eq!(outcome, input(&format!("{}\n", "a".repeat(150)))); + } + + #[tokio::test] + async fn waits_for_the_rest_of_a_bound_sequence() { + let mut reader = reader(); + reader + .bindings + .lock() + .await + .bind( + KeySequence::Bytes(vec![b"\x18".to_vec(), b"e".to_vec()]), + KeyAction::ShellCommand("edit".to_owned()), + ) + .unwrap(); + reader + .bindings + .lock() + .await + .bind( + KeySequence::Bytes(vec![b"\x18".to_vec(), b"\x18".to_vec(), b"x".to_vec()]), + KeyAction::ShellCommand("long".to_owned()), + ) + .unwrap(); + let shell = shell().await; + + let (outcome, _) = read(&mut reader, &shell, b"\x18e").await; + assert_eq!(outcome, Outcome::Bound("edit".to_owned())); + + // Keys that turn out not to be a bound sequence take effect as usual. + let (outcome, _) = read(&mut reader, &shell, b"\x18z\r").await; + assert_eq!(outcome, input("z\n")); + + // When a longer sequence falls through, what follows its first key may still + // be bound. + let (outcome, _) = read(&mut reader, &shell, b"\x18\x18e").await; + assert_eq!(outcome, Outcome::Bound("edit".to_owned())); + } + + #[tokio::test] + async fn waits_a_little_when_bound_keys_also_start_a_longer_sequence() { + let mut reader = reader(); + bind_command(&reader, vec![ctrl('x')], "short").await; + bind_command(&reader, vec![ctrl('x'), ctrl('e')], "long").await; + let shell = shell().await; + + let read_chunks = + async |reader: &mut LineReader, chunks| read_with(reader, &shell, "$ ", chunks).await.0; + assert_eq!( + read_chunks(&mut reader, vec![data(b"\x18\x05")]).await, + Outcome::Bound("long".to_owned()) + ); + assert_eq!( + read_chunks(&mut reader, vec![data(b"\x18"), data(b"\x05")]).await, + Outcome::Bound("long".to_owned()) + ); + assert_eq!( + read_chunks(&mut reader, vec![data(b"\x18"), Chunk::Pause]).await, + Outcome::Bound("short".to_owned()) + ); + // A key that doesn't continue the sequence comes after the shorter one's + // command. + assert_eq!( + read_chunks(&mut reader, vec![data(b"\x18z\r")]).await, + Outcome::Bound("short".to_owned()) + ); + } + + #[tokio::test] + async fn completes_a_single_candidate() { + let shell = shell_completing("alpha beta").await; + let (outcome, _) = read(&mut reader(), &shell, b"cmd be\t\r").await; + assert_eq!(outcome, input("cmd beta \n")); + } + + #[tokio::test] + async fn extends_ambiguous_words_then_lists_candidates() { + let shell = shell_completing("alpha1 alpha2 beta").await; + + let (outcome, drawn) = read(&mut reader(), &shell, b"cmd a\t\r").await; + assert_eq!(outcome, input("cmd alpha\n")); + assert!(!drawn.contains('\x07'), "{drawn:?}"); + + // With nothing to extend, the first Tab rings the bell and a second lists the + // candidates, below the line, which is then drawn again. + let (outcome, drawn) = read(&mut reader(), &shell, b"cmd alpha\t\t\r").await; + assert_eq!(outcome, input("cmd alpha\n")); + assert_eq!(drawn.matches('\x07').count(), 1, "{drawn:?}"); + assert_eq!( + shown(&drawn).0, + ["$ cmd alpha", "alpha1 alpha2", "$ cmd alpha"] + ); + + // Only a second Tab in a row lists them. + let (_, drawn) = read(&mut reader(), &shell, b"cmd alpha\tx\x7f\t\r").await; + assert!(!drawn.contains("alpha1"), "{drawn:?}"); + } + + #[tokio::test] + async fn lists_ambiguous_candidates_straight_away_in_basic_mode() { + let shell = shell_completing("alpha1 alpha2 beta").await; + let (outcome, drawn) = read(&mut basic_reader(), &shell, b"cmd a\t\r").await; + assert_eq!(outcome, input("cmd a\n")); + assert_eq!(drawn, "$ cmd a\nalpha1\nalpha2\n$ cmd a\n"); + } + + #[tokio::test] + async fn rings_the_bell_when_nothing_completes() { + let shell = shell_completing("alpha").await; + + let (outcome, drawn) = read(&mut reader(), &shell, b"cmd z\t\r").await; + assert_eq!(outcome, input("cmd z\n")); + assert!(drawn.contains('\x07'), "{drawn:?}"); + + let (_, drawn) = read(&mut basic_reader(), &shell, b"cmd z\t\r").await; + assert!(!drawn.contains('\x07'), "{drawn:?}"); + } + + #[tokio::test] + async fn keeps_what_was_killed_from_line_to_line() { + let mut reader = reader(); + let shell = shell().await; + read(&mut reader, &shell, b"echo word\x17\r").await; + let (outcome, _) = read(&mut reader, &shell, b"\x19\r").await; + assert_eq!(outcome, input("word\n")); + } + + #[test] + fn assumes_a_width_when_the_terminal_reports_none() { + let mut terminal = FakeTerminal::new([]); + for (reported, assumed) in [ + (Some(120), 120), + (Some(0), DEFAULT_COLUMNS), + (None, DEFAULT_COLUMNS), + ] { + terminal.columns = reported; + assert_eq!(columns(&terminal), assumed); + } + } + + #[tokio::test] + async fn edits_a_line_of_multibyte_characters() { + let (outcome, drawn) = read(&mut reader(), &shell().await, "é界\x02\x02x\x05!\r").await; + assert_eq!(outcome, input("xé界!\n")); + assert_eq!(shown(&drawn), (strings(&["$ xé界!"]), (1, 0))); + } + + #[tokio::test] + async fn searches_history_for_multibyte_text() { + let (outcome, drawn) = read_with( + &mut reader(), + &shell_with_history(&["échec", "naïve café", "ls"]).await, + "$ ", + vec![data("\x12café\x02!\r")], + ) + .await; + // The match is shown with the cursor at its start; Ctrl-B ends the search + // and moves back a character from there. + assert_eq!(outcome, input("naïve! café\n")); + assert!( + drawn.contains("(reverse-i-search)`café': naïve café"), + "{drawn:?}" + ); + } + + #[tokio::test] + async fn completes_multibyte_words() { + let shell = shell_completing("été étage 日本語").await; + let read_keys = async |keys: &str| read(&mut reader(), &shell, keys).await.0; + + // Candidates share `ét`, so Tab extends the word that far. + assert_eq!(read_keys("cmd é\t\r").await, input("cmd ét\n")); + assert_eq!(read_keys("cmd éta\t\r").await, input("cmd étage \n")); + assert_eq!(read_keys("cmd 日\t\r").await, input("cmd 日本語 \n")); + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/mod.rs b/userland/brush/source/brush-interactive/src/vt/mod.rs new file mode 100644 index 0000000..ef28100 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/mod.rs @@ -0,0 +1,18 @@ +//! A line-editing input backend that needs nothing from the terminal but bytes. +//! +//! Keys are decoded from standard input, and the line is drawn on standard error, +//! with VT100-style escape sequences or with backspaces alone. + +mod completion_list; +mod input_backend; +mod keymap; +mod keys; +mod line_buffer; +mod line_editor; +mod line_reader; +mod render; +mod style; +mod terminal; + +pub use input_backend::VtInputBackend; +pub use terminal::{StdTerminal, Terminal}; diff --git a/userland/brush/source/brush-interactive/src/vt/render.rs b/userland/brush/source/brush-interactive/src/vt/render.rs new file mode 100644 index 0000000..a74b9c1 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/render.rs @@ -0,0 +1,926 @@ +//! Drawing the prompt and the line being edited. + +use std::borrow::Cow; +use std::fmt::Write as _; +use std::io::Write; + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::style::Style; + +/// Draws a prompt and a line of input, as the [`Style`] has it drawn. +pub(crate) enum Renderer { + Vt(VtRenderer), + Backspace(BackspaceRenderer), +} + +impl Renderer { + pub fn new(style: Style) -> Self { + match style { + Style::Vt => Self::Vt(VtRenderer::default()), + Style::Basic => Self::Backspace(BackspaceRenderer::default()), + } + } + + /// Updates the styled text used by VT redraws. Basic redraws stay plain. + pub fn set_highlighting( + &mut self, + highlighted: &crate::highlighting::Highlighted<'_>, + start: usize, + ) { + if let Self::Vt(renderer) = self { + let mut styled = String::new(); + for span in highlighted + .spans() + .iter() + .filter(|span| span.range.end > start) + { + let text = highlighted + .line() + .get(span.range.start.max(start)..span.range.end) + .unwrap_or_default(); + // Escape controls before adding our own terminal sequences. + let (shown, _) = shown_text(text, text.len()); + let _ = write!( + styled, + "{}", + crate::styles::kind_to_style(span.kind).paint(shown) + ); + } + let line = highlighted.line().get(start..).unwrap_or_default(); + let (shown, _) = shown_text(line, line.len()); + renderer.highlighted = Some((shown.into_owned(), styled)); + } + } + + /// Draws `prompt` followed by `text`, and puts the cursor at byte offset `cursor` + /// of `text`. Replaces what was drawn last time, if anything. + /// + /// # Arguments + /// + /// * `out` - The terminal. + /// * `prompt` - The prompt, on a single line; may contain escape sequences, with + /// any other non-printing text between `\x01` and `\x02` markers. + /// * `text` - The line being edited. + /// * `cursor` - The cursor's byte offset in `text`. + /// * `columns` - The terminal's width. + pub fn draw( + &mut self, + out: &mut impl Write, + prompt: &str, + text: &str, + cursor: usize, + columns: usize, + ) -> std::io::Result<()> { + let (text, cursor) = shown_text(text, cursor); + match self { + Self::Vt(r) => r.draw(out, prompt, &text, cursor, columns), + Self::Backspace(r) => r.draw(out, prompt, &text, cursor), + } + } + + /// Redraws the line with the cursor at its end, then moves to the start of the + /// next row, leaving the line in place for whatever comes next. + pub fn finish( + &mut self, + out: &mut impl Write, + prompt: &str, + text: &str, + columns: usize, + ) -> std::io::Result<()> { + let (text, _) = shown_text(text, text.len()); + match self { + Self::Vt(r) => r.finish(out, prompt, &text, columns), + Self::Backspace(r) => r.finish(out, prompt, &text), + } + } + + /// Moves to the start of a row of its own to start drawing, as zsh does, so the + /// line doesn't overwrite output that didn't end with a newline. Without cursor + /// addressing, the line is drawn on from wherever the cursor is. + /// + /// # Arguments + /// + /// * `out` - The terminal. + /// * `columns` - The terminal's width. + pub fn start_on_own_row(&self, out: &mut impl Write, columns: usize) -> std::io::Result<()> { + match self { + Self::Vt(_) => VtRenderer::start_on_own_row(out, columns), + Self::Backspace(_) => Ok(()), + } + } + + /// Forgets what was drawn; the next draw starts afresh where the cursor is. + pub fn reset(&mut self) { + match self { + Self::Vt(r) => r.reset(), + Self::Backspace(r) => r.reset(), + } + } +} + +/// Draws with VT100-style escape sequences. +/// +/// Each change redraws the whole line, from the start of the prompt. That keeps +/// the logic simple and correct when the line wraps across rows, at a cost that's +/// negligible for lines people type. +#[derive(Default)] +pub(crate) struct VtRenderer { + /// How many rows the cursor is below the row the prompt starts on. + cursor_row: usize, + /// Plain display text paired with its styled form. Only reuse styling while + /// the text matches, including during completion lists and final redraws. + highlighted: Option<(String, String)>, +} + +impl VtRenderer { + /// Draws `prompt` followed by `text`, and puts the cursor at byte offset `cursor` + /// of `text`. Replaces what was drawn last time, if anything. + /// + /// # Arguments + /// + /// * `out` - The terminal. + /// * `prompt` - The prompt, on a single line; may contain escape sequences, with + /// any other non-printing text between `\x01` and `\x02` markers. + /// * `text` - The line being edited. + /// * `cursor` - The cursor's byte offset in `text`. + /// * `columns` - The terminal's width. + pub fn draw( + &mut self, + out: &mut impl Write, + prompt: &str, + text: &str, + cursor: usize, + columns: usize, + ) -> std::io::Result<()> { + let columns = columns.max(1); + let mut seq = String::new(); + + // Go back to where the prompt starts, and clear from there on. + move_up(&mut seq, self.cursor_row); + seq.push_str("\r\x1b[J"); + + seq.push_str(&printable(prompt)); + seq.push_str( + self.highlighted + .as_ref() + .filter(|(plain, _)| plain == text) + .map_or(text, |(_, styled)| styled.as_str()), + ); + + let shown_prompt = visible_text(prompt); + let (end_row, end_column) = + position_after(shown_prompt.chars().chain(text.chars()), columns); + if end_column == 0 && end_row > 0 { + // The line ends at the right margin, where terminals leave the cursor until + // something else is written; put it at the start of the next row, where + // `position_after` has it. + seq.push('\n'); + } + + let before_cursor = text.get(..cursor).unwrap_or_default(); + let (mut target_row, mut target_column) = + position_after(shown_prompt.chars().chain(before_cursor.chars()), columns); + // The char at the cursor may not fit on what's left of the row, in which case + // it's drawn at the start of the next; put the cursor on it there. + if let Some(c) = text.get(cursor..).and_then(|rest| rest.chars().next()) + && !fits(target_column, char_width(c), columns) + { + (target_row, target_column) = (target_row + 1, 0); + } + + move_up(&mut seq, end_row.saturating_sub(target_row)); + seq.push('\r'); + if target_column > 0 { + let _ = write!(seq, "\x1b[{target_column}C"); + } + + self.cursor_row = target_row; + out.write_all(seq.as_bytes())?; + out.flush() + } + + /// Redraws the line with the cursor at its end, then moves to the start of the + /// next row, leaving the line in place for whatever comes next. + pub fn finish( + &mut self, + out: &mut impl Write, + prompt: &str, + text: &str, + columns: usize, + ) -> std::io::Result<()> { + self.draw(out, prompt, text, text.len(), columns)?; + + // If the line ended at the right margin, the cursor is already on a new row. + let shown_prompt = visible_text(prompt); + let (row, column) = + position_after(shown_prompt.chars().chain(text.chars()), columns.max(1)); + if column > 0 || row == 0 { + out.write_all(b"\n")?; + } + + self.cursor_row = 0; + out.flush() + } + + /// Erases what was drawn, leaving the cursor where the prompt started, for the + /// next draw to start from. + pub fn erase(&mut self, out: &mut impl Write) -> std::io::Result<()> { + let mut seq = String::new(); + move_up(&mut seq, self.cursor_row); + seq.push_str("\r\x1b[J"); + + self.cursor_row = 0; + out.write_all(seq.as_bytes())?; + out.flush() + } + + /// Moves to the start of the next row, unless the cursor is at the start of one. + /// A row's worth of spaces from its first column leaves the cursor at the right + /// margin of that row, waiting to wrap, so a carriage return goes back to its + /// start; from any later column, they wrap onto the next row, leaving what was + /// before them in place. + pub fn start_on_own_row(out: &mut impl Write, columns: usize) -> std::io::Result<()> { + let mut seq = " ".repeat(columns.max(1)); + seq.push('\r'); + out.write_all(seq.as_bytes()) + } + + /// Forgets what was drawn; the next draw starts at the cursor's row. + pub const fn reset(&mut self) { + self.cursor_row = 0; + } +} + +/// Draws with backspaces and plain text alone, on a single row. +#[derive(Default)] +pub(crate) struct BackspaceRenderer { + /// What was drawn last, if anything since the last reset. + shown: Option, +} + +/// A line as drawn. +struct Shown { + prompt: String, + text: String, + /// The cursor's byte offset in `text`. + cursor: usize, +} + +#[allow( + clippy::string_slice, + reason = "offsets are at char boundaries of the text they index" +)] +impl BackspaceRenderer { + fn draw( + &mut self, + out: &mut impl Write, + prompt: &str, + text: &str, + cursor: usize, + ) -> std::io::Result<()> { + let mut seq = String::new(); + + // Only the cursor moved: move it there, over the text. + if let Some(shown) = &self.shown + && shown.prompt == prompt + && shown.text == text + { + if cursor <= shown.cursor { + push_backspaces(&mut seq, cells(&text[cursor..shown.cursor])); + } else { + seq.push_str(&text[shown.cursor..cursor]); + } + self.shown = Some(Shown { + prompt: prompt.to_owned(), + text: text.to_owned(), + cursor, + }); + out.write_all(seq.as_bytes())?; + return out.flush(); + } + + // Where to rewrite from, as a byte offset in `text`, and how many cells + // of the old line from there on are to be overwritten. + let (from, old_cells) = match &self.shown { + // Same prompt: rewrite from where the line changed, moving there from + // the cursor. + Some(shown) if shown.prompt == prompt => { + let from = unchanged_prefix(&shown.text, text); + let (shown_cursor, from_cells) = + (cells(&shown.text[..shown.cursor]), cells(&text[..from])); + if shown_cursor >= from_cells { + push_backspaces(&mut seq, shown_cursor - from_cells); + } else { + seq.push_str(&text[shown.cursor..from]); + } + (from, cells(&shown.text[from..])) + } + // A different prompt (e.g., searching history): rewrite the whole row. + Some(shown) => { + seq.push('\r'); + seq.push_str(&printable(prompt)); + let old = display_width(&shown.prompt) + cells(&shown.text); + (0, old.saturating_sub(display_width(prompt))) + } + None => { + seq.push_str(&printable(prompt)); + (0, 0) + } + }; + + // Rewrite the rest of the line, blank whatever's left of the old one, and + // move back to the cursor. + let rewritten = &text[from..]; + let blanks = old_cells.saturating_sub(cells(rewritten)); + seq.push_str(rewritten); + seq.push_str(&" ".repeat(blanks)); + push_backspaces(&mut seq, cells(&text[cursor..]) + blanks); + + self.shown = Some(Shown { + prompt: prompt.to_owned(), + text: text.to_owned(), + cursor, + }); + out.write_all(seq.as_bytes())?; + out.flush() + } + + /// Leaves the line as drawn (bringing it up to date if need be) and moves to + /// the next row from wherever the cursor is, as readline does on a terminal + /// like this. + fn finish(&mut self, out: &mut impl Write, prompt: &str, text: &str) -> std::io::Result<()> { + if self + .shown + .as_ref() + .is_none_or(|shown| shown.prompt != prompt || shown.text != text) + { + self.draw(out, prompt, text, text.len())?; + } + self.shown = None; + out.write_all(b"\n")?; + out.flush() + } + + fn reset(&mut self) { + self.shown = None; + } +} + +/// Returns how long a prefix `old` and `new` share, in bytes, short of any +/// character that a zero-width character after it may combine with. +fn unchanged_prefix(old: &str, new: &str) -> usize { + let mut len = old + .char_indices() + .zip(new.chars()) + .take_while(|((_, a), b)| a == b) + .last() + .map_or(0, |((i, c), _)| i + c.len_utf8()); + + let combines_at = |s: &str, at: usize| { + s.get(at..) + .and_then(|rest| rest.chars().next()) + .is_some_and(|c| c.width() == Some(0)) + }; + while len > 0 && (combines_at(old, len) || combines_at(new, len)) { + // Back up to the start of the char before. + len = old + .get(..len) + .and_then(|s| s.char_indices().next_back()) + .map_or(0, |(i, _)| i); + } + len +} + +/// Returns how many cells `text` takes on the terminal. +fn cells(text: &str) -> usize { + text.width() +} + +fn push_backspaces(seq: &mut String, count: usize) { + seq.extend(std::iter::repeat_n('\x08', count)); +} + +/// Returns where the cursor is, as a row and column, after writing `chars` from +/// the start of a row on a terminal `columns` wide. As on a terminal, a char that +/// doesn't fit on what's left of a row (e.g., a wide char in the last column) +/// starts the next row, and a row once full leaves the cursor at the start of the +/// next. +fn position_after(chars: impl Iterator, columns: usize) -> (usize, usize) { + let (mut row, mut column) = (0, 0); + for width in chars.map(char_width).filter(|&width| width > 0) { + if !fits(column, width, columns) { + (row, column) = (row + 1, 0); + } + column += width; + if column >= columns { + (row, column) = (row + 1, 0); + } + } + (row, column) +} + +/// Whether a char `width` cells wide fits at `column` of a row `columns` wide. A +/// char too wide for any row is still put at a row's start. +const fn fits(column: usize, width: usize, columns: usize) -> bool { + column == 0 || column + width <= columns +} + +/// Returns how many cells `c` takes on the terminal. +fn char_width(c: char) -> usize { + c.width().unwrap_or(0) +} + +fn move_up(seq: &mut String, rows: usize) { + if rows > 0 { + let _ = write!(seq, "\x1b[{rows}A"); + } +} + +/// Returns `text` as it's shown: with control chars in caret notation (e.g., `^J` +/// for a newline), as readline shows them, so that they take the cells they're +/// measured to and don't act on the terminal. Also returns `cursor`, a byte +/// offset in `text`, as one in what's returned. +fn shown_text(text: &str, cursor: usize) -> (Cow<'_, str>, usize) { + if !text.chars().any(char::is_control) { + return (Cow::Borrowed(text), cursor); + } + + let mut shown = String::with_capacity(text.len() + 8); + let mut shown_cursor = None; + for (index, c) in text.char_indices() { + if index == cursor { + shown_cursor = Some(shown.len()); + } + match u8::try_from(c) { + Ok(byte) if c.is_ascii_control() => { + shown.push('^'); + shown.push(char::from(byte ^ 0x40)); + } + _ if c.is_control() => shown.push(char::REPLACEMENT_CHARACTER), + _ => shown.push(c), + } + } + let shown_cursor = shown_cursor.unwrap_or(shown.len()); + (Cow::Owned(shown), shown_cursor) +} + +/// Returns `prompt` as it should be written: without the markers bracketing +/// non-printing text. +pub(crate) fn printable(prompt: &str) -> String { + prompt.replace(['\x01', '\x02'], "") +} + +/// Returns how many columns `s` takes on the terminal, leaving out escape +/// sequences and text marked as non-printing. +pub(crate) fn display_width(s: &str) -> usize { + visible_text(s).width() +} + +/// Returns the text of `s` that the terminal shows: without escape sequences, +/// text marked as non-printing, or other control chars. +fn visible_text(s: &str) -> String { + let mut visible = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + // Explicitly non-printing text, as marked in prompts with `\[` and `\]`. + '\x01' => { + for c in chars.by_ref() { + if c == '\x02' { + break; + } + } + } + '\x1b' => match chars.next() { + // A control sequence: parameters and intermediates, then a final byte. + Some('[') => { + for c in chars.by_ref() { + if ('\x40'..='\x7e').contains(&c) { + break; + } + } + } + // An operating system command (e.g., a window title), ended by BEL or + // ST (`ESC \`). + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' || (c == '\x1b' && chars.next_if_eq(&'\\').is_some()) { + break; + } + } + } + _ => {} + }, + c if c.is_control() => {} + c => visible.push(c), + } + } + visible +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in tests")] +mod tests { + use super::*; + + /// Returns a terminal `columns` wide to play what's drawn on. + fn terminal(columns: usize) -> vt100::Parser { + let columns = std::num::NonZeroU16::new(u16::try_from(columns).unwrap()).unwrap(); + vt100::Parser::new(vt100::Parser::DEFAULT_ROWS, columns, 0) + } + + /// Plays `output` on `terminal`, translating newlines to CR LF, as terminals do + /// in raw mode. + fn play(terminal: &mut vt100::Parser, output: &[u8]) { + let output = std::str::from_utf8(output).unwrap().replace('\n', "\r\n"); + terminal.process(output.as_bytes()); + } + + /// Returns the rows `terminal` shows, up to the last that isn't blank, and the + /// cursor's row and column. + fn shown(terminal: &vt100::Parser) -> (Vec, (usize, usize)) { + let screen = terminal.screen(); + let mut rows: Vec = screen + .rows(0, screen.size().1.get()) + .map(|row| row.trim_end().to_owned()) + .collect(); + while rows.last().is_some_and(String::is_empty) { + rows.pop(); + } + let (row, column) = screen.cursor_position(); + (rows, (usize::from(row), usize::from(column))) + } + + /// Starts lines below output that didn't end with a newline, and where output + /// that did end with one left the cursor. + #[test] + fn starts_lines_on_their_own_rows() -> std::io::Result<()> { + const COLUMNS: usize = 8; + for (output, expected) in [ + ("", vec!["$ ab"]), + ("done\n", vec!["done", "$ ab"]), + ("h", vec!["h", "$ ab"]), + ("hello", vec!["hello", "$ ab"]), + ("1234567", vec!["1234567", "$ ab"]), + ("12345678", vec!["12345678", "$ ab"]), + ] { + let mut terminal = terminal(COLUMNS); + play(&mut terminal, output.replace('\n', "\r\n").as_bytes()); + + let mut out = vec![]; + VtRenderer::start_on_own_row(&mut out, COLUMNS)?; + VtRenderer::default().draw(&mut out, "$ ", "ab", 2, COLUMNS)?; + play(&mut terminal, &out); + + let (rows, _) = shown(&terminal); + assert_eq!(rows, expected, "after {output:?}"); + } + Ok(()) + } + + /// Draws `text` after a prompt on a narrow screen, as a sequence of edits, and + /// checks the screen and cursor after each. + #[test] + fn redraws_in_place_across_wrapped_rows() -> std::io::Result<()> { + const COLUMNS: usize = 5; + let prompt = "\x01\x1b[1m\x02$\x1b[0m "; + let mut terminal = terminal(COLUMNS); + let mut renderer = VtRenderer::default(); + + // Each step is the text, the cursor's position in it, and the cursor's + // expected row and column. + for (text, cursor, row, column) in [ + ("", 0, 0, 2), + ("abc", 3, 1, 0), + ("abcdefg", 7, 1, 4), + ("abcdefgh", 8, 2, 0), + ("abcdefgh", 1, 0, 3), + ("abcd", 4, 1, 1), + ("a", 0, 0, 2), + ] { + let mut out = vec![]; + renderer.draw(&mut out, prompt, text, cursor, COLUMNS)?; + play(&mut terminal, &out); + + let (rows, position) = shown(&terminal); + assert_eq!( + rows.concat(), + format!("$ {text}").trim_end(), + "drawing {text:?}" + ); + assert_eq!(position, (row, column), "drawing {text:?}"); + } + + // Finishing leaves the line and moves to a fresh row. + let mut out = vec![]; + renderer.finish(&mut out, prompt, "abcdefgh", COLUMNS)?; + play(&mut terminal, &out); + let (rows, position) = shown(&terminal); + assert_eq!(rows.concat(), "$ abcdefgh"); + assert_eq!(position, (2, 0)); + + Ok(()) + } + + /// Returns the cell where a terminal that's `columns` wide draws the char after + /// `before`, wrapping as it does if that char doesn't fit on the row. For a char + /// that takes no cells (e.g., a combining accent), it's the cell where the next + /// char that does would go. + fn cell_after(before: &str, next: char, columns: usize) -> (usize, usize) { + let probe = if next.width().unwrap_or(0) == 0 { + 'X' + } else { + next + }; + let mut terminal = terminal(columns); + play(&mut terminal, format!("{before}{probe}").as_bytes()); + + // The probe is the last thing written, so it's in the last cell in use. + let screen = terminal.screen(); + let (rows, cols) = screen.size(); + (0..rows.get()) + .flat_map(|row| (0..cols.get()).map(move |col| (row, col))) + .rfind(|&(row, col)| { + screen + .cell(row, col) + .is_some_and(|cell| cell.has_contents() && !cell.is_wide_continuation()) + }) + .map(|(row, col)| (usize::from(row), usize::from(col))) + .unwrap() + } + + /// Draws lines of wide, narrow, and combining chars on narrow terminals, with + /// the cursor at each char in turn, and checks against a terminal emulator that + /// the line shows in full and the cursor is on the char it's at: where the + /// terminal draws that char, which for a wide char that doesn't fit on what's + /// left of a row is the start of the next. + #[test] + fn places_the_cursor_on_wide_and_combining_chars_across_rows() -> std::io::Result<()> { + for prompt in ["$ ", "界> ", "\x01\x1b[1m\x02$\x1b[0m "] { + for text in [ + "abc界d", + "界界界界", + "a界b界c", + "e\u{301}界x", + "日本語のテキスト", + ] { + for columns in 3..=9 { + let mut renderer = VtRenderer::default(); + let mut terminal = terminal(columns); + let shown_prompt = visible_text(prompt); + let case = format!("{prompt:?} {text:?} in {columns} columns"); + // The rows lose the spaces at their ends, including the prompt's + // when what follows it wraps; the texts have none of their own. + let expected = format!("{shown_prompt}{text}").replace(' ', ""); + let without_spaces = |rows: &[String]| rows.concat().replace(' ', ""); + + let boundaries = text.char_indices().map(|(i, _)| i).chain([text.len()]); + for cursor in boundaries { + let mut out = vec![]; + renderer.draw(&mut out, prompt, text, cursor, columns)?; + play(&mut terminal, &out); + + let (rows, position) = shown(&terminal); + assert_eq!(without_spaces(&rows), expected, "{case}"); + let (head, tail) = text.split_at(cursor); + let before = format!("{shown_prompt}{head}"); + let next = tail.chars().next().unwrap_or('X'); + assert_eq!( + position, + cell_after(&before, next, columns), + "{case}, cursor at {cursor}" + ); + } + + // Finishing leaves the line in place, and the cursor at the start + // of the row after it. + let mut out = vec![]; + renderer.finish(&mut out, prompt, text, columns)?; + play(&mut terminal, &out); + let (rows, position) = shown(&terminal); + assert_eq!(without_spaces(&rows), expected, "{case}"); + assert_eq!(position, (rows.len(), 0), "{case}"); + } + } + } + Ok(()) + } + + /// Draws lines in turn with backspaces alone, and checks that the terminal + /// shows each, with the cursor in place, however it differs from the last: + /// shorter, longer, changed mid-line, with chars that take two cells or none, + /// or with a different prompt. + #[test] + fn redraws_with_backspaces() -> std::io::Result<()> { + let mut renderer = BackspaceRenderer::default(); + let mut terminal = terminal(80); + + // Each step is the prompt, the text, and the cursor's position in it. + for (prompt, text, cursor) in [ + ("$ ", "", 0), + ("$ ", "cmd abcdef", 10), + ("$ ", "cmd b", 5), + ("$ ", "cmd b 界", 5), + ("$ ", "cmd xyz 界", 7), + ("$ ", "cmd xyz 界", 0), + ("$ ", "cmd e 界", 5), + ("$ ", "cmd e\u{301} 界", 7), + ("$ ", "cmd x 界", 5), + ("$ ", "cmd 界", 4), + ("$ ", "cmd 界x", 8), + ("(reverse-i-search)`x': ", "cmd 界x", 7), + ("$ ", "cmd", 3), + ] { + let mut out = vec![]; + renderer.draw(&mut out, prompt, text, cursor)?; + play(&mut terminal, &out); + + let (rows, position) = shown(&terminal); + assert_eq!( + rows.concat(), + format!("{prompt}{text}").trim_end(), + "drawing {text:?}" + ); + let column = cells(prompt) + text.get(..cursor).map_or(0, cells); + assert_eq!( + position, + (0, column), + "drawing {text:?} with cursor at {cursor}" + ); + } + + Ok(()) + } + + /// Moving the cursor, and finishing the line, write no more than they must: + /// what test harnesses expect, having been written for readline. + #[test] + fn moves_with_backspaces_and_finishes_in_place() -> std::io::Result<()> { + let mut renderer = BackspaceRenderer::default(); + let drawn = |renderer: &mut BackspaceRenderer, text: &str, cursor: usize| { + let mut out = vec![]; + renderer.draw(&mut out, "$ ", text, cursor).map(|()| out) + }; + + assert_eq!(drawn(&mut renderer, "alias foo", 9)?, b"$ alias foo"); + assert_eq!(drawn(&mut renderer, "alias foo", 8)?, b"\x08"); + assert_eq!(drawn(&mut renderer, "alias foo", 6)?, b"\x08\x08"); + assert_eq!(drawn(&mut renderer, "alias foo", 7)?, b"f"); + + let mut out = vec![]; + renderer.finish(&mut out, "$ ", "alias foo")?; + assert_eq!(out, b"\n"); + + Ok(()) + } + + #[test] + fn measures_only_visible_text() { + assert_eq!(display_width("\x01\x1b[1;32m\x02~/x\x01\x1b[0m\x02 $ "), 6); + assert_eq!(display_width("\x1b]0;title\x07> "), 2); + assert_eq!(display_width("\x1b]0;title\x1b\\> "), 2); + assert_eq!(display_width("界> "), 4); + // Other escapes take the char after them; other controls take no room. + assert_eq!(display_width("\x1b7\t> "), 2); + } + + /// Draws a sequence of lines with `renderer`, then finishes the last, and + /// returns what the terminal shows. + fn draw_all( + renderer: &mut Renderer, + lines: &[(&str, usize)], + finish: &str, + ) -> std::io::Result<(Vec, (usize, usize))> { + let mut out = vec![]; + for (text, cursor) in lines { + renderer.draw(&mut out, "$ ", text, *cursor, 80)?; + } + renderer.finish(&mut out, "$ ", finish, 80)?; + let mut terminal = terminal(80); + play(&mut terminal, &out); + Ok(shown(&terminal)) + } + + #[test] + fn draws_in_either_style() -> std::io::Result<()> { + for style in [Style::Vt, Style::Basic] { + let mut renderer = Renderer::new(style); + let (rows, position) = draw_all(&mut renderer, &[("ab", 2), ("abc", 1)], "abcd")?; + assert_eq!(rows, ["$ abcd"], "{style:?}"); + assert_eq!(position, (1, 0), "{style:?}"); + } + Ok(()) + } + + #[test] + fn starts_afresh_after_a_reset() -> std::io::Result<()> { + for style in [Style::Vt, Style::Basic] { + let mut renderer = Renderer::new(style); + let mut out = vec![]; + renderer.draw(&mut out, "$ ", "ab", 2, 80)?; + out.extend_from_slice(b"\n"); + renderer.reset(); + renderer.finish(&mut out, "$ ", "cd", 80)?; + + let mut terminal = terminal(80); + play(&mut terminal, &out); + assert_eq!(shown(&terminal).0, ["$ ab", "$ cd"], "{style:?}"); + } + Ok(()) + } + + #[test] + fn finishes_with_backspaces_by_drawing_what_changed() -> std::io::Result<()> { + let mut renderer = BackspaceRenderer::default(); + let mut out = vec![]; + renderer.draw(&mut out, "$ ", "ab", 1)?; + out.clear(); + renderer.finish(&mut out, "$ ", "abc")?; + assert_eq!(out, b"bc\n"); + + // With nothing drawn yet, it draws the whole line. + out.clear(); + renderer.finish(&mut out, "$ ", "x")?; + assert_eq!(out, b"$ x\n"); + Ok(()) + } + + #[test] + fn shows_control_characters_in_caret_notation() -> std::io::Result<()> { + assert_eq!(shown_text("plain", 2), (Cow::Borrowed("plain"), 2)); + assert_eq!( + shown_text("a\nb\t\x1b[m\x7f\u{85}é", 2), + (Cow::Owned("a^Jb^I^[[m^?\u{FFFD}é".to_owned()), 3) + ); + + // Both styles draw them so, with the cursor after them where it should be. + for style in [Style::Vt, Style::Basic] { + let mut renderer = Renderer::new(style); + let mut out = vec![]; + renderer.draw(&mut out, "$ ", "ab\ncd", 4, 80)?; + let mut terminal = terminal(80); + play(&mut terminal, &out); + assert_eq!( + shown(&terminal), + (vec!["$ ab^Jcd".to_owned()], (0, 7)), + "{style:?}" + ); + } + Ok(()) + } + + /// Styling must not change wrapping, cursor placement, or the safe display + /// of control characters, even when escape bytes appear in quoted input. + #[tokio::test] + async fn highlighted_text_preserves_layout_and_escapes_controls() -> std::io::Result<()> { + let shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await + .unwrap(); + for text in ["cmd '界e\u{301}' $HOME", "cmd '\x1b[31m\t\n'"] { + for columns in [5, 8, 80] { + for cursor in text.char_indices().map(|(i, _)| i).chain([text.len()]) { + let highlighted = crate::highlighting::highlight_command(&shell, text, cursor); + let mut plain = Renderer::new(Style::Vt); + let mut colored = Renderer::new(Style::Vt); + colored.set_highlighting(&highlighted, 0); + let mut plain_out = vec![]; + let mut colored_out = vec![]; + plain.draw(&mut plain_out, "$ ", text, cursor, columns)?; + colored.draw(&mut colored_out, "$ ", text, cursor, columns)?; + let mut plain_terminal = terminal(columns); + let mut colored_terminal = terminal(columns); + play(&mut plain_terminal, &plain_out); + play(&mut colored_terminal, &colored_out); + assert_eq!(shown(&colored_terminal), shown(&plain_terminal)); + // Finishing reuses the styling and places the cursor correctly. + plain.finish(&mut plain_out, "$ ", text, columns)?; + colored.finish(&mut colored_out, "$ ", text, columns)?; + let mut plain_terminal = terminal(columns); + let mut colored_terminal = terminal(columns); + play(&mut plain_terminal, &plain_out); + play(&mut colored_terminal, &colored_out); + assert_eq!(shown(&colored_terminal), shown(&plain_terminal)); + } + } + } + Ok(()) + } + + #[test] + fn treats_a_zero_width_terminal_as_one_column_wide() -> std::io::Result<()> { + let mut renderer = VtRenderer::default(); + let mut out = vec![]; + renderer.draw(&mut out, "", "ab", 1, 0)?; + let mut terminal = terminal(1); + play(&mut terminal, &out); + assert_eq!( + shown(&terminal), + (vec!["a".to_owned(), "b".to_owned()], (1, 0)) + ); + Ok(()) + } +} diff --git a/userland/brush/source/brush-interactive/src/vt/style.rs b/userland/brush/source/brush-interactive/src/vt/style.rs new file mode 100644 index 0000000..e2ccfda --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/style.rs @@ -0,0 +1,20 @@ +//! How a [`super::VtInputBackend`] draws and responds to Tab. + +/// How a [`super::VtInputBackend`] shows the line being edited and responds to +/// Tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Style { + /// For people at a terminal that understands VT100-style escape sequences. + /// Each change redraws the line with them, which handles lines that wrap + /// across rows. As in readline, Tab extends an ambiguous word as far as the + /// candidates agree; if it can't, it rings the bell, and a second Tab lists + /// the candidates in columns. + #[default] + Vt, + /// For programs that drive the shell and read what it prints, such as test + /// harnesses. The line is drawn with backspaces alone, rewriting just what + /// changed, so a line that wraps across rows isn't redrawn correctly; Tab + /// lists ambiguous completions straight away, one per line; and the bell + /// never rings. + Basic, +} diff --git a/userland/brush/source/brush-interactive/src/vt/terminal.rs b/userland/brush/source/brush-interactive/src/vt/terminal.rs new file mode 100644 index 0000000..a223810 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/vt/terminal.rs @@ -0,0 +1,162 @@ +//! The terminal a [`super::VtInputBackend`] edits on. + +use std::io::{IsTerminal, Read}; +use std::ops::{Deref, DerefMut}; +use std::time::Duration; + +/// What a [`super::VtInputBackend`] needs from its terminal, beyond the shell's +/// standard error, where it draws. +/// +/// Hosts that can't control the terminal through the platform (e.g., a browser +/// page running the shell as WebAssembly) provide their own implementation. +pub trait Terminal: Send { + /// Switches the terminal to raw mode for line editing: input arrives a key at + /// a time, without being echoed or interpreted. Output is still translated as + /// usual, so a newline (`\n`) moves to the start of the next line. + fn enable_raw_mode(&mut self) -> std::io::Result<()>; + + /// Restores the mode the terminal was in before [`Self::enable_raw_mode`]. + fn disable_raw_mode(&mut self) -> std::io::Result<()>; + + /// Returns the terminal's width, in columns, if known. + fn columns(&self) -> Option; + + /// Whether there's a terminal to edit on. If not, lines are read as they come, + /// without editing. By default, whether standard input is a terminal. + fn is_terminal(&self) -> bool { + std::io::stdin().is_terminal() + } + + /// Reads what the terminal sends as keys are pressed. By default, from + /// standard input. + /// + /// # Arguments + /// + /// * `buf` - Where to put what's read. + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + std::io::stdin().read(buf) + } + + /// Waits up to `timeout` for the terminal to send more; returns whether it did. + /// This tells a key that sends several bytes, which may arrive separately, from + /// keys pressed one after another. By default, it doesn't wait, and reports that + /// nothing came. + /// + /// # Arguments + /// + /// * `timeout` - How long to wait. + fn wait_for_input(&mut self, timeout: Duration) -> std::io::Result { + let _ = timeout; + Ok(false) + } +} + +/// A [`Terminal`] in raw mode, from [`Self::enable`] until [`Self::restore`]. If +/// it's dropped first (e.g., when reading a line is cancelled), the terminal's +/// mode is restored then, and any error in restoring it is ignored. +pub(crate) struct RawMode<'a, T: Terminal> { + terminal: &'a mut T, + restored: bool, +} + +impl<'a, T: Terminal> RawMode<'a, T> { + /// Switches `terminal` to raw mode. + /// + /// # Arguments + /// + /// * `terminal` - The terminal to switch. + pub fn enable(terminal: &'a mut T) -> std::io::Result { + terminal.enable_raw_mode()?; + Ok(Self { + terminal, + restored: false, + }) + } + + /// Restores the mode the terminal was in before. + pub fn restore(mut self) -> std::io::Result<()> { + self.terminal.disable_raw_mode()?; + self.restored = true; + Ok(()) + } +} + +impl Deref for RawMode<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + self.terminal + } +} + +impl DerefMut for RawMode<'_, T> { + fn deref_mut(&mut self) -> &mut T { + self.terminal + } +} + +impl Drop for RawMode<'_, T> { + fn drop(&mut self) { + if !self.restored { + let _ = self.terminal.disable_raw_mode(); + } + } +} + +/// A [`Terminal`] for the process's own controlling terminal, controlled through +/// the platform's terminal APIs. Only on Unix can it switch to raw mode; +/// elsewhere, [`Terminal::enable_raw_mode`] fails. +#[derive(Default)] +pub struct StdTerminal { + mode: Option, +} + +impl Terminal for StdTerminal { + fn enable_raw_mode(&mut self) -> std::io::Result<()> { + // Only a Unix terminal can be set to send keys as bytes, unechoed: the + // Windows console sends them only in a mode brush doesn't set, and WASI + // has no raw mode. Fail, rather than edit with the terminal echoing keys. + if !cfg!(unix) { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "the terminal can't be switched to raw mode on this platform", + )); + } + + // Restore any mode set before, so it isn't taken for the original one. + self.mode = None; + let mode = brush_core::terminal::AutoModeGuard::new(std::io::stdin().into()) + .map_err(std::io::Error::other)?; + + // Raw mode, as readline sets it, with Enter and Ctrl-J told apart, and a + // read waiting for a byte; but with newlines output as CRLF pairs, as the + // drawing expects. + let settings = brush_core::terminal::Settings::builder() + .raw_input(true) + .output_nl_as_nlcr(true) + .build(); + mode.apply_settings(&settings) + .map_err(std::io::Error::other)?; + + self.mode = Some(mode); + Ok(()) + } + + fn disable_raw_mode(&mut self) -> std::io::Result<()> { + // Dropping the guard restores the original mode. + self.mode = None; + Ok(()) + } + + fn columns(&self) -> Option { + brush_core::sys::terminal::get_columns(&std::io::stdin().into()) + } + + fn wait_for_input(&mut self, timeout: Duration) -> std::io::Result { + match brush_core::sys::poll::poll_for_input(&std::io::stdin().into(), timeout) { + // Where the platform can't wait for input, don't. + Err(e) if e.kind() == std::io::ErrorKind::Unsupported => Ok(false), + result => result, + } + } +} diff --git a/userland/brush/source/brush-interactive/src/zsh_hooks.rs b/userland/brush/source/brush-interactive/src/zsh_hooks.rs new file mode 100644 index 0000000..d344875 --- /dev/null +++ b/userland/brush/source/brush-interactive/src/zsh_hooks.rs @@ -0,0 +1,350 @@ +//! A bash-native adaptation of zsh's `precmd`/`preexec` hooks. +//! +//! zsh defines the hooks; [bash-preexec] is the adaptation of them to bash that the ecosystem +//! actually builds on, so it is what settles the details zsh leaves open for a bash shell. +//! brush implements the hooks in the shell itself rather than in shell script, and +//! interoperates with bash-preexec only as far as an interlock needs to: it claims +//! bash-preexec's inclusion guards, so a copy sourced later stands down instead of layering +//! its `DEBUG`-trap emulation on top of the real thing. +//! +//! Setup ([`init`]) runs once, before the shell loads its profile and rc files; dispatch +//! ([`run_precmd`] and [`run_preexec`]) runs from the interactive loop. The contract as a +//! whole is documented in `docs/reference/zsh-hooks.md`. +//! +//! [bash-preexec]: https://github.com/rcaloras/bash-preexec + +use std::ops::ControlFlow; + +use brush_core::variables::ShellValueLiteral; + +use crate::InteractiveOptions; +use crate::ShellError; + +/// The registry naming the hooks that run before each prompt. +const PRECMD_FUNCTIONS: &str = "precmd_functions"; + +/// The registry naming the hooks that run before each command line. +const PREEXEC_FUNCTIONS: &str = "preexec_functions"; + +/// The bash-preexec inclusion guards, current and legacy. Sourcing bash-preexec is a no-op +/// while either holds a non-empty value, so brush claims both. +const BASH_PREEXEC_GUARDS: [&str; 2] = ["bash_preexec_imported", "__bp_imported"]; + +/// Prepares a shell for zsh-style `precmd`/`preexec` hooks. +/// +/// Seeds the hook registries and claims the [bash-preexec] inclusion guards, so a copy sourced +/// later stands down and leaves the hooks to us. Documented in `docs/reference/zsh-hooks.md`. +/// +/// Call before profile and rc files load, so they observe the state -- and only for a shell +/// that will go on to read commands interactively, since only such a shell ever reaches +/// dispatch and so only such a shell may claim the contract. Whether the hooks are wanted at +/// all is decided here, by the same predicate dispatch uses, so the two cannot disagree. +/// +/// A rejected assignment is fatal: a contract claimed only partway advertises hooks brush +/// never registered. +/// +/// [bash-preexec]: https://github.com/rcaloras/bash-preexec +/// +/// # Arguments +/// +/// * `shell` - The shell to prepare. +/// * `options` - The options the interactive loop will run with. +pub fn init( + shell: &mut brush_core::Shell, + options: &InteractiveOptions, +) -> Result<(), ShellError> { + if !enabled(shell, options) { + return Ok(()); + } + + // No export attribute is added, so a child bash still gets the real thing. + for guard_name in BASH_PREEXEC_GUARDS { + assign_global( + shell, + guard_name, + ShellValueLiteral::Scalar("defined".into()), + )?; + } + + // Seeded with the bare hook names, as bash-preexec seeds them -- but by assignment, not + // `+=`, because this runs before rc files rather than after them. + for (array_name, hook_name) in [(PRECMD_FUNCTIONS, "precmd"), (PREEXEC_FUNCTIONS, "preexec")] { + let value = ShellValueLiteral::from(vec![hook_name]); + assign_global(shell, array_name, value)?; + } + + Ok(()) +} + +/// Runs the `precmd` hooks. Breaks with the result of one that exited the shell. +/// +/// # Arguments +/// +/// * `shell` - The shell to run the hooks in. +/// * `options` - The options the interactive loop is running with. +pub(crate) async fn run_precmd( + shell: &mut brush_core::Shell, + options: &InteractiveOptions, +) -> Result, ShellError> { + let Some(hook_names) = pending_hooks(shell, options, PRECMD_FUNCTIONS) else { + return Ok(ControlFlow::Continue(())); + }; + + dispatch(shell, PRECMD_FUNCTIONS, hook_names, None).await +} + +/// Runs the `preexec` hooks for a line the user entered. Breaks with the result of one that +/// exited the shell. +/// +/// # Arguments +/// +/// * `shell` - The shell to run the hooks in. +/// * `options` - The options the interactive loop is running with. +/// * `command_line` - The line as entered by the user; passed to each hook as `$1`. +pub(crate) async fn run_preexec( + shell: &mut brush_core::Shell, + options: &InteractiveOptions, + command_line: &str, +) -> Result, ShellError> { + // The registry is consulted ahead of `line_runs_a_command`, which parses: with no hook to + // run there is nothing to decide, and every typed line would pay for the answer. + let Some(hook_names) = pending_hooks(shell, options, PREEXEC_FUNCTIONS) else { + return Ok(ControlFlow::Continue(())); + }; + + if !line_runs_a_command(shell, command_line) { + return Ok(ControlFlow::Continue(())); + } + + dispatch( + shell, + PREEXEC_FUNCTIONS, + hook_names, + Some(command_line.trim_end_matches('\n')), + ) + .await +} + +/// Whether zsh-style hooks are live for this shell: enabled by the options, and interactive +/// in the `$-` sense (`script | brush -s` reads commands through the interactive loop but +/// isn't). Setup and dispatch share this so they can't disagree. +fn enabled( + shell: &brush_core::Shell, + options: &InteractiveOptions, +) -> bool { + options.zsh_style_hooks && shell.options().interactive +} + +/// The entries `array_var_name` names, read once as `"${name[@]}"` would expand it -- or +/// `None` when a dispatch would run nothing, so the caller can skip the rest of its work. +/// The registries are seeded non-empty, so an entry naming a `precmd`/`preexec` nobody +/// defined is the ordinary case, not an unusual one. +fn pending_hooks( + shell: &brush_core::Shell, + options: &InteractiveOptions, + array_var_name: &str, +) -> Option> { + if !enabled(shell, options) { + return None; + } + + // Reads a shell variable as the list of words `"${name[@]}"` would expand to. An unset + // variable yields nothing; a scalar yields itself, since a scalar *is* element 0 in bash. + let hook_names = shell + .env_var(array_var_name) + .map(|var| var.value().element_values(shell)) + .unwrap_or_default(); + + hook_names + .iter() + .any(|name| shell.funcs().get(name).is_some()) + .then_some(hook_names) +} + +/// Whether the line runs any command: blank, comment-only, unparsable, and +/// function-definition-only lines run none, and so dispatch no `preexec` -- matching the +/// `DEBUG` trap bash-preexec dispatches from, which does not fire for a line that only +/// defines a function. Parsing is what answers this; a text test for "blank or all comment" +/// would still miss the syntax error. So a shell with a `preexec` hook registered pays a +/// parse of the line here and another in `run_string` moments later. (`parse_string` is +/// cached, which often makes the second one free, but it's a bounded cache and nothing here +/// should lean on a hit.) +fn line_runs_a_command( + shell: &brush_core::Shell, + line: &str, +) -> bool { + shell + .parse_string(line) + .is_ok_and(|program| program_runs_a_command(&program)) +} + +/// Whether `program` runs any command rather than only defining functions: a line like +/// `other() { ...; }` produces a non-empty [`Program`](brush_parser::ast::Program), but no +/// command in it ever runs, so it must not count as running one -- while a line that defines +/// a function *and* runs another command still does. +fn program_runs_a_command(program: &brush_parser::ast::Program) -> bool { + program + .complete_commands + .iter() + .flat_map(|complete_command| complete_command.0.iter()) + .flat_map(|item| item.0.iter()) + .flat_map(|(_operator, pipeline)| pipeline.seq.iter()) + .any(|command| !matches!(command, brush_parser::ast::Command::Function(_))) +} + +/// Invokes the named hooks. Each sees what the last command left in `$?`, `PIPESTATUS`, and +/// `$_`, restored before every hook and again afterwards. Breaks with the result of a hook +/// that exited the shell. The full contract is in `docs/reference/zsh-hooks.md`. +/// +/// # Arguments +/// +/// * `shell` - The shell to run the hooks in. +/// * `array_var_name` - The registry the names came from; used only in diagnostics. +/// * `hook_names` - The registry's entries, already read. +/// * `arg` - Optional argument to pass to each hook. +async fn dispatch( + shell: &mut brush_core::Shell, + array_var_name: &str, + hook_names: Vec, + arg: Option<&str>, +) -> Result, ShellError> { + let saved_status = shell.save_command_status(); + + for hook_name in hook_names { + // Asked here rather than inferred from a `FunctionNotFound` error, which a hook body + // could raise on its own. Asked per entry, not once up front, so a hook that defines + // a later one in the same dispatch is honored -- as it is under bash-preexec, whose + // `type -t` guard also sits inside the loop. + if shell.funcs().get(&hook_name).is_none() { + tracing::debug!("{array_var_name}: skipping '{hook_name}'; not a function"); + continue; + } + + // Each hook sees the same status, whatever an earlier hook ran. + shell.restore_command_status(saved_status.clone()); + + // A failing hook is reported and the rest still run. (PROMPT_COMMAND differs: an + // error that escapes `run_string` there ends the interactive loop.) + match shell + .invoke_function(&hook_name, arg, shell.default_exec_params()) + .await + { + Ok(mut result) => { + // `invoke_function` returns straight from the function call, without + // passing through the statement-boundary check (`interp.rs`) that + // ordinarily applies errexit; a hook that ends with `return N` reaches + // here as normal, non-exiting flow even under `set -e`. Apply it here so + // such a hook is treated the same as one that fails via a plain failing + // command, which already hits that boundary inside its own body. + shell.apply_errexit_if_enabled(&mut result); + + // Returned without restoring: the shell is exiting, and `$?` belongs to + // the hook that exited it. + if result.is_exit() { + return Ok(ControlFlow::Break(result)); + } + } + Err(e) => { + let mut stderr = shell.stderr(); + let _ = shell.display_error(&mut stderr, &e); + } + } + } + + shell.restore_command_status(saved_status); + + Ok(ControlFlow::Continue(())) +} + +/// Assigns to a global variable as `name=value` would: an existing variable keeps its +/// attributes, and a readonly one rejects the assignment with an error. +fn assign_global( + shell: &mut brush_core::Shell, + name: &str, + value: ShellValueLiteral, +) -> Result<(), brush_core::Error> { + shell.env_mut().update_or_add( + name, + value, + |_| Ok(()), + brush_core::env::EnvironmentLookup::Anywhere, + brush_core::env::EnvironmentScope::Global, + ) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")] +mod tests { + use super::*; + + /// Builds a shell that zsh-style hooks would dispatch for, with `guard_readonly` marked + /// readonly beforehand so the assignments in [`init`] must fail. + async fn shell_with_readonly( + interactive: bool, + guard_readonly: &str, + ) -> Result, ShellError> { + let mut shell = brush_core::Shell::builder() + .interactive(interactive) + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build() + .await?; + + let mut var = brush_core::ShellVariable::new("inherited"); + var.set_readonly(); + shell.env_mut().set_global(guard_readonly, var)?; + + Ok(shell) + } + + fn hooks_enabled() -> InteractiveOptions { + InteractiveOptions { + zsh_style_hooks: true, + ..Default::default() + } + } + + /// Claiming the contract only partway would advertise hooks brush never registered, so + /// all four assignments are load-bearing and a rejection is fatal. Unit-tested because + /// only an embedder assigning before us can reach this state. The error kind is pinned + /// too: a bare `is_err()` would also pass on a panic or an unrelated failure. + #[tokio::test] + async fn init_fails_if_any_state_is_readonly() -> Result<(), ShellError> { + for name in [ + "bash_preexec_imported", + "__bp_imported", + PRECMD_FUNCTIONS, + PREEXEC_FUNCTIONS, + ] { + let mut shell = shell_with_readonly(true, name).await?; + let result = init(&mut shell, &hooks_enabled()); + assert!( + matches!(&result, Err(ShellError::ShellError(e)) + if matches!(e.kind(), brush_core::ErrorKind::ReadonlyVariable)), + "readonly '{name}' should have failed initialization as a readonly \ + violation; got: {result:?}" + ); + } + + Ok(()) + } + + /// A shell the hooks won't dispatch for is left completely alone -- the readonly state + /// that would otherwise fail the assignments proves nothing was written. (A shell that + /// never reads commands interactively is the caller's business, and the `-c` and script + /// cases are covered end to end by `tests/cases/brush/zsh_hooks.yaml`.) + #[tokio::test] + async fn init_is_inert_when_hooks_are_off() -> Result<(), ShellError> { + // Enabled, but the shell isn't interactive in the `$-` sense. + let mut shell = shell_with_readonly(false, "bash_preexec_imported").await?; + init(&mut shell, &hooks_enabled())?; + assert!(shell.env_var(PRECMD_FUNCTIONS).is_none()); + + // Interactive, but the option is off. + let mut shell = shell_with_readonly(true, "bash_preexec_imported").await?; + init(&mut shell, &InteractiveOptions::default())?; + assert!(shell.env_var(PRECMD_FUNCTIONS).is_none()); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-parser/Cargo.toml b/userland/brush/source/brush-parser/Cargo.toml new file mode 100644 index 0000000..c5671c3 --- /dev/null +++ b/userland/brush/source/brush-parser/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "brush-parser" +description = "POSIX/bash shell tokenizer and parsers (used by brush-shell)" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[lib] +bench = false + +[features] +arbitrary = ["dep:arbitrary"] +debug-tracing = ["peg/trace"] +diagnostics = ["dep:miette"] +serde = ["dep:serde"] +winnow-parser = ["dep:winnow"] + +[dependencies] +arbitrary = { version = "1.4.2", optional = true, features = ["derive"] } +bon = "3.9.1" +cached = "2.0.2" +indenter = "0.3.4" +miette = { version = "7.6.0", optional = true, default-features = false, features = [ + "derive", +] } +peg = "0.8.6" +serde = { version = "1.0.228", optional = true, features = ["derive", "rc"] } +thiserror = "2.0.18" +tracing = "0.1.44" +utf8-chars = "3.0.6" +winnow = { version = "1.0.0", optional = true } + +[target.wasm32-unknown-unknown.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } +uuid = { version = "1", features = ["js"] } + +[dev-dependencies] +anyhow = "1.0.102" +criterion = { version = "0.8.2", features = ["html_reports"] } +insta = { version = "1.47.1", features = ["glob", "ron", "yaml", "redactions"] } +miette = { version = "7.6.0", features = ["fancy"] } +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } +serde_json = "1.0.149" +yaml_serde = "0.10.7" + +[[bench]] +name = "parser" +harness = false + +[[example]] +name = "miette" +required-features = ["diagnostics"] + +[[example]] +name = "serde" +required-features = ["serde"] diff --git a/userland/brush/source/brush-parser/LICENSE b/userland/brush/source/brush-parser/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-parser/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-parser/benches/parser.rs b/userland/brush/source/brush-parser/benches/parser.rs new file mode 100644 index 0000000..3b7af52 --- /dev/null +++ b/userland/brush/source/brush-parser/benches/parser.rs @@ -0,0 +1,280 @@ +//! Benchmarks for the brush-parser crate. +//! +//! Compares parsing approaches: +//! 1. PEG parser (tokenize + peg parse) +//! 2. `Winnow_str` parser (direct string parse) - when winnow-parser feature enabled + +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +#[cfg(unix)] +mod unix { + use brush_parser::Token; + use criterion::Criterion; + + fn uncached_tokenize(content: &str) -> Vec { + brush_parser::uncached_tokenize_str(content, &brush_parser::TokenizerOptions::default()) + .unwrap() + } + + fn cacheable_tokenize(content: &str) -> Vec { + brush_parser::tokenize_str_with_options(content, &brush_parser::TokenizerOptions::default()) + .unwrap() + } + + fn parse_peg(tokens: &[Token]) -> brush_parser::ast::Program { + brush_parser::parse_tokens(tokens, &brush_parser::ParserOptions::default()).unwrap() + } + + #[cfg(feature = "winnow-parser")] + fn parse_winnow_str(content: &str) -> brush_parser::ast::Program { + use brush_parser::{ParserOptions, SourceInfo, winnow_str}; + winnow_str::parse_program(content, &ParserOptions::default(), &SourceInfo::default()) + .unwrap() + } + + // Combined tokenize + parse functions for full pipeline comparison + fn tokenize_and_parse_peg(content: &str) -> brush_parser::ast::Program { + let tokens = uncached_tokenize(content); + parse_peg(&tokens) + } + + const SAMPLE_SCRIPT: &str = r#" +for f in A B C; do + echo "${f@L}" >&2 +done +"#; + + const SIMPLE_SCRIPT: &str = "echo hello world"; + + const PIPELINE_SCRIPT: &str = "cat file.txt | grep pattern | wc -l"; + + const COMPLEX_SCRIPT: &str = r#" +#!/bin/bash +# Complex script with multiple constructs + +function process_file() { + local file="$1" + if [[ -f "$file" ]]; then + while read -r line; do + case "$line" in + start*) + echo "Starting: $line" + ;; + end*) + echo "Ending: $line" + ;; + *) + echo "Processing: $line" + ;; + esac + done < "$file" + fi +} + +for i in {1..10}; do + if (( i % 2 == 0 )); then + echo "$i is even" | tee -a output.txt + else + echo "$i is odd" >> output.txt + fi +done + +process_file "input.txt" && echo "Success" || echo "Failed" +"#; + + const NESTED_EXPANSIONS_SCRIPT: &str = r" +# Script with deeply nested expansions (tests balanced delimiter parsing) +result=$(echo $(echo $((1 + (2 * (3 - 4)))))) +fallback=${foo:-${bar:-${baz}}} +arithmetic=$((1 + (2 * (3 + (4 - 5))))) +command_subst=$(ls $(pwd)) +mixed=$(echo $((1 + 2)) | cat) +backtick=`echo (nested parens)` +"; + + // Extended test expression benchmarks - various patterns + #[allow(dead_code)] + const EXTENDED_TEST_SIMPLE: &str = "[[ -f file.txt ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_BINARY: &str = "[[ $a == $b ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_REGEX: &str = "[[ $str =~ ^[0-9]+$ ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_COMPLEX_REGEX: &str = "[[ $input =~ ^(foo|bar)[0-9]+(baz|qux)$ ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_LOGICAL: &str = "[[ -f file.txt && -r file.txt || -w other.txt ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_NESTED: &str = "[[ ( -f $file && -r $file ) || ( -d $dir && -x $dir ) ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_COMPLEX: &str = + "[[ ! ( $a -eq 5 && $b -gt 10 ) || ( $c =~ pattern && -f $file ) ]]"; + + fn benchmark_parsing_script_using_caches(c: &mut Criterion, script_path: &std::path::Path) { + let contents = std::fs::read_to_string(script_path).unwrap(); + let filename = script_path.file_name().unwrap().to_string_lossy(); + + c.bench_function(std::format!("parse_peg_{filename}").as_str(), |b| { + b.iter(|| parse_peg(&cacheable_tokenize(contents.as_str()))); + }); + } + + pub(crate) fn criterion_benchmark(c: &mut Criterion) { + const POSSIBLE_BASH_COMPLETION_SCRIPT_PATH: &str = + "/usr/share/bash-completion/bash_completion"; + + // Tokenization benchmark (applies to both parsers) + c.bench_function("tokenize_sample_script", |b| { + b.iter(|| uncached_tokenize(SAMPLE_SCRIPT)); + }); + + // Simple script benchmarks + let simple_tokens = uncached_tokenize(SIMPLE_SCRIPT); + c.bench_function("parse_peg_simple", |b| b.iter(|| parse_peg(&simple_tokens))); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_simple", |b| { + b.iter(|| parse_winnow_str(SIMPLE_SCRIPT)); + }); + + // Pipeline script benchmarks + let pipeline_tokens = uncached_tokenize(PIPELINE_SCRIPT); + c.bench_function("parse_peg_pipeline", |b| { + b.iter(|| parse_peg(&pipeline_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_pipeline", |b| { + b.iter(|| parse_winnow_str(PIPELINE_SCRIPT)); + }); + + // Sample script (for loop) benchmarks + let sample_tokens = uncached_tokenize(SAMPLE_SCRIPT); + c.bench_function("parse_peg_for_loop", |b| { + b.iter(|| parse_peg(&sample_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_for_loop", |b| { + b.iter(|| parse_winnow_str(SAMPLE_SCRIPT)); + }); + + // Complex script benchmarks + let complex_tokens = uncached_tokenize(COMPLEX_SCRIPT); + c.bench_function("parse_peg_complex", |b| { + b.iter(|| parse_peg(&complex_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_complex", |b| { + b.iter(|| parse_winnow_str(COMPLEX_SCRIPT)); + }); + + // Real-world bash completion script (if available) + let well_known_complicated_script = + std::path::PathBuf::from(POSSIBLE_BASH_COMPLETION_SCRIPT_PATH); + + if well_known_complicated_script.exists() { + benchmark_parsing_script_using_caches(c, &well_known_complicated_script); + } + + // ======================================================================== + // FULL PIPELINE BENCHMARKS (tokenize + parse) + // ======================================================================== + // These benchmarks measure the complete parsing pipeline from string to AST, + // allowing fair comparison between different approaches: + // - tokenize_and_parse_peg: Legacy tokenizer + PEG parser + // - parse_winnow_str: Direct string parsing (no separate tokenization) + + // Simple script full pipeline + c.bench_function("full_peg_simple", |b| { + b.iter(|| tokenize_and_parse_peg(SIMPLE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_simple", |b| { + b.iter(|| parse_winnow_str(SIMPLE_SCRIPT)); + }); + + // Pipeline script full pipeline + c.bench_function("full_peg_pipeline", |b| { + b.iter(|| tokenize_and_parse_peg(PIPELINE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_pipeline", |b| { + b.iter(|| parse_winnow_str(PIPELINE_SCRIPT)); + }); + + // For loop full pipeline + c.bench_function("full_peg_for_loop", |b| { + b.iter(|| tokenize_and_parse_peg(SAMPLE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_for_loop", |b| { + b.iter(|| parse_winnow_str(SAMPLE_SCRIPT)); + }); + + // Complex script full pipeline + c.bench_function("full_peg_complex", |b| { + b.iter(|| tokenize_and_parse_peg(COMPLEX_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_complex", |b| { + b.iter(|| parse_winnow_str(COMPLEX_SCRIPT)); + }); + + // Nested expansions (balanced delimiter parsing stress test) + c.bench_function("full_peg_nested_expansions", |b| { + b.iter(|| tokenize_and_parse_peg(NESTED_EXPANSIONS_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_nested_expansions", |b| { + b.iter(|| parse_winnow_str(NESTED_EXPANSIONS_SCRIPT)); + }); + + // ======================================================================== + // EXTENDED TEST EXPRESSION BENCHMARKS + // ======================================================================== + // Benchmarks for the refactored extended test ([[ ]]) parser + // Tests various patterns: simple, binary, regex, logical operators, nesting + + #[cfg(feature = "winnow-parser")] + { + c.bench_function("extended_test_simple", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_SIMPLE)); + }); + + c.bench_function("extended_test_binary", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_BINARY)); + }); + + c.bench_function("extended_test_regex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_REGEX)); + }); + + c.bench_function("extended_test_complex_regex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_COMPLEX_REGEX)); + }); + + c.bench_function("extended_test_logical", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_LOGICAL)); + }); + + c.bench_function("extended_test_nested", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_NESTED)); + }); + + c.bench_function("extended_test_complex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_COMPLEX)); + }); + } + } +} + +#[cfg(unix)] +criterion::criterion_group! { + name = benches; + config = criterion::Criterion::default(); + targets = unix::criterion_benchmark +} + +#[cfg(unix)] +criterion::criterion_main!(benches); + +#[cfg(not(unix))] +fn main() {} diff --git a/userland/brush/source/brush-parser/examples/miette.rs b/userland/brush/source/brush-parser/examples/miette.rs new file mode 100644 index 0000000..729c1fe --- /dev/null +++ b/userland/brush/source/brush-parser/examples/miette.rs @@ -0,0 +1,24 @@ +//! Simple example of miette usage + +use std::io::Cursor; + +use brush_parser::Parser; +use miette::{IntoDiagnostic, miette}; + +fn main() -> miette::Result<()> { + let f = std::env::args() + .nth(1) + .ok_or_else(|| miette!("Please provide a file name"))?; + + let source = std::fs::read_to_string(&f).into_diagnostic()?; + let reader = Cursor::new(&source); + let mut parser = Parser::builder().build(reader); + + let ast = parser + .parse_program() + .map_err(|e| e.to_pretty_error(&source))?; + + println!("{ast:#?}"); + + Ok(()) +} diff --git a/userland/brush/source/brush-parser/examples/serde.rs b/userland/brush/source/brush-parser/examples/serde.rs new file mode 100644 index 0000000..ee7facf --- /dev/null +++ b/userland/brush/source/brush-parser/examples/serde.rs @@ -0,0 +1,31 @@ +//! Example demonstrating AST serialization and deserialization with the `serde` feature. +//! +//! Run with: `cargo run --package brush-parser --example serde --features serde` + +use brush_parser::{Parser, ParserOptions}; +use std::io::BufReader; + +fn main() -> Result<(), Box> { + // Parse a simple shell command + let input = "echo 'Hello, World!' && ls -la"; + let reader = BufReader::new(input.as_bytes()); + let options = ParserOptions::default(); + + let mut parser = Parser::new(reader, &options); + let program = parser.parse_program()?; + + // Serialize the AST to JSON + let json = serde_json::to_string_pretty(&program)?; + println!("Parsed AST:"); + println!("{json}"); + + // Demonstrate round-trip: deserialize the JSON back to AST + println!("\nRound-trip deserialization:"); + let deserialized: brush_parser::ast::Program = serde_json::from_str(&json)?; + println!( + "Successfully deserialized AST with {} command(s)", + deserialized.complete_commands.len() + ); + + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/arithmetic.rs b/userland/brush/source/brush-parser/src/arithmetic.rs new file mode 100644 index 0000000..5dcebdb --- /dev/null +++ b/userland/brush/source/brush-parser/src/arithmetic.rs @@ -0,0 +1,193 @@ +//! Parser for shell arithmetic expressions. + +use crate::ast; +use crate::error; + +/// Parses a shell arithmetic expression. +/// +/// # Arguments +/// +/// * `input` - The arithmetic expression to parse, in string form. +pub fn parse(input: &str) -> Result { + cacheable_parse(input) +} + +#[cached::macros::cached(max_size = 64, key = "String", convert = r#"{ input.to_owned() }"#)] +fn cacheable_parse(input: &str) -> Result { + tracing::debug!(target: "arithmetic", "parsing arithmetic expression: '{input}'"); + arithmetic::full_expression(input) + .map_err(|e| error::WordParseError::ArithmeticExpression(e.into())) +} + +peg::parser! { + grammar arithmetic() for str { + // An expression holding nothing but whitespace evaluates to 0, wherever it came from: + // `$(( ))`, `let ''`, or a blank array subscript. + pub(crate) rule full_expression() -> ast::ArithmeticExpr = + _ ![_] { ast::ArithmeticExpr::Literal(0) } / + _ e:expression() _ { e } + + pub(crate) rule expression() -> ast::ArithmeticExpr = precedence!{ + x:(@) _ "," _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Comma, Box::new(x), Box::new(y)) } + -- + x:lvalue() _ "*=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Multiply, x, Box::new(y)) } + x:lvalue() _ "/=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Divide, x, Box::new(y)) } + x:lvalue() _ "%=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Modulo, x, Box::new(y)) } + x:lvalue() _ "+=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Add, x, Box::new(y)) } + x:lvalue() _ "-=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Subtract, x, Box::new(y)) } + x:lvalue() _ "<<=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::ShiftLeft, x, Box::new(y)) } + x:lvalue() _ ">>=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::ShiftRight, x, Box::new(y)) } + x:lvalue() _ "&=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseAnd, x, Box::new(y)) } + x:lvalue() _ "|=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseOr, x, Box::new(y)) } + x:lvalue() _ "^=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseXor, x, Box::new(y)) } + x:lvalue() _ "=" _ y:(@) { ast::ArithmeticExpr::Assignment(x, Box::new(y)) } + -- + x:@ _ "?" _ y:expression() _ ":" _ z:(@) { ast::ArithmeticExpr::Conditional(Box::new(x), Box::new(y), Box::new(z)) } + -- + x:(@) _ "||" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LogicalOr, Box::new(x), Box::new(y)) } + -- + x:(@) _ "&&" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LogicalAnd, Box::new(x), Box::new(y)) } + -- + x:(@) _ "|" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseOr, Box::new(x), Box::new(y)) } + -- + x:(@) _ "^" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseXor, Box::new(x), Box::new(y)) } + -- + x:(@) _ "&" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseAnd, Box::new(x), Box::new(y)) } + -- + x:(@) _ "==" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Equals, Box::new(x), Box::new(y)) } + x:(@) _ "!=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::NotEquals, Box::new(x), Box::new(y)) } + -- + x:(@) _ "<" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LessThan, Box::new(x), Box::new(y)) } + x:(@) _ ">" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::GreaterThan, Box::new(x), Box::new(y)) } + x:(@) _ "<=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LessThanOrEqualTo, Box::new(x), Box::new(y)) } + x:(@) _ ">=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::GreaterThanOrEqualTo, Box::new(x), Box::new(y)) } + -- + x:(@) _ "<<" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::ShiftLeft, Box::new(x), Box::new(y)) } + x:(@) _ ">>" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::ShiftRight, Box::new(x), Box::new(y)) } + -- + x:(@) _ "+" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Add, Box::new(x), Box::new(y)) } + x:(@) _ "-" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Subtract, Box::new(x), Box::new(y)) } + -- + x:(@) _ "*" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Multiply, Box::new(x), Box::new(y)) } + x:(@) _ "%" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Modulo, Box::new(x), Box::new(y)) } + x:(@) _ "/" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Divide, Box::new(x), Box::new(y)) } + -- + x:@ _ "**" _ y:(@) { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Power, Box::new(x), Box::new(y)) } + -- + "!" _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::LogicalNot, Box::new(x)) } + "~" _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::BitwiseNot, Box::new(x)) } + -- + // NOTE: We add negative lookahead to avoid ambiguity with the pre-increment/pre-decrement operators. + "+" !['+'] _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::UnaryPlus, Box::new(x)) } + "-" !['-'] _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::UnaryMinus, Box::new(x)) } + -- + "++" _ x:lvalue() { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PrefixIncrement, x) } + "--" _ x:lvalue() { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PrefixDecrement, x) } + -- + x:lvalue() _ "++" { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PostfixIncrement, x) } + x:lvalue() _ "--" { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PostfixDecrement, x) } + -- + n:literal_number() { ast::ArithmeticExpr::Literal(n) } + l:lvalue() { ast::ArithmeticExpr::Reference(l) } + "(" _ expr:expression() _ ")" { expr } + } + + rule lvalue() -> ast::ArithmeticTarget = + name:variable_name() "[" index:expression() "]" { + ast::ArithmeticTarget::ArrayElement(name.to_owned(), Box::new(index)) + } / + name:variable_name() { + ast::ArithmeticTarget::Variable(name.to_owned()) + } + + rule variable_name() -> &'input str = + $(['a'..='z' | 'A'..='Z' | '_'](['a'..='z' | 'A'..='Z' | '_' | '0'..='9']*)) + + rule _() -> () = quiet!{[' ' | '\t' | '\n' | '\r']*} {} + + rule literal_number() -> i64 = + // Literal with explicit radix (format: #) + radix:decimal_literal() "#" s:$(['0'..='9' | 'a'..='z' | 'A'..='Z' | '@' | '_']+) {? + parse_shell_literal_number(s, radix.cast_unsigned()) + } / + // Hex literal + "0" ['x' | 'X'] s:$(['0'..='9' | 'a'..='f' | 'A'..='F']*) {? + i64::from_str_radix(s, 16).or(Err("i64")) + } / + // Octal literal + s:$("0" ['0'..='8']*) {? + i64::from_str_radix(s, 8).or(Err("i64")) + } / + // Decimal literal + decimal_literal() + + rule decimal_literal() -> i64 = + s:$(['1'..='9'] ['0'..='9']*) {? + // Parse as u64 first, then cast to i64. This handles values like + // 9223372036854775808 (i64::MAX + 1) which is needed for INT64_MIN + // when preceded by unary minus: -(9223372036854775808) wraps to i64::MIN. + s.parse::().map(|v| v.cast_signed()).or(Err("i64")) + } + } +} + +fn parse_shell_literal_number(s: &str, radix: u64) -> Result { + if !(2..=64).contains(&radix) { + return Err("invalid base"); + } + + // For bases <= 36: case-insensitive (a-z and A-Z both map to 10-35) + // For bases > 36 (bash extension): + // 0-9 = 0-9, a-z = 10-35, A-Z = 36-61, @ = 62, _ = 63 + let mut result: i64 = 0; + + for ch in s.chars() { + let digit_val = if radix <= 36 { + match ch { + '0'..='9' => (ch as u64) - ('0' as u64), + 'a'..='z' => (ch as u64) - ('a' as u64) + 10, + 'A'..='Z' => (ch as u64) - ('A' as u64) + 10, + _ => return Err("invalid digit"), + } + } else { + match ch { + '0'..='9' => (ch as u64) - ('0' as u64), + 'a'..='z' => (ch as u64) - ('a' as u64) + 10, + 'A'..='Z' => (ch as u64) - ('A' as u64) + 36, + '@' => 62, + '_' => 63, + _ => return Err("invalid digit"), + } + }; + + if digit_val >= radix { + return Err("value too great for base"); + } + + result = result + .wrapping_mul(radix.cast_signed()) + .wrapping_add(digit_val.cast_signed()); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_blank_expression_yields_zero() { + for input in ["", " ", "\t", " \n "] { + assert!( + matches!(parse(input), Ok(ast::ArithmeticExpr::Literal(0))), + "expected 0 for {input:?}" + ); + } + } + + #[test] + fn parse_rejects_an_incomplete_expression() { + assert!(parse("1 +").is_err()); + } +} diff --git a/userland/brush/source/brush-parser/src/ast.rs b/userland/brush/source/brush-parser/src/ast.rs new file mode 100644 index 0000000..043baf4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/ast.rs @@ -0,0 +1,2485 @@ +//! Defines the Abstract Syntax Tree (ast) for shell programs. Includes types and utilities +//! for manipulating the AST. + +use std::fmt::{Display, Write}; + +use crate::{SourceSpan, tokenizer}; + +const DISPLAY_INDENT: &str = " "; + +/// Trait implemented by all AST nodes. Used to aggregate traits expected +/// to be implemented. +pub trait Node: Display + SourceLocation {} + +/// Provides the source location for the syntax item +pub trait SourceLocation { + /// The location of the syntax item, when known + fn location(&self) -> Option; +} + +pub(crate) fn maybe_location( + start: Option<&SourceSpan>, + end: Option<&SourceSpan>, +) -> Option { + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(s, e)) + } else { + None + } +} + +/// Represents a complete shell program. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Program { + /// A sequence of complete shell commands. + pub complete_commands: Vec, +} + +impl Node for Program {} + +impl SourceLocation for Program { + fn location(&self) -> Option { + let start = self + .complete_commands + .first() + .and_then(SourceLocation::location); + let end = self + .complete_commands + .last() + .and_then(SourceLocation::location); + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for Program { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for complete_command in &self.complete_commands { + write!(f, "{complete_command}")?; + } + Ok(()) + } +} + +/// Represents a complete shell command. +pub type CompleteCommand = CompoundList; + +/// Represents a complete shell command item. +pub type CompleteCommandItem = CompoundListItem; + +// TODO(tracing): decide if we want to trace this location or consider it a whitespace separator +/// Indicates whether the preceding command is executed synchronously or asynchronously. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SeparatorOperator { + /// The preceding command is executed asynchronously. + Async, + /// The preceding command is executed synchronously. + Sequence, +} + +impl Display for SeparatorOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Async => write!(f, "&"), + Self::Sequence => write!(f, ";"), + } + } +} + +/// Represents a sequence of command pipelines connected by boolean operators. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct AndOrList { + /// The first command pipeline. + pub first: Pipeline, + /// Any additional command pipelines, in sequence order. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Vec::is_empty", default) + )] + pub additional: Vec, +} + +impl Node for AndOrList {} + +impl SourceLocation for AndOrList { + fn location(&self) -> Option { + let start = self.first.location(); + let last = self.additional.last(); + let end = last.and_then(SourceLocation::location); + + match (start, end) { + (Some(s), Some(e)) => Some(SourceSpan::within(&s, &e)), + (start, _) => start, + } + } +} + +impl Display for AndOrList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.first)?; + for item in &self.additional { + write!(f, "{item}")?; + } + + Ok(()) + } +} + +/// Represents a boolean operator used to connect command pipelines in an [`AndOrList`] +#[derive(PartialEq, Eq)] +pub enum PipelineOperator { + /// The command pipelines are connected by a boolean AND operator. + And, + /// The command pipelines are connected by a boolean OR operator. + Or, +} + +impl PartialEq for PipelineOperator { + fn eq(&self, other: &AndOr) -> bool { + matches!( + (self, other), + (Self::And, AndOr::And(_)) | (Self::Or, AndOr::Or(_)) + ) + } +} + +// We cannot losslessly convert into `AndOr`, hence we can only do `Into`. +#[expect(clippy::from_over_into)] +impl Into for AndOr { + fn into(self) -> PipelineOperator { + match self { + Self::And(_) => PipelineOperator::And, + Self::Or(_) => PipelineOperator::Or, + } + } +} + +/// An iterator over the pipelines in an [`AndOrList`]. +pub struct AndOrListIter<'a> { + first: Option<&'a Pipeline>, + additional_iter: std::slice::Iter<'a, AndOr>, +} + +impl<'a> Iterator for AndOrListIter<'a> { + type Item = (PipelineOperator, &'a Pipeline); + + fn next(&mut self) -> Option { + if let Some(first) = self.first.take() { + Some((PipelineOperator::And, first)) + } else { + self.additional_iter.next().map(|and_or| match and_or { + AndOr::And(pipeline) => (PipelineOperator::And, pipeline), + AndOr::Or(pipeline) => (PipelineOperator::Or, pipeline), + }) + } + } +} + +impl<'a> IntoIterator for &'a AndOrList { + type Item = (PipelineOperator, &'a Pipeline); + type IntoIter = AndOrListIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + AndOrListIter { + first: Some(&self.first), + additional_iter: self.additional.iter(), + } + } +} + +impl<'a> From<(PipelineOperator, &'a Pipeline)> for AndOr { + fn from(value: (PipelineOperator, &'a Pipeline)) -> Self { + match value.0 { + PipelineOperator::Or => Self::Or(value.1.to_owned()), + PipelineOperator::And => Self::And(value.1.to_owned()), + } + } +} + +impl AndOrList { + /// Returns an iterator over the pipelines in this `AndOrList`. + pub fn iter(&self) -> AndOrListIter<'_> { + self.into_iter() + } +} + +/// Represents a boolean operator used to connect command pipelines, along with the +/// succeeding pipeline. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AndOr { + /// Boolean AND operator; the embedded pipeline is only to be executed if the + /// preceding command has succeeded. + And(Pipeline), + /// Boolean OR operator; the embedded pipeline is only to be executed if the + /// preceding command has not succeeded. + Or(Pipeline), +} + +impl Node for AndOr {} + +// TODO(source-location): add a loc to account for the operator +impl SourceLocation for AndOr { + fn location(&self) -> Option { + match self { + Self::And(p) => p.location(), + Self::Or(p) => p.location(), + } + } +} + +impl Display for AndOr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::And(pipeline) => write!(f, " && {pipeline}"), + Self::Or(pipeline) => write!(f, " || {pipeline}"), + } + } +} + +/// The type of timing requested for a pipeline. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PipelineTimed { + /// The pipeline should be timed with bash-like output. + Timed(SourceSpan), + /// The pipeline should be timed with POSIX-like output. + TimedWithPosixOutput(SourceSpan), +} + +impl Node for PipelineTimed {} + +impl SourceLocation for PipelineTimed { + fn location(&self) -> Option { + match self { + Self::Timed(t) => Some(t.to_owned()), + Self::TimedWithPosixOutput(t) => Some(t.to_owned()), + } + } +} + +impl Display for PipelineTimed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Timed(_) => write!(f, "time"), + Self::TimedWithPosixOutput(_) => write!(f, "time -p"), + } + } +} + +impl PipelineTimed { + /// Returns true if the pipeline should be timed with POSIX-like output. + pub const fn is_posix_output(&self) -> bool { + matches!(self, Self::TimedWithPosixOutput(_)) + } +} + +/// A pipeline of commands, where each command's output is passed as standard input +/// to the command that follows it. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Pipeline { + /// Indicates whether the pipeline's execution should be timed with reported + /// timings in output. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub timed: Option, + /// Indicates whether the result of the overall pipeline should be the logical + /// negation of the result of the pipeline. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub bang: bool, + /// The sequence of commands in the pipeline. + pub seq: Vec, +} + +impl Node for Pipeline {} + +// TODO(source-location): Handle the case where `self.timed` is `None` but there is a bang. +impl SourceLocation for Pipeline { + fn location(&self) -> Option { + let start = self + .timed + .as_ref() + .and_then(SourceLocation::location) + .or_else(|| self.seq.first().and_then(SourceLocation::location)); + let end = self.seq.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for Pipeline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(timed) = &self.timed { + write!(f, "{timed} ")?; + } + + if self.bang { + write!(f, "! ")?; + } + for (i, command) in self.seq.iter().enumerate() { + if i > 0 { + write!(f, " |")?; + } + write!(f, "{command}")?; + } + + Ok(()) + } +} + +/// Represents a shell command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Command { + /// A simple command, directly invoking an external command, a built-in command, + /// a shell function, or similar. + Simple(SimpleCommand), + /// A compound command, composed of multiple commands. + Compound(CompoundCommand, Option), + /// A command whose side effect is to define a shell function. + Function(FunctionDefinition), +} + +impl Node for Command {} + +impl SourceLocation for Command { + fn location(&self) -> Option { + match self { + Self::Simple(s) => s.location(), + Self::Compound(c, r) => { + match (c.location(), r.as_ref().and_then(SourceLocation::location)) { + (Some(s), Some(e)) => Some(SourceSpan::within(&s, &e)), + (s, _) => s, + } + } + Self::Function(f) => f.location(), + } + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Simple(simple_command) => write!(f, "{simple_command}"), + Self::Compound(compound_command, redirect_list) => { + write!(f, "{compound_command}")?; + if let Some(redirect_list) = redirect_list { + write!(f, "{redirect_list}")?; + } + Ok(()) + } + Self::Function(function_definition) => write!(f, "{function_definition}"), + } + } +} + +/// Represents a compound command, potentially made up of multiple nested commands. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CompoundCommand { + /// An arithmetic command, evaluating an arithmetic expression. + Arithmetic(ArithmeticCommand), + /// An arithmetic for clause, which loops until an arithmetic condition is reached. + ArithmeticForClause(ArithmeticForClauseCommand), + /// A brace group, which groups commands together. + BraceGroup(BraceGroupCommand), + /// A subshell, which executes commands in a subshell. + Subshell(SubshellCommand), + /// A for clause, which loops over a set of values. + ForClause(ForClauseCommand), + /// A case clause, which selects a command based on a value and a set of + /// pattern-based filters. + CaseClause(CaseClauseCommand), + /// An if clause, which conditionally executes a command. + IfClause(IfClauseCommand), + /// A while clause, which loops while a condition is met. + WhileClause(WhileOrUntilClauseCommand), + /// An until clause, which loops until a condition is met. + UntilClause(WhileOrUntilClauseCommand), + /// A coprocess, which runs a command asynchronously in a subshell. + Coprocess(CoprocessCommand), + /// An extended test command, evaluating an extended test expression. + ExtendedTest(ExtendedTestExprCommand), +} + +impl Node for CompoundCommand {} + +impl SourceLocation for CompoundCommand { + fn location(&self) -> Option { + match self { + Self::Arithmetic(a) => a.location(), + Self::ArithmeticForClause(a) => a.location(), + Self::BraceGroup(b) => b.location(), + Self::Subshell(s) => s.location(), + Self::ForClause(f) => f.location(), + Self::CaseClause(c) => c.location(), + Self::IfClause(i) => i.location(), + Self::WhileClause(w) => w.location(), + Self::UntilClause(u) => u.location(), + Self::Coprocess(c) => c.location(), + Self::ExtendedTest(e) => e.location(), + } + } +} + +impl Display for CompoundCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Arithmetic(arithmetic_command) => write!(f, "{arithmetic_command}"), + Self::ArithmeticForClause(arithmetic_for_clause_command) => { + write!(f, "{arithmetic_for_clause_command}") + } + Self::BraceGroup(brace_group_command) => { + write!(f, "{brace_group_command}") + } + Self::Subshell(subshell_command) => write!(f, "{subshell_command}"), + Self::ForClause(for_clause_command) => write!(f, "{for_clause_command}"), + Self::CaseClause(case_clause_command) => { + write!(f, "{case_clause_command}") + } + Self::IfClause(if_clause_command) => write!(f, "{if_clause_command}"), + Self::WhileClause(while_or_until_clause_command) => { + write!(f, "while {while_or_until_clause_command}") + } + Self::UntilClause(while_or_until_clause_command) => { + write!(f, "until {while_or_until_clause_command}") + } + Self::Coprocess(coproc_clause_command) => { + write!(f, "{coproc_clause_command}") + } + Self::ExtendedTest(extended_test_expr_command) => { + write!(f, "[[ {extended_test_expr_command} ]]") + } + } + } +} + +/// An arithmetic command, evaluating an arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ArithmeticCommand { + /// The raw, unparsed and unexpanded arithmetic expression. + pub expr: UnexpandedArithmeticExpr, + /// Location of the command + pub loc: SourceSpan, +} + +impl Node for ArithmeticCommand {} + +impl SourceLocation for ArithmeticCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ArithmeticCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "(({}))", self.expr) + } +} + +/// A subshell, which executes commands in a subshell. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SubshellCommand { + /// Command list in the subshell + pub list: CompoundList, + /// Location of the subshell + pub loc: SourceSpan, +} + +impl Node for SubshellCommand {} + +impl SourceLocation for SubshellCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for SubshellCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "( ")?; + write!(f, "{}", self.list)?; + write!(f, " )") + } +} + +/// A for clause, which loops over a set of values. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ForClauseCommand { + /// The name of the iterator variable. + pub variable_name: String, + /// The values being iterated over. + pub values: Option>, + /// The command to run for each iteration of the loop. + pub body: DoGroupCommand, + /// Location of the for command. + pub loc: SourceSpan, +} + +impl Node for ForClauseCommand {} + +impl SourceLocation for ForClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ForClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "for {} in ", self.variable_name)?; + + if let Some(values) = &self.values { + for (i, value) in values.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{value}")?; + } + } + + writeln!(f, ";")?; + + write!(f, "{}", self.body) + } +} + +/// An arithmetic for clause, which loops until an arithmetic condition is reached. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ArithmeticForClauseCommand { + /// Optionally, the initializer expression evaluated before the first iteration of the loop. + pub initializer: Option, + /// Optionally, the expression evaluated as the exit condition of the loop. + pub condition: Option, + /// Optionally, the expression evaluated after each iteration of the loop. + pub updater: Option, + /// The command to run for each iteration of the loop. + pub body: DoGroupCommand, + /// Location of the clause + pub loc: SourceSpan, +} + +impl Node for ArithmeticForClauseCommand {} + +impl SourceLocation for ArithmeticForClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ArithmeticForClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "for ((")?; + + if let Some(initializer) = &self.initializer { + write!(f, "{initializer}")?; + } + + write!(f, "; ")?; + + if let Some(condition) = &self.condition { + write!(f, "{condition}")?; + } + + write!(f, "; ")?; + + if let Some(updater) = &self.updater { + write!(f, "{updater}")?; + } + + writeln!(f, "))")?; + + write!(f, "{}", self.body) + } +} + +/// A case clause, which selects a command based on a value and a set of +/// pattern-based filters. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CaseClauseCommand { + /// The value being matched on. + pub value: Word, + /// The individual case branches. + pub cases: Vec, + /// Location of the case command. + pub loc: SourceSpan, +} + +impl Node for CaseClauseCommand {} + +impl SourceLocation for CaseClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for CaseClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Note the trailing space, which the shell emits when printing a case clause. + write!(f, "case {} in ", self.value)?; + for case in &self.cases { + write!(indenter::indented(f).with_str(DISPLAY_INDENT), "{case}")?; + } + writeln!(f)?; + write!(f, "esac") + } +} + +/// A sequence of commands. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CompoundList(pub Vec); + +impl Node for CompoundList {} + +// TODO(source-location): Handle the optional trailing separator. +impl SourceLocation for CompoundList { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(&s, &e)) + } else { + None + } + } +} + +impl CompoundList { + /// Displays the list with its trailing `;` retained, the way the shell prints the + /// body of a block closed by a reserved word (`fi`, `else`, `done`). + fn terminated(&self) -> impl Display + '_ { + TerminatedCompoundList(self) + } + + fn fmt_items( + &self, + f: &mut std::fmt::Formatter<'_>, + keep_trailing_separator: bool, + ) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + writeln!(f)?; + } + + // Write the and-or list. + write!(f, "{}", item.0)?; + + // Write the separator... unless we're on the last list item and it's a ';' + // that the enclosing construct doesn't want. + if keep_trailing_separator + || i < self.0.len() - 1 + || !matches!(item.1, SeparatorOperator::Sequence) + { + write!(f, "{}", item.1)?; + } + } + + Ok(()) + } +} + +impl Display for CompoundList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_items(f, false) + } +} + +struct TerminatedCompoundList<'a>(&'a CompoundList); + +impl Display for TerminatedCompoundList<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt_items(f, true) + } +} + +/// An element of a compound command list. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CompoundListItem(pub AndOrList, pub SeparatorOperator); + +impl Node for CompoundListItem {} + +// TODO(source-location): Account for the location of the separator operator. +impl SourceLocation for CompoundListItem { + fn location(&self) -> Option { + self.0.location() + } +} + +impl Display for CompoundListItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + write!(f, "{}", self.1)?; + Ok(()) + } +} + +/// An if clause, which conditionally executes a command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct IfClauseCommand { + /// The command whose execution result is inspected. + pub condition: CompoundList, + /// The command to execute if the condition is true. + pub then: CompoundList, + /// Optionally, `else` clauses that will be evaluated if the condition is false. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub elses: Option>, + /// Location of the if clause + pub loc: SourceSpan, +} + +impl Node for IfClauseCommand {} + +impl SourceLocation for IfClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for IfClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "if {}; then", self.condition)?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.then.terminated() + )?; + if let Some(elses) = &self.elses { + for else_clause in elses { + write!(f, "{else_clause}")?; + } + } + + writeln!(f)?; + write!(f, "fi")?; + + Ok(()) + } +} + +/// Represents the `else` clause of a conditional command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ElseClause { + /// If present, the condition that must be met for this `else` clause to be executed. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub condition: Option, + /// The commands to execute if this `else` clause is selected. + pub body: CompoundList, +} + +impl Display for ElseClause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f)?; + if let Some(condition) = &self.condition { + writeln!(f, "elif {condition}; then")?; + } else { + writeln!(f, "else")?; + } + + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.body.terminated() + ) + } +} + +/// A coprocess command, which runs a command asynchronously in a subshell. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CoprocessCommand { + /// The optional name for the coprocess. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub name: Option, + /// The command to run as a coprocess (can be simple or compound). + pub body: Box, + /// The location of this command in the source. + #[cfg_attr(any(test, feature = "serde"), serde(skip_serializing, default))] + pub loc: SourceSpan, +} + +impl Node for CoprocessCommand {} + +impl SourceLocation for CoprocessCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for CoprocessCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "coproc")?; + if let Some(name) = &self.name { + write!(f, " {name}")?; + } + write!(f, " {}", self.body)?; + Ok(()) + } +} + +/// An individual matching case item in a case clause. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CaseItem { + /// The patterns that select this case branch. + pub patterns: Vec, + /// The commands to execute if this case branch is selected. + pub cmd: Option, + /// When the case branch is selected, the action to take after the command is executed. + pub post_action: CaseItemPostAction, + /// Location of the item + pub loc: Option, +} + +impl Node for CaseItem {} + +impl SourceLocation for CaseItem { + fn location(&self) -> Option { + self.loc.clone() + } +} + +impl Display for CaseItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f)?; + for (i, pattern) in self.patterns.iter().enumerate() { + if i > 0 { + // Note the spaces, which the shell puts around the pattern separator. + write!(f, " | ")?; + } + write!(f, "{pattern}")?; + } + writeln!(f, ")")?; + + if let Some(cmd) = &self.cmd { + write!(indenter::indented(f).with_str(DISPLAY_INDENT), "{cmd}")?; + } + writeln!(f)?; + write!(f, "{}", self.post_action) + } +} + +/// Describes the action to take after executing the body command of a case clause. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CaseItemPostAction { + /// The containing case should be exited. + ExitCase, + /// If one is present, the command body of the succeeding case item should be + /// executed (without evaluating its pattern). + UnconditionallyExecuteNextCaseItem, + /// The case should continue evaluating the remaining case items, as if this + /// item had not been executed. + ContinueEvaluatingCases, +} + +impl Display for CaseItemPostAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ExitCase => write!(f, ";;"), + Self::UnconditionallyExecuteNextCaseItem => write!(f, ";&"), + Self::ContinueEvaluatingCases => write!(f, ";;&"), + } + } +} + +/// A while or until clause, whose looping is controlled by a condition. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct WhileOrUntilClauseCommand(pub CompoundList, pub DoGroupCommand, pub SourceSpan); + +impl Node for WhileOrUntilClauseCommand {} + +impl SourceLocation for WhileOrUntilClauseCommand { + fn location(&self) -> Option { + Some(self.2.clone()) + } +} + +impl Display for WhileOrUntilClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}; {}", self.0, self.1) + } +} + +/// Encapsulates the definition of a shell function. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct FunctionDefinition { + /// The name of the function. + pub fname: Word, + /// The body of the function. + pub body: FunctionBody, +} + +impl Node for FunctionDefinition {} + +// TODO(source-location): Account for the optional 'function' keyword that may +// precede the function name. +impl SourceLocation for FunctionDefinition { + fn location(&self) -> Option { + let start = self.fname.location(); + let end = self.body.location(); + + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(&s, &e)) + } else { + None + } + } +} + +impl Display for FunctionDefinition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{} () ", self.fname.value)?; + write!(f, "{}", self.body)?; + Ok(()) + } +} + +/// Encapsulates the body of a function definition. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct FunctionBody(pub CompoundCommand, pub Option); + +impl Node for FunctionBody {} + +impl SourceLocation for FunctionBody { + fn location(&self) -> Option { + let cmd_span = self.0.location(); + let redirect_span = self.1.as_ref().and_then(SourceLocation::location); + + match (cmd_span, redirect_span) { + // If there's a redirect, include it in the span. + (Some(cmd_span), Some(redirect_span)) => { + Some(SourceSpan::within(&cmd_span, &redirect_span)) + } + // Otherwise, just return the command span. + (Some(cmd_span), None) => Some(cmd_span), + _ => None, + } + } +} + +impl Display for FunctionBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + if let Some(redirect_list) = &self.1 { + write!(f, "{redirect_list}")?; + } + + Ok(()) + } +} + +/// A brace group, which groups commands together. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct BraceGroupCommand { + /// List of commands + pub list: CompoundList, + /// Location of the group + pub loc: SourceSpan, +} + +impl Node for BraceGroupCommand {} + +impl SourceLocation for BraceGroupCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for BraceGroupCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{{ ")?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.list + )?; + writeln!(f)?; + write!(f, "}}")?; + + Ok(()) + } +} + +/// A do group, which groups commands together. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct DoGroupCommand { + /// List of commands + pub list: CompoundList, + /// Location of the group + pub loc: SourceSpan, +} + +impl Display for DoGroupCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "do")?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.list.terminated() + )?; + writeln!(f)?; + write!(f, "done") + } +} + +/// Represents the invocation of a simple command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SimpleCommand { + /// Optionally, a prefix to the command. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub prefix: Option, + /// The name of the command to execute. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub word_or_name: Option, + /// Optionally, a suffix to the command. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub suffix: Option, +} + +impl Node for SimpleCommand {} + +impl SourceLocation for SimpleCommand { + fn location(&self) -> Option { + let mid = &self + .word_or_name + .as_ref() + .and_then(SourceLocation::location); + let start = self.prefix.as_ref().and_then(SourceLocation::location); + let end = self.suffix.as_ref().and_then(SourceLocation::location); + + match (start, mid, end) { + (Some(start), _, Some(end)) => Some(SourceSpan::within(&start, &end)), + (Some(start), Some(mid), None) => Some(SourceSpan::within(&start, mid)), + (Some(start), None, None) => Some(start), + (None, Some(mid), Some(end)) => Some(SourceSpan::within(mid, &end)), + (None, Some(mid), None) => Some(mid.clone()), + _ => None, + } + } +} + +impl Display for SimpleCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut wrote_something = false; + + if let Some(prefix) = &self.prefix { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{prefix}")?; + wrote_something = true; + } + + if let Some(word_or_name) = &self.word_or_name { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{word_or_name}")?; + wrote_something = true; + } + + if let Some(suffix) = &self.suffix { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{suffix}")?; + } + + Ok(()) + } +} + +/// Represents a prefix to a simple command. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CommandPrefix(pub Vec); + +impl Node for CommandPrefix {} + +impl SourceLocation for CommandPrefix { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for CommandPrefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// Represents a suffix to a simple command; a word argument, declaration, or I/O redirection. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CommandSuffix(pub Vec); + +impl Node for CommandSuffix {} + +impl SourceLocation for CommandSuffix { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for CommandSuffix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// Represents the I/O direction of a process substitution. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ProcessSubstitutionKind { + /// The process is read from. + Read, + /// The process is written to. + Write, +} + +impl Display for ProcessSubstitutionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "<"), + Self::Write => write!(f, ">"), + } + } +} + +/// A prefix or suffix for a simple command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CommandPrefixOrSuffixItem { + /// An I/O redirection. + IoRedirect(IoRedirect), + /// A word. + Word(Word), + /// An assignment/declaration word. + AssignmentWord(Assignment, Word), + /// A process substitution. + ProcessSubstitution(ProcessSubstitutionKind, SubshellCommand), +} + +impl Node for CommandPrefixOrSuffixItem {} + +impl SourceLocation for CommandPrefixOrSuffixItem { + fn location(&self) -> Option { + match self { + Self::Word(w) => w.location(), + Self::IoRedirect(io_redirect) => io_redirect.location(), + Self::AssignmentWord(assignment, _word) => assignment.location(), + // TODO(source-location): account for the kind token + Self::ProcessSubstitution(_kind, cmd) => cmd.location(), + } + } +} + +impl Display for CommandPrefixOrSuffixItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoRedirect(io_redirect) => write!(f, "{io_redirect}"), + Self::Word(word) => write!(f, "{word}"), + Self::AssignmentWord(_assignment, word) => write!(f, "{word}"), + Self::ProcessSubstitution(kind, subshell_command) => { + write!(f, "{kind}({subshell_command})") + } + } + } +} + +/// Encapsulates an assignment declaration. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Assignment { + /// Name being assigned to. + pub name: AssignmentName, + /// Value being assigned. + pub value: AssignmentValue, + /// Whether or not to append to the preexisting value associated with the named variable. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub append: bool, + /// Location of the assignment + pub loc: SourceSpan, +} + +impl Node for Assignment {} + +impl SourceLocation for Assignment { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for Assignment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name)?; + if self.append { + write!(f, "+")?; + } + write!(f, "={}", self.value) + } +} + +/// The target of an assignment. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AssignmentName { + /// A named variable. + VariableName(String), + /// An element in a named array. + ArrayElementName(String, String), +} + +impl AssignmentName { + /// Returns the base name of the assignment, without any array indexing + /// if present. + pub fn base_name(&self) -> &str { + match self { + Self::VariableName(name) => name, + Self::ArrayElementName(name, _index) => name, + } + } +} + +impl Display for AssignmentName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::VariableName(name) => write!(f, "{name}"), + Self::ArrayElementName(name, index) => { + write!(f, "{name}[{index}]") + } + } + } +} + +/// A value being assigned to a variable. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AssignmentValue { + /// A scalar (word) value. + Scalar(Word), + /// An array of elements. + Array(Vec<(Option, Word)>), +} + +impl Node for AssignmentValue {} + +impl SourceLocation for AssignmentValue { + fn location(&self) -> Option { + match self { + Self::Scalar(word) => word.location(), + Self::Array(words) => { + // TODO(source-location): account for the surrounding parentheses + let first = words.first().and_then(|(_key, value)| value.location()); + let last = words.last().and_then(|(_key, value)| value.location()); + maybe_location(first.as_ref(), last.as_ref()) + } + } + } +} + +impl Display for AssignmentValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Scalar(word) => write!(f, "{word}"), + Self::Array(words) => { + write!(f, "(")?; + for (i, value) in words.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + match value { + (Some(key), value) => write!(f, "[{key}]={value}")?, + (None, value) => write!(f, "{value}")?, + } + } + write!(f, ")") + } + } + } +} + +/// A list of I/O redirections to be applied to a command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct RedirectList(pub Vec); + +impl Node for RedirectList {} + +impl SourceLocation for RedirectList { + fn location(&self) -> Option { + let first = self.0.first().and_then(SourceLocation::location); + let last = self.0.last().and_then(SourceLocation::location); + maybe_location(first.as_ref(), last.as_ref()) + } +} + +impl Display for RedirectList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for item in &self.0 { + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// A file descriptor number. +pub type IoFd = i32; + +/// An I/O redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoRedirect { + /// Redirection to a file. + File(Option, IoFileRedirectKind, IoFileRedirectTarget), + /// Redirection from a here-document. + HereDocument(Option, IoHereDocument), + /// Redirection from a here-string. + HereString(Option, Word), + /// Redirection of both standard output and standard error (with optional append). + OutputAndError(Word, bool), +} + +impl Node for IoRedirect {} + +impl SourceLocation for IoRedirect { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for IoRedirect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::File(fd_num, kind, target) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "{kind} {target}")?; + } + Self::OutputAndError(target, append) => { + write!(f, "&>")?; + if *append { + write!(f, ">")?; + } + write!(f, " {target}")?; + } + Self::HereDocument(fd_num, here_doc) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "<<{here_doc}")?; + } + Self::HereString(fd_num, s) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "<<< {s}")?; + } + } + + Ok(()) + } +} + +/// Kind of file I/O redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoFileRedirectKind { + /// Read (`<`). + Read, + /// Write (`>`). + Write, + /// Append (`>>`). + Append, + /// Read and write (`<>`). + ReadAndWrite, + /// Clobber (`>|`). + Clobber, + /// Duplicate input (`<&`). + DuplicateInput, + /// Duplicate output (`>&`). + DuplicateOutput, +} + +impl Display for IoFileRedirectKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "<"), + Self::Write => write!(f, ">"), + Self::Append => write!(f, ">>"), + Self::ReadAndWrite => write!(f, "<>"), + Self::Clobber => write!(f, ">|"), + Self::DuplicateInput => write!(f, "<&"), + Self::DuplicateOutput => write!(f, ">&"), + } + } +} + +/// Target for an I/O file redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoFileRedirectTarget { + /// Path to a file. + Filename(Word), + /// File descriptor number. + Fd(IoFd), + /// Process substitution: substitution with the results of executing the given + /// command in a subshell. + ProcessSubstitution(ProcessSubstitutionKind, SubshellCommand), + /// Item to duplicate in a word redirection. After expansion, this could be a + /// filename, a file descriptor, or a file descriptor and a "-" to indicate + /// requested closure. + Duplicate(Word), +} + +impl Display for IoFileRedirectTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Filename(word) => write!(f, "{word}"), + Self::Fd(fd) => write!(f, "{fd}"), + Self::ProcessSubstitution(kind, subshell_command) => { + write!(f, "{kind}{subshell_command}") + } + Self::Duplicate(word) => write!(f, "{word}"), + } + } +} + +/// Represents an I/O here document. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct IoHereDocument { + /// Whether to remove leading tabs from the here document. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub remove_tabs: bool, + /// Whether to basic-expand the contents of the here document. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub requires_expansion: bool, + /// The delimiter marking the end of the here document. + pub here_end: Word, + /// The contents of the here document. + pub doc: Word, +} + +impl Node for IoHereDocument {} + +impl SourceLocation for IoHereDocument { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for IoHereDocument { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.remove_tabs { + write!(f, "-")?; + } + + writeln!(f, "{}", self.here_end)?; + write!(f, "{}", self.doc)?; + writeln!(f, "{}", self.here_end)?; + + Ok(()) + } +} + +/// A (non-extended) test expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum TestExpr { + /// Always evaluates to false. + False, + /// A literal string. + Literal(String), + /// Logical AND operation on two nested expressions. + And(Box, Box), + /// Logical OR operation on two nested expressions. + Or(Box, Box), + /// Logical NOT operation on a nested expression. + Not(Box), + /// A parenthesized expression. + Parenthesized(Box), + /// A unary test operation. + UnaryTest(UnaryPredicate, String), + /// A binary test operation. + BinaryTest(BinaryPredicate, String, String), +} + +impl Node for TestExpr {} + +impl SourceLocation for TestExpr { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for TestExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::False => Ok(()), + Self::Literal(s) => write!(f, "{s}"), + Self::And(left, right) => write!(f, "{left} -a {right}"), + Self::Or(left, right) => write!(f, "{left} -o {right}"), + Self::Not(expr) => write!(f, "! {expr}"), + Self::Parenthesized(expr) => write!(f, "( {expr} )"), + Self::UnaryTest(pred, word) => write!(f, "{pred} {word}"), + Self::BinaryTest(left, op, right) => write!(f, "{left} {op} {right}"), + } + } +} + +/// An extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ExtendedTestExpr { + /// Logical AND operation on two nested expressions. + And(Box, Box), + /// Logical OR operation on two nested expressions. + Or(Box, Box), + /// Logical NOT operation on a nested expression. + Not(Box), + /// A parenthesized expression. + Parenthesized(Box), + /// A unary test operation. + UnaryTest(UnaryPredicate, Word), + /// A binary test operation. + BinaryTest(BinaryPredicate, Word, Word), +} + +impl Display for ExtendedTestExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::And(left, right) => { + write!(f, "{left} && {right}") + } + Self::Or(left, right) => { + write!(f, "{left} || {right}") + } + Self::Not(expr) => { + write!(f, "! {expr}") + } + Self::Parenthesized(expr) => { + write!(f, "( {expr} )") + } + Self::UnaryTest(pred, word) => { + write!(f, "{pred} {word}") + } + Self::BinaryTest(pred, left, right) => { + write!(f, "{left} {pred} {right}") + } + } + } +} + +/// An extended test expression command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ExtendedTestExprCommand { + /// The extended test expression + pub expr: ExtendedTestExpr, + /// Location of the expression + pub loc: SourceSpan, +} + +impl Node for ExtendedTestExprCommand {} + +impl SourceLocation for ExtendedTestExprCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ExtendedTestExprCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.expr.fmt(f) + } +} + +/// A unary predicate usable in an extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryPredicate { + /// Computes if the operand is a path to an existing file. + FileExists, + /// Computes if the operand is a path to an existing block device file. + FileExistsAndIsBlockSpecialFile, + /// Computes if the operand is a path to an existing character device file. + FileExistsAndIsCharSpecialFile, + /// Computes if the operand is a path to an existing directory. + FileExistsAndIsDir, + /// Computes if the operand is a path to an existing regular file. + FileExistsAndIsRegularFile, + /// Computes if the operand is a path to an existing file with the setgid bit set. + FileExistsAndIsSetgid, + /// Computes if the operand is a path to an existing symbolic link. + FileExistsAndIsSymlink, + /// Computes if the operand is a path to an existing file with the sticky bit set. + FileExistsAndHasStickyBit, + /// Computes if the operand is a path to an existing FIFO file. + FileExistsAndIsFifo, + /// Computes if the operand is a path to an existing file that is readable. + FileExistsAndIsReadable, + /// Computes if the operand is a path to an existing file with a non-zero length. + FileExistsAndIsNotZeroLength, + /// Computes if the operand is a file descriptor that is an open terminal. + FdIsOpenTerminal, + /// Computes if the operand is a path to an existing file with the setuid bit set. + FileExistsAndIsSetuid, + /// Computes if the operand is a path to an existing file that is writable. + FileExistsAndIsWritable, + /// Computes if the operand is a path to an existing file that is executable. + FileExistsAndIsExecutable, + /// Computes if the operand is a path to an existing file owned by the current context's + /// effective group ID. + FileExistsAndOwnedByEffectiveGroupId, + /// Computes if the operand is a path to an existing file that has been modified since last + /// being read. + FileExistsAndModifiedSinceLastRead, + /// Computes if the operand is a path to an existing file owned by the current context's + /// effective user ID. + FileExistsAndOwnedByEffectiveUserId, + /// Computes if the operand is a path to an existing socket file. + FileExistsAndIsSocket, + /// Computes if the operand is a 'set -o' option that is enabled. + ShellOptionEnabled, + /// Computes if the operand names a shell variable that is set and assigned a value. + ShellVariableIsSetAndAssigned, + /// Computes if the operand names a shell variable that is set and of nameref type. + ShellVariableIsSetAndNameRef, + /// Computes if the operand is a string with zero length. + StringHasZeroLength, + /// Computes if the operand is a string with non-zero length. + StringHasNonZeroLength, +} + +impl Display for UnaryPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FileExists => write!(f, "-e"), + Self::FileExistsAndIsBlockSpecialFile => write!(f, "-b"), + Self::FileExistsAndIsCharSpecialFile => write!(f, "-c"), + Self::FileExistsAndIsDir => write!(f, "-d"), + Self::FileExistsAndIsRegularFile => write!(f, "-f"), + Self::FileExistsAndIsSetgid => write!(f, "-g"), + Self::FileExistsAndIsSymlink => write!(f, "-h"), + Self::FileExistsAndHasStickyBit => write!(f, "-k"), + Self::FileExistsAndIsFifo => write!(f, "-p"), + Self::FileExistsAndIsReadable => write!(f, "-r"), + Self::FileExistsAndIsNotZeroLength => write!(f, "-s"), + Self::FdIsOpenTerminal => write!(f, "-t"), + Self::FileExistsAndIsSetuid => write!(f, "-u"), + Self::FileExistsAndIsWritable => write!(f, "-w"), + Self::FileExistsAndIsExecutable => write!(f, "-x"), + Self::FileExistsAndOwnedByEffectiveGroupId => write!(f, "-G"), + Self::FileExistsAndModifiedSinceLastRead => write!(f, "-N"), + Self::FileExistsAndOwnedByEffectiveUserId => write!(f, "-O"), + Self::FileExistsAndIsSocket => write!(f, "-S"), + Self::ShellOptionEnabled => write!(f, "-o"), + Self::ShellVariableIsSetAndAssigned => write!(f, "-v"), + Self::ShellVariableIsSetAndNameRef => write!(f, "-R"), + Self::StringHasZeroLength => write!(f, "-z"), + Self::StringHasNonZeroLength => write!(f, "-n"), + } + } +} + +/// A binary predicate usable in an extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BinaryPredicate { + /// Computes if two files refer to the same device and inode numbers. + FilesReferToSameDeviceAndInodeNumbers, + /// Computes if the left file is newer than the right, or exists when the right does not. + LeftFileIsNewerOrExistsWhenRightDoesNot, + /// Computes if the left file is older than the right, or does not exist when the right does. + LeftFileIsOlderOrDoesNotExistWhenRightDoes, + /// Computes if a string exactly matches a pattern. + StringExactlyMatchesPattern, + /// Computes if a string does not exactly match a pattern. + StringDoesNotExactlyMatchPattern, + /// Computes if a string matches a regular expression. + StringMatchesRegex, + /// Computes if a string exactly matches another string. + StringExactlyMatchesString, + /// Computes if a string does not exactly match another string. + StringDoesNotExactlyMatchString, + /// Computes if a string contains a substring. + StringContainsSubstring, + /// Computes if the left value sorts before the right. + LeftSortsBeforeRight, + /// Computes if the left value sorts after the right. + LeftSortsAfterRight, + /// Computes if two values are equal via arithmetic comparison. + ArithmeticEqualTo, + /// Computes if two values are not equal via arithmetic comparison. + ArithmeticNotEqualTo, + /// Computes if the left value is less than the right via arithmetic comparison. + ArithmeticLessThan, + /// Computes if the left value is less than or equal to the right via arithmetic comparison. + ArithmeticLessThanOrEqualTo, + /// Computes if the left value is greater than the right via arithmetic comparison. + ArithmeticGreaterThan, + /// Computes if the left value is greater than or equal to the right via arithmetic comparison. + ArithmeticGreaterThanOrEqualTo, +} + +impl Display for BinaryPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FilesReferToSameDeviceAndInodeNumbers => write!(f, "-ef"), + Self::LeftFileIsNewerOrExistsWhenRightDoesNot => write!(f, "-nt"), + Self::LeftFileIsOlderOrDoesNotExistWhenRightDoes => write!(f, "-ot"), + Self::StringExactlyMatchesPattern => write!(f, "=="), + Self::StringDoesNotExactlyMatchPattern => write!(f, "!="), + Self::StringMatchesRegex => write!(f, "=~"), + Self::StringContainsSubstring => write!(f, "=~"), + Self::StringExactlyMatchesString => write!(f, "=="), + Self::StringDoesNotExactlyMatchString => write!(f, "!="), + Self::LeftSortsBeforeRight => write!(f, "<"), + Self::LeftSortsAfterRight => write!(f, ">"), + Self::ArithmeticEqualTo => write!(f, "-eq"), + Self::ArithmeticNotEqualTo => write!(f, "-ne"), + Self::ArithmeticLessThan => write!(f, "-lt"), + Self::ArithmeticLessThanOrEqualTo => write!(f, "-le"), + Self::ArithmeticGreaterThan => write!(f, "-gt"), + Self::ArithmeticGreaterThanOrEqualTo => write!(f, "-ge"), + } + } +} + +/// Represents a shell word. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Word { + /// Raw text of the word. + pub value: String, + /// Location of the word + pub loc: Option, +} + +impl Node for Word {} + +impl SourceLocation for Word { + fn location(&self) -> Option { + self.loc.clone() + } +} + +impl Display for Word { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +impl From<&tokenizer::Token> for Word { + fn from(t: &tokenizer::Token) -> Self { + match t { + tokenizer::Token::Word(value, loc) => Self { + value: value.clone(), + loc: Some(loc.clone()), + }, + tokenizer::Token::Operator(value, loc) => Self { + value: value.clone(), + loc: Some(loc.clone()), + }, + } + } +} + +impl From for Word { + fn from(s: String) -> Self { + Self { + value: s, + loc: None, + } + } +} + +impl AsRef for Word { + fn as_ref(&self) -> &str { + &self.value + } +} + +impl Word { + /// Constructs a new `Word` from a given string. + pub fn new(s: &str) -> Self { + Self { + value: s.to_owned(), + loc: None, + } + } + + /// Constructs a new `Word` from a given string and location. + pub fn with_location(s: &str, loc: &SourceSpan) -> Self { + Self { + value: s.to_owned(), + loc: Some(loc.to_owned()), + } + } + + /// Returns the raw text of the word, consuming the `Word`. + pub fn flatten(&self) -> String { + self.value.clone() + } +} + +/// Encapsulates an unparsed arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct UnexpandedArithmeticExpr { + /// The raw text of the expression. + pub value: String, +} + +impl Display for UnexpandedArithmeticExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +/// An arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ArithmeticExpr { + /// A literal integer value. + Literal(i64), + /// A dereference of a variable or array element. + Reference(ArithmeticTarget), + /// A unary operation on an the result of a given nested expression. + UnaryOp(UnaryOperator, Box), + /// A binary operation on two nested expressions. + BinaryOp(BinaryOperator, Box, Box), + /// A ternary conditional expression. + Conditional(Box, Box, Box), + /// An assignment operation. + Assignment(ArithmeticTarget, Box), + /// A binary assignment operation. + BinaryAssignment(BinaryOperator, ArithmeticTarget, Box), + /// A unary assignment operation. + UnaryAssignment(UnaryAssignmentOperator, ArithmeticTarget), +} + +impl Node for ArithmeticExpr {} + +impl SourceLocation for ArithmeticExpr { + fn location(&self) -> Option { + // TODO(source-location): complete and add loc for literal + None + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for ArithmeticExpr { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let variant = u.choose(&[ + "Literal", + "Reference", + "UnaryOp", + "BinaryOp", + "Conditional", + "Assignment", + "BinaryAssignment", + "UnaryAssignment", + ])?; + + match *variant { + "Literal" => Ok(Self::Literal(i64::arbitrary(u)?)), + "Reference" => Ok(Self::Reference(ArithmeticTarget::arbitrary(u)?)), + "UnaryOp" => Ok(Self::UnaryOp( + UnaryOperator::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "BinaryOp" => Ok(Self::BinaryOp( + BinaryOperator::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + )), + "Conditional" => Ok(Self::Conditional( + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + )), + "Assignment" => Ok(Self::Assignment( + ArithmeticTarget::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "BinaryAssignment" => Ok(Self::BinaryAssignment( + BinaryOperator::arbitrary(u)?, + ArithmeticTarget::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "UnaryAssignment" => Ok(Self::UnaryAssignment( + UnaryAssignmentOperator::arbitrary(u)?, + ArithmeticTarget::arbitrary(u)?, + )), + _ => unreachable!(), + } + } +} + +impl Display for ArithmeticExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Literal(literal) => write!(f, "{literal}"), + Self::Reference(target) => write!(f, "{target}"), + Self::UnaryOp(op, operand) => write!(f, "{op}{operand}"), + Self::BinaryOp(op, left, right) => { + if matches!(op, BinaryOperator::Comma) { + write!(f, "{left}{op} {right}") + } else { + write!(f, "{left} {op} {right}") + } + } + Self::Conditional(condition, if_branch, else_branch) => { + write!(f, "{condition} ? {if_branch} : {else_branch}") + } + Self::Assignment(target, value) => write!(f, "{target} = {value}"), + Self::BinaryAssignment(op, target, operand) => { + write!(f, "{target} {op}= {operand}") + } + Self::UnaryAssignment(op, target) => match op { + UnaryAssignmentOperator::PrefixIncrement + | UnaryAssignmentOperator::PrefixDecrement => write!(f, "{op}{target}"), + UnaryAssignmentOperator::PostfixIncrement + | UnaryAssignmentOperator::PostfixDecrement => write!(f, "{target}{op}"), + }, + } + } +} + +/// A binary arithmetic operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BinaryOperator { + /// Exponentiation (e.g., `x ** y`). + Power, + /// Multiplication (e.g., `x * y`). + Multiply, + /// Division (e.g., `x / y`). + Divide, + /// Modulo (e.g., `x % y`). + Modulo, + /// Comma (e.g., `x, y`). + Comma, + /// Addition (e.g., `x + y`). + Add, + /// Subtraction (e.g., `x - y`). + Subtract, + /// Bitwise left shift (e.g., `x << y`). + ShiftLeft, + /// Bitwise right shift (e.g., `x >> y`). + ShiftRight, + /// Less than (e.g., `x < y`). + LessThan, + /// Less than or equal to (e.g., `x <= y`). + LessThanOrEqualTo, + /// Greater than (e.g., `x > y`). + GreaterThan, + /// Greater than or equal to (e.g., `x >= y`). + GreaterThanOrEqualTo, + /// Equals (e.g., `x == y`). + Equals, + /// Not equals (e.g., `x != y`). + NotEquals, + /// Bitwise AND (e.g., `x & y`). + BitwiseAnd, + /// Bitwise exclusive OR (xor) (e.g., `x ^ y`). + BitwiseXor, + /// Bitwise OR (e.g., `x | y`). + BitwiseOr, + /// Logical AND (e.g., `x && y`). + LogicalAnd, + /// Logical OR (e.g., `x || y`). + LogicalOr, +} + +impl Display for BinaryOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Power => write!(f, "**"), + Self::Multiply => write!(f, "*"), + Self::Divide => write!(f, "/"), + Self::Modulo => write!(f, "%"), + Self::Comma => write!(f, ","), + Self::Add => write!(f, "+"), + Self::Subtract => write!(f, "-"), + Self::ShiftLeft => write!(f, "<<"), + Self::ShiftRight => write!(f, ">>"), + Self::LessThan => write!(f, "<"), + Self::LessThanOrEqualTo => write!(f, "<="), + Self::GreaterThan => write!(f, ">"), + Self::GreaterThanOrEqualTo => write!(f, ">="), + Self::Equals => write!(f, "=="), + Self::NotEquals => write!(f, "!="), + Self::BitwiseAnd => write!(f, "&"), + Self::BitwiseXor => write!(f, "^"), + Self::BitwiseOr => write!(f, "|"), + Self::LogicalAnd => write!(f, "&&"), + Self::LogicalOr => write!(f, "||"), + } + } +} + +/// A unary arithmetic operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryOperator { + /// Unary plus (e.g., `+x`). + UnaryPlus, + /// Unary minus (e.g., `-x`). + UnaryMinus, + /// Bitwise not (e.g., `~x`). + BitwiseNot, + /// Logical not (e.g., `!x`). + LogicalNot, +} + +impl Display for UnaryOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnaryPlus => write!(f, "+"), + Self::UnaryMinus => write!(f, "-"), + Self::BitwiseNot => write!(f, "~"), + Self::LogicalNot => write!(f, "!"), + } + } +} + +/// A unary arithmetic assignment operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryAssignmentOperator { + /// Prefix increment (e.g., `++x`). + PrefixIncrement, + /// Prefix increment (e.g., `--x`). + PrefixDecrement, + /// Postfix increment (e.g., `x++`). + PostfixIncrement, + /// Postfix decrement (e.g., `x--`). + PostfixDecrement, +} + +impl Display for UnaryAssignmentOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PrefixIncrement => write!(f, "++"), + Self::PrefixDecrement => write!(f, "--"), + Self::PostfixIncrement => write!(f, "++"), + Self::PostfixDecrement => write!(f, "--"), + } + } +} + +/// Identifies the target of an arithmetic assignment expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ArithmeticTarget { + /// A named variable. + Variable(String), + /// An element in an array. + ArrayElement(String, Box), +} + +impl Node for ArithmeticTarget {} + +impl SourceLocation for ArithmeticTarget { + fn location(&self) -> Option { + // TODO(source-location): complete and add loc + None + } +} + +impl Display for ArithmeticTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Variable(name) => write!(f, "{name}"), + Self::ArrayElement(name, index) => write!(f, "{name}[{index}]"), + } + } +} + +#[cfg(test)] +#[allow(clippy::panic)] +mod tests { + use super::*; + use crate::{ParserOptions, SourcePosition}; + use std::io::BufReader; + + fn parse(input: &str) -> Program { + let reader = BufReader::new(input.as_bytes()); + let mut parser = crate::Parser::new(reader, &ParserOptions::default()); + parser.parse_program().unwrap() + } + + #[test] + fn program_source_loc() { + const INPUT: &str = r"echo hi +echo there +"; + + let loc = parse(INPUT).location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 2, + column: 11, + index: 18 + } + ); + } + + #[test] + fn function_def_loc() { + const INPUT: &str = r"my_func() { + echo hi + echo there +} + +my_func +"; + + let program = parse(INPUT); + + let Command::Function(func_def) = &program.complete_commands[0].0[0].0.first.seq[0] else { + panic!("expected function definition"); + }; + + let loc = func_def.location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 4, + column: 2, + index: 36 + } + ); + } + + #[test] + fn simple_cmd_loc() { + const INPUT: &str = r"var=value somecmd arg1 arg2 +"; + + let program = parse(INPUT); + + let Command::Simple(cmd) = &program.complete_commands[0].0[0].0.first.seq[0] else { + panic!("expected function definition"); + }; + + let loc = cmd.location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 1, + column: 28, + index: 27 + } + ); + } +} diff --git a/userland/brush/source/brush-parser/src/error.rs b/userland/brush/source/brush-parser/src/error.rs new file mode 100644 index 0000000..111809b --- /dev/null +++ b/userland/brush/source/brush-parser/src/error.rs @@ -0,0 +1,132 @@ +use crate::tokenizer; + +/// Represents an error that occurred while parsing tokens. +#[derive(thiserror::Error, Debug)] +pub enum ParseError { + /// A parsing error occurred near the given position. + #[error("syntax error at line {} col {}", .0.line, .0.column)] + ParsingNear(crate::SourcePosition), + + /// A parsing error occurred at the end of the input. + #[error("syntax error at end of input")] + ParsingAtEndOfInput, + + /// An error occurred while tokenizing the input stream. + #[error("{} (detected near {})", .inner, .position.as_ref().map_or_else(|| String::from(""), |p| std::format!("line {} col {}", p.line, p.column)))] + Tokenizing { + /// The inner error. + inner: tokenizer::TokenizerError, + /// Optionally provides the position of the error. + position: Option, + }, +} + +#[cfg(feature = "diagnostics")] +#[allow(clippy::cast_sign_loss)] +#[allow(unused)] // Workaround unused warnings in nightly versions of the compiler +pub mod miette { + use super::ParseError; + use miette::SourceOffset; + + impl ParseError { + /// Convert the original error to one miette can pretty print + pub fn to_pretty_error(self, input: impl Into) -> PrettyError { + let input = input.into(); + let location = match self { + Self::ParsingNear(ref pos) => { + Some(SourceOffset::from_location(&input, pos.line, pos.column)) + } + Self::Tokenizing { ref position, .. } => position + .as_ref() + .map(|p| SourceOffset::from_location(&input, p.line, p.column)), + Self::ParsingAtEndOfInput => { + Some(SourceOffset::from_location(&input, usize::MAX, usize::MAX)) + } + }; + + PrettyError { + cause: self, + input, + location, + } + } + } + + /// Represents an error that occurred while parsing tokens. + #[derive(thiserror::Error, Debug, miette::Diagnostic)] + #[error("Cannot parse the input script")] + pub struct PrettyError { + cause: ParseError, + #[source_code] + input: String, + #[label("{cause}")] + location: Option, + } +} + +/// Represents a parsing error with its location information +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct ParseErrorLocation { + #[from] + inner: peg::error::ParseError, +} + +/// Represents an error that occurred while parsing a word. +#[derive(Debug, thiserror::Error)] +pub enum WordParseError { + /// An error occurred while parsing an arithmetic expression. + #[error("failed to parse arithmetic expression")] + ArithmeticExpression(ParseErrorLocation), + + /// An error occurred while parsing a shell pattern. + #[error("failed to parse pattern")] + Pattern(ParseErrorLocation), + + /// An error occurred while parsing a prompt string. + #[error("failed to parse prompt string")] + Prompt(ParseErrorLocation), + + /// An error occurred while parsing a parameter. + #[error("failed to parse parameter '{0}'")] + Parameter(String, ParseErrorLocation), + + /// An error occurred while parsing for brace expansion. + #[error("failed to parse for brace expansion: '{0}'")] + BraceExpansion(String, ParseErrorLocation), + + /// An error occurred while parsing a word. + #[error("failed to parse word '{0}'")] + Word(String, ParseErrorLocation), +} + +/// Represents an error that occurred while parsing a (non-extended) test command. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct TestCommandParseError(#[from] peg::error::ParseError); + +/// Represents an error that occurred while parsing a key-binding specification. +#[derive(Debug, thiserror::Error)] +pub enum BindingParseError { + /// An unknown error occurred while parsing a key-binding specification. + #[error("unknown error while parsing key-binding: '{0}'")] + Unknown(String), + + /// A key code was missing from the key-binding specification. + #[error("missing key code in key-binding")] + MissingKeyCode, +} + +pub(crate) fn convert_peg_parse_error( + err: &peg::error::ParseError, + tokens: &[crate::Token], +) -> ParseError { + let approx_token_index = err.location; + + if approx_token_index < tokens.len() { + let token = &tokens[approx_token_index]; + ParseError::ParsingNear((*token.location().start).clone()) + } else { + ParseError::ParsingAtEndOfInput + } +} diff --git a/userland/brush/source/brush-parser/src/lib.rs b/userland/brush/source/brush-parser/src/lib.rs new file mode 100644 index 0000000..1935e62 --- /dev/null +++ b/userland/brush/source/brush-parser/src/lib.rs @@ -0,0 +1,37 @@ +//! Implements a tokenizer and parsers for POSIX / bash shell syntax. + +// TODO(unwrap): remove or scope this allow attribute +#![allow(clippy::unwrap_used)] + +pub mod arithmetic; +pub mod ast; +pub mod pattern; +pub mod prompt; +pub mod readline_binding; +pub mod test_command; +pub mod word; + +mod error; +mod parser; +mod source; +mod tokenizer; + +#[cfg(test)] +mod snapshot_tests; + +pub use error::{ + BindingParseError, ParseError, ParseErrorLocation, TestCommandParseError, WordParseError, +}; + +#[cfg(feature = "diagnostics")] +pub use error::miette::PrettyError; + +#[cfg(feature = "winnow-parser")] +pub use parser::winnow_str; +pub use parser::{Parser, ParserBuilder, ParserImpl, ParserOptions, SourceInfo, parse_tokens}; + +pub use source::{SourcePosition, SourcePositionOffset, SourceSpan}; +pub use tokenizer::{ + Token, TokenLocation, TokenizerError, TokenizerOptions, tokenize_str, + tokenize_str_with_options, uncached_tokenize_str, unquote_str, +}; diff --git a/userland/brush/source/brush-parser/src/parser/mod.rs b/userland/brush/source/brush-parser/src/parser/mod.rs new file mode 100644 index 0000000..fc84324 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/mod.rs @@ -0,0 +1,276 @@ +use std::path::PathBuf; + +use bon::bon; + +use crate::ast; +use crate::tokenizer::{Token, TokenEndReason, Tokenizer, TokenizerOptions, Tokens}; + +pub mod peg; +#[cfg(feature = "winnow-parser")] +pub mod winnow_str; + +/// Parser implementation to use +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Default)] +pub enum ParserImpl { + /// PEG-based parser (token-based) + #[default] + Peg, + /// Winnow-based parser (string-based, direct) + #[cfg(feature = "winnow-parser")] + Winnow, +} + +/// Options used to control the behavior of the parser. +#[derive(Clone, Eq, Hash, PartialEq)] +pub struct ParserOptions { + /// Whether or not to enable extended globbing (a.k.a. `extglob`). + pub enable_extended_globbing: bool, + /// Whether or not to enable POSIX compliance mode. + pub posix_mode: bool, + /// Whether or not to enable maximal compatibility with the `sh` shell. + pub sh_mode: bool, + /// Whether or not to perform tilde expansion for tildes at the start of words. + pub tilde_expansion_at_word_start: bool, + /// Whether or not to perform tilde expansion for tildes after colons. + pub tilde_expansion_after_colon: bool, + /// Select the parser internal implementation + pub parser_impl: ParserImpl, +} + +impl Default for ParserOptions { + fn default() -> Self { + Self { + enable_extended_globbing: true, + posix_mode: false, + sh_mode: false, + tilde_expansion_at_word_start: true, + tilde_expansion_after_colon: false, + parser_impl: ParserImpl::default(), + } + } +} + +impl ParserOptions { + /// Returns the tokenizer options implied by these parser options. + pub const fn tokenizer_options(&self) -> TokenizerOptions { + TokenizerOptions { + enable_extended_globbing: self.enable_extended_globbing, + posix_mode: self.posix_mode, + sh_mode: self.sh_mode, + } + } +} + +/// Information about the source of tokens. +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] +pub struct SourceInfo { + /// The source of the tokens. + pub source: String, +} + +impl From for SourceInfo { + fn from(path: PathBuf) -> Self { + Self { + source: path.to_string_lossy().to_string(), + } + } +} + +/// Implements parsing for shell programs. +pub struct Parser { + /// The reader to use for input + reader: R, + /// Parsing options + options: ParserOptions, +} + +#[bon] +impl Parser { + /// + /// # Arguments + /// + /// * `reader` - The reader to use for input. + /// * `options` - The options to use when parsing. + pub fn new(reader: R, options: &ParserOptions) -> Self { + Self { + reader, + options: options.clone(), + } + } + + /// Create a new parser instance through a builder + #[builder( + finish_fn(doc { + /// Instantiate a parser with the provided reader as input + }) + )] + pub const fn builder( + /// The reader to use for input + #[builder(finish_fn)] + reader: R, + + #[builder(default = true)] + /// Whether or not to enable extended globbing (a.k.a. `extglob`). + enable_extended_globbing: bool, + #[builder(default = false)] + /// Whether or not to enable POSIX compliance mode. + posix_mode: bool, + #[builder(default = false)] + /// Whether or not to enable maximal compatibility with the `sh` shell. + sh_mode: bool, + #[builder(default = true)] + /// Whether or not to perform tilde expansion for tildes at the start of words. + tilde_expansion_at_word_start: bool, + #[builder(default = false)] + /// Whether or not to perform tilde expansion for tildes after colons. + tilde_expansion_after_colon: bool, + #[builder(default)] + /// Select the parser internal implementation + parser_impl: ParserImpl, + ) -> Self { + let options = ParserOptions { + enable_extended_globbing, + posix_mode, + sh_mode, + tilde_expansion_at_word_start, + tilde_expansion_after_colon, + parser_impl, + }; + Self { reader, options } + } + + /// Parses the input into an abstract syntax tree (AST) of a shell program. + pub fn parse_program(&mut self) -> Result { + // + // References: + // * https://www.gnu.org/software/bash/manual/bash.html#Shell-Syntax + // * https://mywiki.wooledge.org/BashParser + // * https://aosabook.org/en/v1/bash.html + // * https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html + // + match self.options.parser_impl { + ParserImpl::Peg => { + let tokens = self.tokenize()?; + parse_tokens(&tokens, &self.options) + } + #[cfg(feature = "winnow-parser")] + ParserImpl::Winnow => { + // Read entire input to string for winnow_str parser + let mut input_str = String::new(); + std::io::Read::read_to_string(&mut self.reader, &mut input_str).map_err(|e| { + crate::error::ParseError::Tokenizing { + inner: crate::tokenizer::TokenizerError::from(e), + position: None, + } + })?; + + winnow_str::parse_program(&input_str, &self.options, &SourceInfo::default()) + .map_err(|_e| { + // Convert winnow error to ParseError + // TODO: Extract position information from winnow error + crate::error::ParseError::ParsingAtEndOfInput + }) + } + } + } + + /// Parses a function definition body from the input. The body is expected to be + /// preceded by "()", but no function name. + pub fn parse_function_parens_and_body( + &mut self, + ) -> Result { + let tokens = self.tokenize()?; + let parse_result = + peg::token_parser::function_parens_and_body(&Tokens { tokens: &tokens }, &self.options); + parse_result_to_error(parse_result, &tokens) + } + + fn tokenize(&mut self) -> Result, crate::error::ParseError> { + // First we tokenize the input, according to the policy implied by provided options. + let mut tokenizer = Tokenizer::new(&mut self.reader, &self.options.tokenizer_options()); + + tracing::debug!(target: "tokenize", "Tokenizing..."); + + let mut tokens = vec![]; + loop { + let result = match tokenizer.next_token() { + Ok(result) => result, + Err(e) => { + return Err(crate::error::ParseError::Tokenizing { + inner: e, + position: tokenizer.current_location(), + }); + } + }; + + let reason = result.reason; + if let Some(token) = result.token { + tracing::debug!(target: "tokenize", "TOKEN {}: {:?} {reason:?}", tokens.len(), token); + tokens.push(token); + } + + if matches!(reason, TokenEndReason::EndOfInput) { + break; + } + } + + tracing::debug!(target: "tokenize", " => {} token(s)", tokens.len()); + + Ok(tokens) + } +} + +/// Parses a sequence of tokens into the abstract syntax tree (AST) of a shell program. +/// +/// # Arguments +/// +/// * `tokens` - The tokens to parse. +/// * `options` - The options to use when parsing. +pub fn parse_tokens( + tokens: &[Token], + options: &ParserOptions, +) -> Result { + let parse_result = peg::token_parser::program(&Tokens { tokens }, options); + parse_result_to_error(parse_result, tokens) +} + +/// Tokenizes and parses text as a compound assignment value, returning its element words. Returns +/// `None` if the text is not a well-formed compound value. +/// +/// # Arguments +/// +/// * `input` - The text to parse. +/// * `options` - The options to use when parsing. +pub(crate) fn parse_compound_assignment_value( + input: &str, + options: &ParserOptions, +) -> Option> { + let mut parser = Parser::new(input.as_bytes(), options); + let tokens = parser.tokenize().ok()?; + let tokens = Tokens { tokens: &tokens }; + let elements = peg::token_parser::compound_assignment_value(&tokens, options).ok()?; + Some(elements.into_iter().cloned().collect()) +} + +fn parse_result_to_error( + parse_result: Result>, + tokens: &[Token], +) -> Result +where + R: std::fmt::Debug, +{ + match parse_result { + Ok(program) => { + tracing::debug!(target: "parse", "PROG: {:?}", program); + Ok(program) + } + Err(parse_error) => { + tracing::debug!(target: "parse", "Parse error: {:?}", parse_error); + Err(crate::error::convert_peg_parse_error(&parse_error, tokens)) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/userland/brush/source/brush-parser/src/parser/peg.rs b/userland/brush/source/brush-parser/src/parser/peg.rs new file mode 100644 index 0000000..a6b9726 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/peg.rs @@ -0,0 +1,857 @@ +//! PEG-based parser implementation for shell scripts. + +use crate::SourceSpan; +use crate::ast::{self, SeparatorOperator, SourceLocation, maybe_location}; +use crate::tokenizer::Token; +use crate::word; + +use super::{ParserOptions, Tokens}; + +peg::parser! { + pub grammar token_parser<'a>(parser_options: &ParserOptions) for Tokens<'a> { + pub(crate) rule program() -> ast::Program = + linebreak() c:complete_commands() linebreak() { ast::Program { complete_commands: c } } / + linebreak() { ast::Program { complete_commands: vec![] } } + + rule complete_commands() -> Vec = + c:complete_command() ++ newline_list() + + rule complete_command() -> ast::CompleteCommand = + first:and_or() remainder:(s:separator_op() l:and_or() { (s, l) })* last_sep:separator_op()? { + let mut and_ors = vec![first]; + let mut seps = Vec::with_capacity(remainder.len()); + + for (sep, ao) in remainder { + seps.push(sep); + and_ors.push(ao); + } + + // N.B. We default to synchronous if no separator op is given. + seps.push(last_sep.unwrap_or(SeparatorOperator::Sequence)); + + let items = and_ors.into_iter().enumerate().map(|(i, ao)| ast::CompoundListItem(ao, seps[i].clone())).collect(); + + ast::CompoundList(items) + } + + rule and_or() -> ast::AndOrList = + first:pipeline() additional:_and_or_item()* { ast::AndOrList { first, additional } } + + rule _and_or_item() -> ast::AndOr = + op:_and_or_op() linebreak() p:pipeline() { op(p) } + + rule _and_or_op() -> fn(ast::Pipeline) -> ast::AndOr = + specific_operator("&&") { ast::AndOr::And } / + specific_operator("||") { ast::AndOr::Or } + + rule pipeline() -> ast::Pipeline = + timed:pipeline_timed()? bang:bang()* seq:pipe_sequence() {? + if timed.is_none() && bang.is_empty() && seq.is_empty() { + Err("empty pipeline") + } else { + let invert = bang.len() % 2 == 1; + Ok(ast::Pipeline { timed, bang: invert, seq }) + } + } + + rule pipeline_timed() -> ast::PipelineTimed = + non_posix_extensions_enabled() s:specific_word("time") posix_output:specific_word("-p")? { + let start = s.location(); + if let Some(end) = posix_output { + ast::PipelineTimed::TimedWithPosixOutput(SourceSpan::within(start, end.location())) + } else { + ast::PipelineTimed::Timed(start.to_owned()) + } + } + + rule bang() -> bool = specific_word("!") { true } + + pub(crate) rule pipe_sequence() -> Vec = + c:(c:command() r:&pipe_extension_redirection()? {? // check for `|&` without consuming the stream. + let mut c = c; + if r.is_some() { + add_pipe_extension_redirection(&mut c); + } + Ok(c) + }) ** (pipe_operator() linebreak()) { + c + } + + rule pipe_operator() = + specific_operator("|") / + pipe_extension_redirection() + + rule pipe_extension_redirection() -> &'input Token = + non_posix_extensions_enabled() p:specific_operator("|&") { p } + + // N.B. We needed to move the function definition branch up to avoid conflicts with array assignment syntax. + rule command() -> ast::Command = + f:function_definition() { ast::Command::Function(f) } / + c:simple_command() { ast::Command::Simple(c) } / + c:compound_command() r:redirect_list()? { ast::Command::Compound(c, r) } / + expected!("command") + + // N.B. The arithmetic command is a non-sh extension. + // N.B. The arithmetic for clause command is a non-sh extension. + // N.B. The extended test command is a non-sh extension. + pub(crate) rule compound_command() -> ast::CompoundCommand = + non_posix_extensions_enabled() a:arithmetic_command() { ast::CompoundCommand::Arithmetic(a) } / + non_posix_extensions_enabled() c:coproc_clause() { ast::CompoundCommand::Coprocess(c) } / + b:brace_group() { ast::CompoundCommand::BraceGroup(b) } / + s:subshell() { ast::CompoundCommand::Subshell(s) } / + f:for_clause() { ast::CompoundCommand::ForClause(f) } / + c:case_clause() { ast::CompoundCommand::CaseClause(c) } / + i:if_clause() { ast::CompoundCommand::IfClause(i) } / + w:while_clause() { ast::CompoundCommand::WhileClause(w) } / + u:until_clause() { ast::CompoundCommand::UntilClause(u) } / + non_posix_extensions_enabled() c:arithmetic_for_clause() { ast::CompoundCommand::ArithmeticForClause(c) } / + non_posix_extensions_enabled() c:extended_test_command() { ast::CompoundCommand::ExtendedTest(c) } / + expected!("compound command") + + pub(crate) rule arithmetic_command() -> ast::ArithmeticCommand = + start:specific_operator("(") open:specific_operator("(") expr:arithmetic_expression() close:specific_operator(")") end:specific_operator(")") {? + // As in bash, `((` and `))` only form an arithmetic command when each pair of + // parentheses touch. Otherwise (e.g., `( (cmd) )`) we decline, and the text is + // read as nested subshells instead. + if !locations_are_contiguous(start.location(), open.location()) + || !locations_are_contiguous(close.location(), end.location()) + { + return Err("arithmetic command"); + } + + let loc = SourceSpan::within( + start.location(), + end.location() + ); + Ok(ast::ArithmeticCommand { expr, loc }) + } + + pub(crate) rule arithmetic_expression() -> ast::UnexpandedArithmeticExpr = + raw_expr:$(arithmetic_expression_piece()*) { ast::UnexpandedArithmeticExpr { value: raw_expr } } + + rule arithmetic_expression_piece() = + // Allow a parenthesized expression (with matching opening and closing parens). + specific_operator("(") (!specific_operator(")") arithmetic_expression_piece())* specific_operator(")") {} / + // Otherwise consume any token that's neither the normal end of the entire arithmetic expression, nor an + // unexpected mismatched closing parenthesis. In the latter case, it may be that this really was never an + // arithmetic expression in the first place and we need to backtrack and instead try parsing as a subshell + // command instead. + !arithmetic_end() !specific_operator(")") [_] {} + + // The tokenizer reads `;;` as a single operator, so in the header of an arithmetic for + // loop, an expression must also stop in front of `;;`. + rule arithmetic_for_expression() -> ast::UnexpandedArithmeticExpr = + raw_expr:$((!specific_operator(";;") arithmetic_expression_piece())*) { ast::UnexpandedArithmeticExpr { value: raw_expr } } + + // TODO(arithmetic): evaluate arithmetic end; the semicolon is used in arithmetic for loops. + rule arithmetic_end() -> () = + specific_operator(")") specific_operator(")") {} / + specific_operator(";") {} + + rule subshell() -> ast::SubshellCommand = + start:specific_operator("(") list:compound_list() end:specific_operator(")") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::SubshellCommand { list, loc } + } + + rule compound_list() -> ast::CompoundList = + linebreak() first:and_or() remainder:(s:separator() l:and_or() { (s, l) })* last_sep:separator()? { + let mut and_ors = vec![first]; + let mut seps = Vec::with_capacity(remainder.len()); + + for (sep, ao) in remainder { + seps.push(sep.unwrap_or(SeparatorOperator::Sequence)); + and_ors.push(ao); + } + + // N.B. We default to synchronous if no separator op is given. + let last_sep = last_sep.unwrap_or(None); + seps.push(last_sep.unwrap_or(SeparatorOperator::Sequence)); + + let items = and_ors.into_iter().enumerate().map(|(i, ao)| ast::CompoundListItem(ao, seps[i].clone())).collect(); + + ast::CompoundList(items) + } + + rule for_clause() -> ast::ForClauseCommand = + s:specific_word("for") n:name() linebreak() _in() w:wordlist()? sequential_sep() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + ast::ForClauseCommand { variable_name: n.to_owned(), values: w, body: d, loc } + } / + s:specific_word("for") n:name() sequential_sep()? d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + ast::ForClauseCommand { variable_name: n.to_owned(), values: None, body: d, loc } + } + + // N.B. The arithmetic for loop is a non-sh extension. + rule arithmetic_for_clause() -> ast::ArithmeticForClauseCommand = + s:specific_word("for") + specific_operator("(") specific_operator("(") + initializer:arithmetic_for_expression()? + condition:arithmetic_for_condition() + updater:arithmetic_for_expression()? + specific_operator(")") specific_operator(")") + body:arithmetic_for_body() { + let start = s.location(); + let end = &body.loc; + let loc = SourceSpan::within(start, end); + ast::ArithmeticForClauseCommand { initializer, condition, updater, body, loc } + } + + // The condition of an arithmetic for loop, along with the `;` on each side of it. When the + // condition is empty and there is no space between the semicolons, the tokenizer has + // already combined them into a single `;;` operator. + rule arithmetic_for_condition() -> Option = + specific_operator(";") condition:arithmetic_expression()? specific_operator(";") { condition } / + specific_operator(";;") { Some(ast::UnexpandedArithmeticExpr { value: String::new() }) } + + rule arithmetic_for_body() -> ast::DoGroupCommand = + sequential_sep()? body:do_group() { body } / + body:brace_group() { ast::DoGroupCommand { list: body.list, loc: body.loc } } + + rule extended_test_command() -> ast::ExtendedTestExprCommand = + s:specific_word("[[") linebreak() expr:extended_test_expression() e:specific_word("]]") { + let start = s.location(); + let end = e.location(); + let loc = SourceSpan::within(start, end); + + ast::ExtendedTestExprCommand { expr, loc } + } + + // N.B. The placement of linebreak() here matches bash's handling of newlines + // within `[[ ]]` conditionals. Newlines are consumed (1) at the start of every + // term (after `[[`, `(`, `&&`, `||`, `!`), and (2) trailing after a *complete* + // binary test, unary-predicate test, or parenthesized expression -- but never + // after a bare-word string test (e.g. `[[ x \n ]]` is a syntax error in bash). + rule extended_test_expression() -> ast::ExtendedTestExpr = precedence! { + left:(@) specific_operator("||") linebreak() right:@ { ast::ExtendedTestExpr::Or(Box::from(left), Box::from(right)) } + -- + left:(@) specific_operator("&&") linebreak() right:@ { ast::ExtendedTestExpr::And(Box::from(left), Box::from(right)) } + -- + specific_word("!") linebreak() e:@ { ast::ExtendedTestExpr::Not(Box::from(e)) } + -- + specific_operator("(") linebreak() e:extended_test_expression() specific_operator(")") linebreak() { ast::ExtendedTestExpr::Parenthesized(Box::from(e)) } + -- + !extended_unary_predicate() e:extended_test_binary_predicate() linebreak() { e } + -- + p:extended_unary_predicate() f:extended_unary_operand() linebreak() { ast::ExtendedTestExpr::UnaryTest(p, ast::Word::from(f)) } + -- + !extended_unary_predicate() w:word() { ast::ExtendedTestExpr::UnaryTest(ast::UnaryPredicate::StringHasNonZeroLength, ast::Word::from(w)) } + } + + // An unquoted unary operator (e.g. `-z`) at the start of a test is always read as that + // operator, never as the left side of a binary test or as a bare string; this is why the + // binary and bare-word alternatives above are guarded. The operator takes the next word + // as its operand, even when that word looks like an operator itself (`[[ -z == ]]` tests + // the string "=="). Only the closing `]]` and operator tokens (`&&`, `)`, `<`, ...) can't + // be the operand; in those cases, the expression is a syntax error. + rule extended_unary_operand() -> &'input Token = + !specific_word("]]") w:word() { w } + + rule extended_test_binary_predicate() -> ast::ExtendedTestExpr = + // Arithmetic operators + left:word() specific_word("-eq") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ne") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticNotEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-lt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThan, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-le") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-gt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThan, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ge") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + // Non-arithmetic binary operators + left:word() specific_word("-ef") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-nt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ot") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes, ast::Word::from(left), ast::Word::from(right)) } / + left:word() (specific_word("==") / specific_word("=")) right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringExactlyMatchesPattern, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("!=") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringDoesNotExactlyMatchPattern, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("=~") right:regex_word() { + if right.value.starts_with(['\'', '\"']) { + // TODO(test): Confirm it ends with that too? + ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringContainsSubstring, ast::Word::from(left), right) + } else { + ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringMatchesRegex, ast::Word::from(left), right) + } + } / + left:word() specific_operator("<") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftSortsBeforeRight, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_operator(">") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftSortsAfterRight, ast::Word::from(left), ast::Word::from(right)) } + + rule extended_unary_predicate() -> ast::UnaryPredicate = + specific_word("-a") { ast::UnaryPredicate::FileExists } / + specific_word("-b") { ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile } / + specific_word("-c") { ast::UnaryPredicate::FileExistsAndIsCharSpecialFile } / + specific_word("-d") { ast::UnaryPredicate::FileExistsAndIsDir } / + specific_word("-e") { ast::UnaryPredicate::FileExists } / + specific_word("-f") { ast::UnaryPredicate::FileExistsAndIsRegularFile } / + specific_word("-g") { ast::UnaryPredicate::FileExistsAndIsSetgid } / + specific_word("-h") { ast::UnaryPredicate::FileExistsAndIsSymlink } / + specific_word("-k") { ast::UnaryPredicate::FileExistsAndHasStickyBit } / + specific_word("-n") { ast::UnaryPredicate::StringHasNonZeroLength } / + specific_word("-o") { ast::UnaryPredicate::ShellOptionEnabled } / + specific_word("-p") { ast::UnaryPredicate::FileExistsAndIsFifo } / + specific_word("-r") { ast::UnaryPredicate::FileExistsAndIsReadable } / + specific_word("-s") { ast::UnaryPredicate::FileExistsAndIsNotZeroLength } / + specific_word("-t") { ast::UnaryPredicate::FdIsOpenTerminal } / + specific_word("-u") { ast::UnaryPredicate::FileExistsAndIsSetuid } / + specific_word("-v") { ast::UnaryPredicate::ShellVariableIsSetAndAssigned } / + specific_word("-w") { ast::UnaryPredicate::FileExistsAndIsWritable } / + specific_word("-x") { ast::UnaryPredicate::FileExistsAndIsExecutable } / + specific_word("-z") { ast::UnaryPredicate::StringHasZeroLength } / + specific_word("-G") { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId } / + specific_word("-L") { ast::UnaryPredicate::FileExistsAndIsSymlink } / + specific_word("-N") { ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead } / + specific_word("-O") { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId } / + specific_word("-R") { ast::UnaryPredicate::ShellVariableIsSetAndNameRef } / + specific_word("-S") { ast::UnaryPredicate::FileExistsAndIsSocket } + + // N.B. For some reason we seem to need to allow a select subset + // of unescaped operators in regex words. + rule regex_word() -> ast::Word = + value:$((!specific_word("]]") regex_word_piece())+) { + ast::Word::from(value) + } + + rule regex_word_piece() = + word() {} / + specific_operator("|") {} / + specific_operator("(") parenthesized_regex_word()* specific_operator(")") {} + + rule parenthesized_regex_word() = + regex_word_piece() / + !specific_operator(")") !specific_operator("]]") [_] + + rule name() -> &'input str = + w:[Token::Word(_, _)] { w.to_str() } + + rule _in() -> () = + specific_word("in") { } + + rule wordlist() -> Vec = + (w:word() { ast::Word::from(w) })+ + + pub(crate) rule case_clause() -> ast::CaseClauseCommand = + start:specific_word("case") w:word() linebreak() _in() linebreak() first_items:case_item()* last_item:case_item_ns()? end:specific_word("esac") { + let mut cases = first_items; + + if let Some(last_item) = last_item { + cases.push(last_item); + } + + let loc = SourceSpan::within(start.location(), end.location()); + ast::CaseClauseCommand { value: ast::Word::from(w), cases, loc } + } + + pub(crate) rule case_item_ns() -> ast::CaseItem = + s:specific_operator("(")? p:pattern() specific_operator(")") c:compound_list() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = c.location(); + + let loc = maybe_location(start, end.as_ref()); + + ast::CaseItem { patterns: p, cmd: Some(c), post_action: ast::CaseItemPostAction::ExitCase, loc } + } / + s:specific_operator("(")? p:pattern() e:specific_operator(")") linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(e.location()); + + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: None, post_action: ast::CaseItemPostAction::ExitCase, loc } + } + + pub(crate) rule case_item() -> ast::CaseItem = + s:specific_operator("(")? p:pattern() specific_operator(")") linebreak() post_action:case_item_post_action() linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(post_action.1); + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: None, post_action: post_action.0, loc } + } / + s:specific_operator("(")? p:pattern() specific_operator(")") c:compound_list() post_action:case_item_post_action() linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(post_action.1); + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: Some(c), post_action: post_action.0, loc } + } + + rule case_item_post_action() -> (ast::CaseItemPostAction, &'input SourceSpan) = + s:specific_operator(";;") { + (ast::CaseItemPostAction::ExitCase, s.location()) + } / + non_posix_extensions_enabled() s:specific_operator(";;&") { + (ast::CaseItemPostAction::ContinueEvaluatingCases, s.location()) + } / + non_posix_extensions_enabled() s:specific_operator(";&") { + (ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem, s.location()) + } + + rule pattern() -> Vec = + (w:word() { ast::Word::from(w) }) ++ specific_operator("|") + + rule if_clause() -> ast::IfClauseCommand = + s:specific_word("if") condition:compound_list() specific_word("then") then:compound_list() elses:else_part()? e:specific_word("fi") { + let start = s.location(); + let end = e.location(); + let loc = SourceSpan::within(start, end); + + ast::IfClauseCommand { + condition, + then, + elses, + loc + } + } + + rule else_part() -> Vec = + cs:_conditional_else_part()+ u:_unconditional_else_part()? { + let mut parts = Vec::with_capacity(cs.len() + 1); + parts.extend(cs); + + if let Some(uncond) = u { + parts.push(uncond); + } + + parts + } / + e:_unconditional_else_part() { vec![e] } + + rule _conditional_else_part() -> ast::ElseClause = + specific_word("elif") condition:compound_list() specific_word("then") body:compound_list() { + ast::ElseClause { condition: Some(condition), body } + } + + rule _unconditional_else_part() -> ast::ElseClause = + specific_word("else") body:compound_list() { + ast::ElseClause { condition: None, body } + } + + rule while_clause() -> ast::WhileOrUntilClauseCommand = + s:specific_word("while") c:compound_list() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + + ast::WhileOrUntilClauseCommand(c, d, loc) + } + + rule until_clause() -> ast::WhileOrUntilClauseCommand = + s:specific_word("until") c:compound_list() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + + ast::WhileOrUntilClauseCommand(c, d, loc) + } + + // N.B. Coproc is a bash extension. + rule coproc_clause() -> ast::CoprocessCommand = + s:specific_word("coproc") linebreak() name:coproc_name()? body:command() { + let start = s.location(); + let loc = body.location().unwrap_or_else(|| start.clone()); + + ast::CoprocessCommand { name, body: Box::new(body), loc: SourceSpan::within(start, &loc) } + } + + // N.B. The name must be followed by a compound command start ({ or () + rule coproc_name() -> ast::Word = + w:fname() linebreak() &(specific_word("{") / specific_operator("(")) { + w + } + + // N.B. Non-sh extensions allows use of the 'function' word to indicate a function definition. + // N.B. Without the 'function' keyword, reserved words cannot be used as function names + // (bash rejects e.g. `for (){ :; }`). With the 'function' keyword, reserved words are + // allowed as function names (bash accepts e.g. `function for { :; }`). + rule function_definition() -> ast::FunctionDefinition = + specific_word("function") fname:fname() body:function_parens_and_body() { + ast::FunctionDefinition { fname, body } + } / + fname:non_reserved_fname() body:function_parens_and_body() { + ast::FunctionDefinition { fname, body } + } / + specific_word("function") fname:fname() linebreak() body:function_body() { + ast::FunctionDefinition { fname, body } + } / + expected!("function definition") + + pub(crate) rule function_parens_and_body() -> ast::FunctionBody = + specific_operator("(") specific_operator(")") linebreak() body:function_body() { body } + + // N.B. A function body must be a compound command per POSIX grammar. + rule function_body() -> ast::FunctionBody = + c:compound_command() r:redirect_list()? { ast::FunctionBody(c, r) } + + rule fname() -> ast::Word = + // Special-case: don't allow it to end with an equals sign, to avoid the challenge of + // misinterpreting certain declaration assignments as function definitions. + // TODO(parser): Find a way to make this still work without requiring this targeted exception. + w:[Token::Word(word, l) if !word.ends_with('=')] { ast::Word::with_location(word, l) } + + rule non_reserved_fname() -> ast::Word = + !reserved_word() w:fname() { w } + + rule brace_group() -> ast::BraceGroupCommand = + start:specific_word("{") list:compound_list() end:specific_word("}") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::BraceGroupCommand { list, loc } + } + + rule do_group() -> ast::DoGroupCommand = + start:specific_word("do") list:compound_list() end:specific_word("done") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::DoGroupCommand { list, loc } + } + + rule simple_command() -> ast::SimpleCommand = + prefix:cmd_prefix() word_and_suffix:(word_or_name:cmd_word() suffix:cmd_suffix()? { (word_or_name, suffix) })? { + match word_and_suffix { + Some((word_or_name, suffix)) => { + ast::SimpleCommand { prefix: Some(prefix), word_or_name: Some(ast::Word::from(word_or_name)), suffix } + } + None => { + ast::SimpleCommand { prefix: Some(prefix), word_or_name: None, suffix: None } + } + } + } / + word_or_name:cmd_name() suffix:cmd_suffix()? { + ast::SimpleCommand { prefix: None, word_or_name: Some(ast::Word::from(word_or_name)), suffix } } / + expected!("simple command") + + rule cmd_name() -> &'input Token = + non_reserved_word() + + rule cmd_word() -> &'input Token = + !assignment_word() w:non_reserved_word() { w } + + rule cmd_prefix() -> ast::CommandPrefix = + p:( + i:io_redirect() { ast::CommandPrefixOrSuffixItem::IoRedirect(i) } / + assignment_and_word:assignment_word() { + let (assignment, word) = assignment_and_word; + ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) + } + )+ { ast::CommandPrefix(p) } + + rule cmd_suffix() -> ast::CommandSuffix = + s:( + non_posix_extensions_enabled() sub:process_substitution() { + let (kind, subshell) = sub; + ast::CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell) + } / + i:io_redirect() { + ast::CommandPrefixOrSuffixItem::IoRedirect(i) + } / + assignment_and_word:assignment_word() { + let (assignment, word) = assignment_and_word; + ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) + } / + w:word() { + ast::CommandPrefixOrSuffixItem::Word(ast::Word::from(w)) + } + )+ { ast::CommandSuffix(s) } + + rule redirect_list() -> ast::RedirectList = + r:io_redirect()+ { ast::RedirectList(r) } / + expected!("redirect list") + + // N.B. here strings are extensions to the POSIX standard. + rule io_redirect() -> ast::IoRedirect = + n:io_number()? f:io_file() { + let (kind, target) = f; + ast::IoRedirect::File(n, kind, target) + } / + non_posix_extensions_enabled() specific_operator("&>>") target:filename() { ast::IoRedirect::OutputAndError(ast::Word::from(target), true) } / + non_posix_extensions_enabled() specific_operator("&>") target:filename() { ast::IoRedirect::OutputAndError(ast::Word::from(target), false) } / + non_posix_extensions_enabled() n:io_number()? specific_operator("<<<") w:word() { ast::IoRedirect::HereString(n, ast::Word::from(w)) } / + n:io_number()? h:io_here() { ast::IoRedirect::HereDocument(n, h) } / + expected!("I/O redirect") + + // N.B. Process substitution forms are extensions to the POSIX standard. + rule io_file() -> (ast::IoFileRedirectKind, ast::IoFileRedirectTarget) = + specific_operator("<") f:io_filename() { (ast::IoFileRedirectKind::Read, f) } / + specific_operator("<&") f:io_fd_duplication_source() { (ast::IoFileRedirectKind::DuplicateInput, f) } / + specific_operator(">") f:io_filename() { (ast::IoFileRedirectKind::Write, f) } / + specific_operator(">&") f:io_fd_duplication_source() { (ast::IoFileRedirectKind::DuplicateOutput, f) } / + specific_operator(">>") f:io_filename() { (ast::IoFileRedirectKind::Append, f) } / + specific_operator("<>") f:io_filename() { (ast::IoFileRedirectKind::ReadAndWrite, f) } / + specific_operator(">|") f:io_filename() { (ast::IoFileRedirectKind::Clobber, f) } + + rule io_fd_duplication_source() -> ast::IoFileRedirectTarget = + w:word() { ast::IoFileRedirectTarget::Duplicate(ast::Word::from(w)) } + + rule io_fd() -> u32 = + w:[Token::Word(_, _)] {? w.to_str().parse().or(Err("io_fd u32")) } + + rule io_filename() -> ast::IoFileRedirectTarget = + non_posix_extensions_enabled() sub:process_substitution() { + let (kind, subshell) = sub; + ast::IoFileRedirectTarget::ProcessSubstitution(kind, subshell) + } / + f:filename() { ast::IoFileRedirectTarget::Filename(ast::Word::from(f)) } + + rule filename() -> &'input Token = + word() + + pub(crate) rule io_here() -> ast::IoHereDocument = + specific_operator("<<-") here_tag:here_tag() doc:[_] closing_tag:here_tag() { + let requires_expansion = !here_tag.to_str().contains(['\'', '"', '\\']); + ast::IoHereDocument { + remove_tabs: true, + requires_expansion, + here_end: ast::Word::from(here_tag), + doc: ast::Word::from(doc) + } + } / + specific_operator("<<") here_tag:here_tag() doc:[_] closing_tag:here_tag() { + let requires_expansion = !here_tag.to_str().contains(['\'', '"', '\\']); + ast::IoHereDocument { + remove_tabs: false, + requires_expansion, + here_end: ast::Word::from(here_tag), + doc: ast::Word::from(doc) + } + } + + rule here_tag() -> &'input Token = + word() + + rule process_substitution() -> (ast::ProcessSubstitutionKind, ast::SubshellCommand) = + specific_operator("<") s:subshell() { (ast::ProcessSubstitutionKind::Read, s) } / + specific_operator(">") s:subshell() { (ast::ProcessSubstitutionKind::Write, s) } + + rule newline_list() -> () = + newline()+ {} + + rule linebreak() -> () = + quiet! { + newline()* {} + } + + rule separator_op() -> ast::SeparatorOperator = + specific_operator("&") { ast::SeparatorOperator::Async } / + specific_operator(";") { ast::SeparatorOperator::Sequence } + + rule separator() -> Option = + s:separator_op() linebreak() { Some(s) } / + newline_list() { None } + + rule sequential_sep() -> () = + specific_operator(";") linebreak() / + newline_list() + + // + // Token interpretation + // + + rule non_reserved_word() -> &'input Token = + !reserved_word() w:word() { w } + + rule word() -> &'input Token = + [Token::Word(_, _)] + + rule reserved_word() -> &'input Token = + [Token::Word(w, _) if matches!(w.as_str(), + "!" | + "{" | + "}" | + "case" | + "do" | + "done" | + "elif" | + "else" | + "esac" | + "fi" | + "for" | + "if" | + "in" | + "then" | + "until" | + "while" + )] / + + // N.B. bash also treats the following as reserved. + non_posix_extensions_enabled() token:non_posix_reserved_word_token() { token } + + rule non_posix_reserved_word_token() -> &'input Token = + specific_word("[[") / + specific_word("]]") / + specific_word("function") / + specific_word("select") / + specific_word("coproc") + + rule newline() -> () = quiet! { + specific_operator("\n") {} + } + + pub(crate) rule assignment_word() -> (ast::Assignment, ast::Word) = + non_posix_extensions_enabled() [Token::Word(w, l)] specific_operator("(") elements:array_elements() end:specific_operator(")") {? + let mut parsed = word::parse_array_assignment(w.as_str(), elements.as_slice(), parser_options)?; + + let mut all_as_word = w.to_owned(); + all_as_word.push('('); + for (i, e) in elements.iter().enumerate() { + if i > 0 { + all_as_word.push(' '); + } + all_as_word.push_str(e); + } + all_as_word.push(')'); + + let loc = SourceSpan::within(l, end.location()); + parsed.loc = loc.clone(); + Ok((parsed, ast::Word::with_location(&all_as_word, &loc))) + } / + [Token::Word(w, l)] {? + let mut parsed = word::parse_scalar_assignment(w.as_str(), parser_options).map_err(|_| "not assignment word")?; + parsed.loc = l.clone(); + Ok((parsed, ast::Word::with_location(w, l))) + } + + // A standalone compound assignment value, i.e. the `(...)` half of an array assignment. + // Used to reinterpret text that only became recognizable as a compound value after + // expansion. + pub(crate) rule compound_assignment_value() -> Vec<&'input String> = + non_posix_extensions_enabled() specific_operator("(") elements:array_elements() specific_operator(")") { + elements + } + + rule array_elements() -> Vec<&'input String> = + linebreak() e:array_element()* { e } + + rule array_element() -> &'input String = + linebreak() [Token::Word(e, _)] linebreak() { e } + + // N.B. An I/O number must be a string of only digits, and it must be + // followed by a '<' or '>' character (but not consume them). We also + // need to make sure that there was no space between the number and the + // redirection operator; unfortunately we don't have the space anymore + // but we can infer it by looking at the tokens' locations. + rule io_number() -> ast::IoFd = + [Token::Word(w, num_loc) if w.chars().all(|c: char| c.is_ascii_digit())] + &([Token::Operator(o, redir_loc) if + o.starts_with(['<', '>']) && + locations_are_contiguous(num_loc, redir_loc)]) {? + + // A run of ASCII digits still need not fit in an `IoFd`; e.g. + // `echo 99999999999999999999>&1` overflows. Decline the rule + // instead of unwrapping, so the token is reconsidered as an + // ordinary word rather than panicking the parser. + w.parse().map_err(|_| "io number out of range") + } + + // + // Helpers + // + rule specific_operator(expected: &str) -> &'input Token = + [Token::Operator(w, _) if w.as_str() == expected] + + rule specific_word(expected: &str) -> &'input Token = + [Token::Word(w, _) if w.as_str() == expected] + + rule non_posix_extensions_enabled() -> () = + &[_] {? if !parser_options.sh_mode { Ok(()) } else { Err("posix") } } + } +} + +// add `2>&1` to the command if the pipeline is `|&` +fn add_pipe_extension_redirection(c: &mut ast::Command) { + fn add_to_redirect_list(l: &mut Option, r: ast::IoRedirect) { + if let Some(l) = l { + l.0.push(r); + } else { + let v = vec![r]; + *l = Some(ast::RedirectList(v)); + } + } + + let r = ast::IoRedirect::File( + Some(2), + ast::IoFileRedirectKind::DuplicateOutput, + ast::IoFileRedirectTarget::Fd(1), + ); + + match c { + ast::Command::Simple(c) => { + let r = ast::CommandPrefixOrSuffixItem::IoRedirect(r); + if let Some(l) = &mut c.suffix { + l.0.push(r); + } else { + c.suffix = Some(ast::CommandSuffix(vec![r])); + } + } + ast::Command::Compound(_, l) => add_to_redirect_list(l, r), + ast::Command::Function(f) => add_to_redirect_list(&mut f.body.1, r), + } +} + +#[inline] +fn locations_are_contiguous(loc_left: &crate::SourceSpan, loc_right: &crate::SourceSpan) -> bool { + loc_left.end.index == loc_right.start.index +} + +impl peg::Parse for Tokens<'_> { + type PositionRepr = usize; + + #[inline] + fn start(&self) -> usize { + 0 + } + + #[inline] + fn is_eof(&self, p: usize) -> bool { + p >= self.tokens.len() + } + + #[inline] + fn position_repr(&self, p: usize) -> Self::PositionRepr { + p + } +} + +impl<'a> peg::ParseElem<'a> for Tokens<'a> { + type Element = &'a Token; + + #[inline] + fn parse_elem(&'a self, pos: usize) -> peg::RuleResult { + match self.tokens.get(pos) { + Some(c) => peg::RuleResult::Matched(pos + 1, c), + None => peg::RuleResult::Failed, + } + } +} + +impl<'a> peg::ParseSlice<'a> for Tokens<'a> { + type Slice = String; + + /// Reconstructs a source string from a slice of tokens. + /// + /// Uses each token's source position to detect whether whitespace existed + /// between adjacent tokens in the original source, preserving it as a + /// single space. This matters for constructs like `[[ x =~ (a| *) ]]` + /// where the space inside the regex group is significant. + /// + /// N.B. This relies on tokens having accurate, contiguous source + /// positions. All tokens produced by the normal tokenizer path satisfy + /// this. If positions are missing or non-monotonic (as can happen with + /// synthetic here-document end-tag tokens), the gap check evaluates + /// false and no space is inserted — a safe degradation to the previous + /// behavior, which also omitted spaces between non-word tokens. + fn parse_slice(&'a self, start: usize, end: usize) -> Self::Slice { + let mut result = String::new(); + let mut prev_end_index: Option = None; + + for token in &self.tokens[start..end] { + let loc = token.location(); + + if let Some(prev_end) = prev_end_index { + if loc.start.index > prev_end { + result.push(' '); + } + } + + result.push_str(token.to_str()); + prev_end_index = Some(loc.end.index); + } + + result + } +} diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap new file mode 100644 index 0000000..2696c38 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap @@ -0,0 +1,126 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( : && ( (( 0 )) || : ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: Some(SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "0", + ), + loc: SourceSpan( + start: SourcePosition( + index: 9, + line: 1, + column: 10, + ), + end: SourcePosition( + index: 16, + line: 1, + column: 17, + ), + ), + )), None), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: Some(SourceSpan( + start: SourcePosition( + index: 20, + line: 1, + column: 21, + ), + end: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + )), + )), + )), + ], + )), + ], + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + end: SourcePosition( + index: 23, + line: 1, + column: 24, + ), + ), + )), None), + ], + )), + ], + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 25, + line: 1, + column: 26, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap new file mode 100644 index 0000000..9355327 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap @@ -0,0 +1,124 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "\\\ncase x in\nx)\n echo y;;\nesac\\\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 7, + line: 2, + column: 6, + ), + end: SourcePosition( + index: 8, + line: 2, + column: 7, + ), + )), + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 13, + line: 3, + column: 2, + ), + )), + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 19, + line: 4, + column: 5, + ), + end: SourcePosition( + index: 23, + line: 4, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: Some(SourceSpan( + start: SourcePosition( + index: 24, + line: 4, + column: 10, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 27, + line: 4, + column: 13, + ), + )), + ), + ], + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 34, + line: 6, + column: 1, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap new file mode 100644 index 0000000..3a84a5f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap @@ -0,0 +1,124 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "\\\ncase x in\nx)\n echo y\nesac\\\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 7, + line: 2, + column: 6, + ), + end: SourcePosition( + index: 8, + line: 2, + column: 7, + ), + )), + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 13, + line: 3, + column: 2, + ), + )), + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 19, + line: 4, + column: 5, + ), + end: SourcePosition( + index: 23, + line: 4, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: Some(SourceSpan( + start: SourcePosition( + index: 24, + line: 4, + column: 10, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + ), + ], + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 32, + line: 6, + column: 1, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap new file mode 100644 index 0000000..096dae1 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap @@ -0,0 +1,111 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } |& cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + ), + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Fd(1)), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: Some(SourceSpan( + start: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + end: SourcePosition( + index: 24, + line: 1, + column: 25, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap new file mode 100644 index 0000000..acb5d88 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap @@ -0,0 +1,125 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } 2>&1 | cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + ), + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + end: SourcePosition( + index: 22, + line: 1, + column: 23, + ), + )), + ))), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: Some(SourceSpan( + start: SourcePosition( + index: 25, + line: 1, + column: 26, + ), + end: SourcePosition( + index: 28, + line: 1, + column: 29, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap new file mode 100644 index 0000000..cb83307 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <&2\n\n done\n\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: Some(SourceSpan( + start: SourcePosition( + index: 32, + line: 5, + column: 10, + ), + end: SourcePosition( + index: 33, + line: 5, + column: 11, + ), + )), + ), + Word( + value: "B", + loc: Some(SourceSpan( + start: SourcePosition( + index: 34, + line: 5, + column: 12, + ), + end: SourcePosition( + index: 35, + line: 5, + column: 13, + ), + )), + ), + Word( + value: "C", + loc: Some(SourceSpan( + start: SourcePosition( + index: 36, + line: 5, + column: 14, + ), + end: SourcePosition( + index: 37, + line: 5, + column: 15, + ), + )), + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 60, + line: 8, + column: 5, + ), + end: SourcePosition( + index: 64, + line: 8, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: Some(SourceSpan( + start: SourcePosition( + index: 65, + line: 8, + column: 10, + ), + end: SourcePosition( + index: 73, + line: 8, + column: 18, + ), + )), + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: Some(SourceSpan( + start: SourcePosition( + index: 76, + line: 8, + column: 21, + ), + end: SourcePosition( + index: 77, + line: 8, + column: 22, + ), + )), + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 39, + line: 5, + column: 17, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + ), + loc: SourceSpan( + start: SourcePosition( + index: 23, + line: 5, + column: 1, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap new file mode 100644 index 0000000..dd851ae --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo |& wc", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Fd(1))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/and_or_lists.rs b/userland/brush/source/brush-parser/src/parser/tests/and_or_lists.rs new file mode 100644 index 0000000..f0df1ea --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/and_or_lists.rs @@ -0,0 +1,82 @@ +//! Tests for and/or list parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_simple_and() -> Result<()> { + let input = "true && echo yes"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_simple_or() -> Result<()> { + let input = "false || echo no"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_chained_and() -> Result<()> { + let input = "cmd1 && cmd2 && cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_chained_or() -> Result<()> { + let input = "cmd1 || cmd2 || cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_mixed_and_or() -> Result<()> { + let input = "cmd1 && cmd2 || cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_and_or_with_pipes() -> Result<()> { + let input = "cmd1 | cmd2 && cmd3 | cmd4"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_and_or_in_sequence() -> Result<()> { + let input = "cmd1 && cmd2; cmd3 || cmd4"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/assignments.rs b/userland/brush/source/brush-parser/src/parser/tests/assignments.rs new file mode 100644 index 0000000..d579546 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/assignments.rs @@ -0,0 +1,248 @@ +//! Tests for assignment parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// Scalar assignments + +#[test] +fn parse_assignment_simple() -> Result<()> { + let input = "x=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_empty() -> Result<()> { + let input = "x="; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_quoted() -> Result<()> { + let input = r#"x="hello world""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_single_quoted() -> Result<()> { + let input = "x='hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_expansion() -> Result<()> { + let input = "x=$HOME/bin"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_command_substitution() -> Result<()> { + let input = "x=$(pwd)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Append assignments + +#[test] +fn parse_assignment_append() -> Result<()> { + let input = "x+=more"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_append_quoted() -> Result<()> { + let input = r#"x+=" more text""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Array assignments + +#[test] +fn parse_assignment_array() -> Result<()> { + let input = "arr=(a b c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_empty() -> Result<()> { + let input = "arr=()"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_with_indices() -> Result<()> { + let input = "arr=([0]=a [1]=b [2]=c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_mixed() -> Result<()> { + let input = "arr=(a [5]=b c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_quoted_elements() -> Result<()> { + let input = r#"arr=("hello world" "foo bar")"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Array element assignment + +#[test] +fn parse_assignment_array_element() -> Result<()> { + let input = "arr[0]=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_element_expression() -> Result<()> { + let input = "arr[i+1]=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Multiple assignments + +#[test] +fn parse_multiple_assignments() -> Result<()> { + let input = "x=1 y=2 z=3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_command() -> Result<()> { + let input = "VAR=value command arg1 arg2"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multiple_assignments_with_command() -> Result<()> { + let input = "A=1 B=2 command"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Export/local with assignment + +#[test] +fn parse_export_assignment() -> Result<()> { + let input = "export VAR=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_local_assignment() -> Result<()> { + let input = "local x=5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_declare_assignment() -> Result<()> { + let input = "declare -i x=5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/complex.rs b/userland/brush/source/brush-parser/src/parser/tests/complex.rs new file mode 100644 index 0000000..5ede5fe --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/complex.rs @@ -0,0 +1,255 @@ +//! Complex and integration tests that combine multiple parser features. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_shebang_and_program() -> Result<()> { + let input = r#"#!/usr/bin/env bash + +for f in A B C; do + + # sdfsdf + echo "${f@L}" >&2 + + done +"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_with_newlines() -> Result<()> { + let input = r"case x in +x) + echo y;; +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_no_semicolon() -> Result<()> { + let input = r"case x in +x) + echo y +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_if_while() -> Result<()> { + let input = r#"if true; then + while read line; do + echo "$line" + done < file.txt +fi"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_if() -> Result<()> { + let input = r#"myfunc() { + if [[ -z "$1" ]]; then + echo "No argument" + return 1 + fi + echo "Got: $1" +}"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipeline_with_redirections() -> Result<()> { + let input = "cat < input.txt | grep pattern | tee output.txt > /dev/null 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_substitution_nested() -> Result<()> { + let input = "echo \"$(echo $(pwd))\""; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_with_command_substitution() -> Result<()> { + let input = "for f in $(ls *.txt); do cat \"$f\"; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_in_condition() -> Result<()> { + let input = "if (( x > 5 )); then echo big; else echo small; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_brace_expansion_context() -> Result<()> { + let input = "echo {a,b,c}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +#[allow(clippy::literal_string_with_formatting_args)] +fn parse_parameter_expansion_complex() -> Result<()> { + let input = r#"echo "${var:-default}" "${var:+alt}" "${var:=assign}""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_subshell_with_assignments() -> Result<()> { + let input = "( x=1; y=2; echo $((x + y)) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_coprocess() -> Result<()> { + let input = "coproc cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multiple_here_docs() -> Result<()> { + let input = r"cmd < Result<()> { + let input = "cmd1 & cmd2 & cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_mixed_sequences() -> Result<()> { + let input = "cmd1; cmd2 && cmd3 || cmd4; cmd5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_complex_array_operations() -> Result<()> { + let input = r#"arr=($(seq 1 10)); echo "${arr[@]}"; echo "${#arr[@]}""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_glob_patterns() -> Result<()> { + let input = "ls *.txt **/foo.* file?.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extglob_patterns() -> Result<()> { + let input = "ls !(*.txt) +(foo|bar) ?(a|b)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_tilde_expansion() -> Result<()> { + let input = "cd ~/projects; ls ~user/home"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/compound_commands.rs b/userland/brush/source/brush-parser/src/parser/tests/compound_commands.rs new file mode 100644 index 0000000..fc77d48 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/compound_commands.rs @@ -0,0 +1,373 @@ +//! Tests for compound command parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// Arithmetic commands + +#[test] +fn parse_arithmetic_simple() -> Result<()> { + let input = "(( 1 + 2 ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_increment() -> Result<()> { + let input = "(( x++ ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_complex() -> Result<()> { + let input = "(( x = 5 + 3 * 2 ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_open_with_line_continuations() -> Result<()> { + // Line continuations are removed, so the parentheses still touch. + let input = "(\\\n(\\\n10+3))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Arithmetic for clause + +#[test] +fn parse_arithmetic_for() -> Result<()> { + let input = "for (( i = 0; i < 10; i++ )); do echo $i; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_for_empty_parts() -> Result<()> { + let input = "for (( ; ; )); do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_for_adjacent_semicolons() -> Result<()> { + let input = "for ((;;)); do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_for_empty_condition() -> Result<()> { + let input = "for ((i = 0;;i++)); do echo $i; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Brace group + +#[test] +fn parse_brace_group() -> Result<()> { + let input = "{ echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_brace_group_multiline() -> Result<()> { + let input = r"{ + echo hello + echo world +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Subshell + +#[test] +fn parse_subshell() -> Result<()> { + let input = "( echo hello )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_subshell_multiple_commands() -> Result<()> { + let input = "( echo hello; echo world )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_subshell() -> Result<()> { + let input = "( ( echo nested ) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_subshell_split_open() -> Result<()> { + let input = "( (sleep 6))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_subshell_split_close() -> Result<()> { + let input = "((sleep 6) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// For clause + +#[test] +fn parse_for_in() -> Result<()> { + let input = "for x in a b c; do echo $x; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_in_multiline() -> Result<()> { + let input = r"for x in a b c +do + echo $x +done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_no_in() -> Result<()> { + let input = "for x; do echo $x; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Case clause + +#[test] +fn parse_case_simple() -> Result<()> { + let input = "case x in a) echo a;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_multiple_patterns() -> Result<()> { + let input = r"case x in + a|b) echo ab;; + c) echo c;; + *) echo default;; +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_fallthrough() -> Result<()> { + let input = "case x in a) echo a;& b) echo b;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_continue() -> Result<()> { + let input = "case x in a) echo a;;& b) echo b;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// If clause + +#[test] +fn parse_if_simple() -> Result<()> { + let input = "if true; then echo yes; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_else() -> Result<()> { + let input = "if true; then echo yes; else echo no; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_elif() -> Result<()> { + let input = "if false; then echo one; elif true; then echo two; else echo three; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_multiline() -> Result<()> { + let input = r"if true +then + echo yes +else + echo no +fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// While/Until + +#[test] +fn parse_while() -> Result<()> { + let input = "while true; do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_until() -> Result<()> { + let input = "until false; do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_while_multiline() -> Result<()> { + let input = r"while true +do + echo loop + break +done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Mixed/nested + +#[test] +fn parse_arith_and_non_arith_parens() -> Result<()> { + let input = "( : && ( (( 0 )) || : ) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/extended_test.rs b/userland/brush/source/brush-parser/src/parser/tests/extended_test.rs new file mode 100644 index 0000000..99be163 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/extended_test.rs @@ -0,0 +1,268 @@ +//! Tests for extended test expression [[ ]] parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// File tests + +#[test] +fn parse_extended_test_file_exists() -> Result<()> { + let input = "[[ -f file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_directory() -> Result<()> { + let input = "[[ -d /path/to/dir ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_readable() -> Result<()> { + let input = "[[ -r file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_writable() -> Result<()> { + let input = "[[ -w file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_executable() -> Result<()> { + let input = "[[ -x file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// String tests + +#[test] +fn parse_extended_test_string_zero_length() -> Result<()> { + let input = r#"[[ -z "$var" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_non_zero() -> Result<()> { + let input = r#"[[ -n "$var" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_equal() -> Result<()> { + let input = r#"[[ "$a" == "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_not_equal() -> Result<()> { + let input = r#"[[ "$a" != "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_pattern() -> Result<()> { + let input = r#"[[ "$str" == *pattern* ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_regex() -> Result<()> { + let input = r#"[[ "$str" =~ ^[0-9]+$ ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_regex_with_spaces() -> Result<()> { + let input = r#"[[ "x" =~ (a| *) ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Logical operators + +#[test] +fn parse_extended_test_and() -> Result<()> { + let input = "[[ -f file && -r file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_or() -> Result<()> { + let input = "[[ -f file || -d file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_not() -> Result<()> { + let input = "[[ ! -f file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_parenthesized() -> Result<()> { + let input = "[[ ( -f file ) ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_complex() -> Result<()> { + let input = "[[ ( -f file && -r file ) || -d file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Comparison operators + +#[test] +fn parse_extended_test_less_than() -> Result<()> { + let input = r#"[[ "$a" < "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_greater_than() -> Result<()> { + let input = r#"[[ "$a" > "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Arithmetic comparison + +#[test] +fn parse_extended_test_arith_equal() -> Result<()> { + let input = "[[ 5 -eq 5 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_not_equal() -> Result<()> { + let input = "[[ 5 -ne 3 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_less_than() -> Result<()> { + let input = "[[ 3 -lt 5 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_greater_than() -> Result<()> { + let input = "[[ 5 -gt 3 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/functions.rs b/userland/brush/source/brush-parser/src/parser/tests/functions.rs new file mode 100644 index 0000000..bffcd6a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/functions.rs @@ -0,0 +1,124 @@ +//! Tests for function definition parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_function_basic() -> Result<()> { + let input = "foo() { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_keyword() -> Result<()> { + let input = "function foo { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_keyword_with_parens() -> Result<()> { + let input = "function foo() { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_redirect() -> Result<()> { + let input = "foo() { echo 1; } 2>&1 | cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_stderr_redirect() -> Result<()> { + let input = "foo() { echo 1; } |& cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_multiline() -> Result<()> { + let input = r"foo() { + echo hello + echo world +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_subshell_body() -> Result<()> { + let input = "foo() ( echo subshell )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_local_vars() -> Result<()> { + let input = r"foo() { + local x=1 + echo $x +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_extended_test_body() -> Result<()> { + // bash accepts `[[ ... ]]` (itself a compound command) as a brace-less function + // body, e.g. real-world usage in Gentoo Portage's bin/eapi.sh: + // ___eapi_has_pkg_pretend() [[ ${1-${EAPI-0}} != [0-3] ]] + let input = "foo() [[ -n $1 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_extended_test_body_and_redirect() -> Result<()> { + let input = "foo() [[ -n $1 ]] 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/here_docs.rs b/userland/brush/source/brush-parser/src/parser/tests/here_docs.rs new file mode 100644 index 0000000..8684a10 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/here_docs.rs @@ -0,0 +1,166 @@ +//! Tests for here-document parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_here_doc_basic() -> Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = "cat <<-EOF\n\tcontent with tab\nEOF\n"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_quoted_delimiter() -> Result<()> { + let input = r"cat <<'EOF' +$variable should not expand +EOF +"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_double_quoted_delimiter() -> Result<()> { + let input = r#"cat <<"EOF" +$variable should not expand +EOF +"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_with_expansion() -> Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = r"command 3< = [ + "$(cat < Result<()> { + // The inner substitution ends before the here-document's body, which follows the line; + // bash accepts this (with a warning). The body belongs in the inner substitution's text, + // with its operator and tag, and the outer substitution must still end at its own + // closing paren, after the body and the command that follows it. + let input = "echo $(echo $(cat < { + pub input: &'a str, + pub result: &'a T, +} + +/// Macro to assert snapshots with location information redacted. +/// This makes snapshots stable across parser changes that only affect source locations. +#[macro_export] +macro_rules! assert_snapshot_redacted { + ($value:expr) => {{ + let mut settings = insta::Settings::clone_current(); + settings.add_redaction(".**.loc", "[location]"); + settings.bind(|| { + insta::assert_ron_snapshot!($value); + }); + }}; +} + +/// Recursively redact location fields from a JSON value. +/// This normalizes AST representations for comparison by removing source location info. +#[cfg(feature = "winnow-parser")] +fn redact_locations(value: &mut Value) { + match value { + Value::Object(map) => { + // Remove location-related fields + map.remove("loc"); + // Also handle tuple structs that store SourceSpan as positional element + // These appear as arrays with SourceSpan objects + + // Recursively process remaining fields + for (_, v) in map.iter_mut() { + // If this value is a SourceSpan object, normalize it + if is_source_span(v) { + normalize_source_span(v); + } else { + redact_locations(v); + } + } + } + Value::Array(arr) => { + // Check if this array looks like a tuple struct containing SourceSpan at the end + // SourceSpan has: start: SourcePosition, end: SourcePosition + // SourcePosition has: index, line, column + if let Some(last) = arr.last() { + if is_source_span(last) { + arr.pop(); + } + } + + for item in arr.iter_mut() { + redact_locations(item); + } + } + _ => {} + } +} + +/// Check if a value looks like a `SourceSpan` object +#[cfg(feature = "winnow-parser")] +fn is_source_span(value: &Value) -> bool { + if let Value::Object(map) = value { + map.contains_key("start") && map.contains_key("end") && map.len() == 2 + } else { + false + } +} + +/// Normalize a `SourceSpan` object by replacing its positions with placeholder values. +/// This allows comparing ASTs without position differences. +#[cfg(feature = "winnow-parser")] +fn normalize_source_span(value: &mut Value) { + if let Value::Object(map) = value { + let placeholder_pos = serde_json::json!({ + "index": 0, + "line": 0, + "column": 0 + }); + map.insert("start".to_string(), placeholder_pos.clone()); + map.insert("end".to_string(), placeholder_pos); + } +} + +/// Convert a Program to a normalized JSON value with locations redacted +#[cfg(feature = "winnow-parser")] +#[allow(clippy::expect_used)] +fn normalize_ast(program: &Program) -> Value { + let mut value = serde_json::to_value(program).expect("Failed to serialize Program to JSON"); + redact_locations(&mut value); + value +} + +/// A named parser configuration for test output clarity +#[derive(Debug, Clone)] +pub struct ParserConfig { + pub name: &'static str, + pub parser_impl: ParserImpl, +} + +/// Returns all available parser implementations for testing. +/// - Without `winnow-parser`: returns only Peg +/// - With `winnow-parser`: returns both Peg and Winnow +pub fn parser_configs() -> Vec { + #[allow(unused_mut)] + let mut configs = vec![ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }]; + + #[cfg(feature = "winnow-parser")] + configs.push(ParserConfig { + name: "winnow", + parser_impl: ParserImpl::Winnow, + }); + + configs +} + +/// Helper to parse input with a specific parser configuration +pub fn parse_with_config(input: &str, config: &ParserConfig) -> Result { + let options = ParserOptions { + parser_impl: config.parser_impl, + ..Default::default() + }; + + let mut parser = Parser::new(std::io::Cursor::new(input), &options); + parser.parse_program() +} + +/// Verify all parser implementations produce the same result. +/// +/// This function parses the input with each available parser and verifies +/// they all produce structurally equivalent ASTs. Returns an error if +/// parsing fails or if the results differ. +#[cfg(feature = "winnow-parser")] +#[allow(dead_code)] +pub fn test_all_parsers_match(input: &str) -> Result<()> { + let configs = parser_configs(); + + // Parse with each configuration + let mut results: Vec<(&str, Program)> = Vec::new(); + + for config in &configs { + let result = parse_with_config(input, config).map_err(|e| { + anyhow::anyhow!( + "Parser '{}' failed to parse input: {}\nInput: {}", + config.name, + e, + input + ) + })?; + results.push((config.name, result)); + } + + // Compare all results against the first (peg) implementation + // Normalize ASTs by redacting location info before comparison + if results.len() > 1 { + let (base_name, base_result) = &results[0]; + let base_normalized = normalize_ast(base_result); + for (name, result) in results.iter().skip(1) { + let result_normalized = normalize_ast(result); + pretty_assertions::assert_eq!( + base_normalized, + result_normalized, + "Parser outputs differ between '{}' and '{}'.\nInput: {}", + base_name, + name, + input + ); + } + } + + Ok(()) +} + +/// Run a test and create snapshot for peg parser (canonical implementation). +/// +/// This function parses the input with the peg parser and returns the result +/// for snapshot testing. When the winnow-parser feature is enabled, it also +/// verifies that winnow produces the same result. +pub fn test_with_snapshot(input: &str) -> Result { + // Always parse with peg (canonical implementation) + let peg_config = ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }; + let peg_result = parse_with_config(input, &peg_config) + .map_err(|e| anyhow::anyhow!("Peg parser failed: {e}\nInput: {input}"))?; + + // When winnow is enabled, verify it matches (ignoring location differences) + #[cfg(feature = "winnow-parser")] + { + let winnow_config = ParserConfig { + name: "winnow", + parser_impl: ParserImpl::Winnow, + }; + let winnow_result = parse_with_config(input, &winnow_config) + .map_err(|e| anyhow::anyhow!("Winnow parser failed: {e}\nInput: {input}"))?; + + // Normalize both ASTs by redacting location info before comparison + let peg_normalized = normalize_ast(&peg_result); + let winnow_normalized = normalize_ast(&winnow_result); + + pretty_assertions::assert_eq!( + peg_normalized, + winnow_normalized, + "Parser outputs differ between 'peg' and 'winnow'.\nInput: {}", + input + ); + } + + Ok(peg_result) +} + +#[cfg(test)] +mod harness_tests { + use super::*; + + #[test] + fn test_parser_configs_includes_peg() { + let configs = parser_configs(); + assert!(!configs.is_empty()); + assert_eq!(configs[0].name, "peg"); + } + + #[test] + #[cfg(feature = "winnow-parser")] + fn test_parser_configs_includes_winnow() { + let configs = parser_configs(); + assert!(configs.len() >= 2); + assert!(configs.iter().any(|c| c.name == "winnow")); + } + + #[test] + fn test_parse_with_config_basic() { + let config = ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }; + let result = parse_with_config("echo hello", &config); + assert!(result.is_ok()); + } +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/pipelines.rs b/userland/brush/source/brush-parser/src/parser/tests/pipelines.rs new file mode 100644 index 0000000..e87db1f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/pipelines.rs @@ -0,0 +1,93 @@ +//! Tests for pipeline parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_simple_pipe() -> Result<()> { + let input = "echo hello | grep world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multi_stage_pipe() -> Result<()> { + let input = "cat file | grep pattern | wc -l"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipe_with_stderr() -> Result<()> { + let input = "echo |& wc"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_timed_pipeline() -> Result<()> { + let input = "time echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_timed_pipeline_posix() -> Result<()> { + let input = "time -p echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_negated_pipeline() -> Result<()> { + let input = "! echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_negated_timed_pipeline() -> Result<()> { + let input = "time ! echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipe_with_multiple_commands() -> Result<()> { + let input = "ls -la | head -10 | tail -5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/redirections.rs b/userland/brush/source/brush-parser/src/parser/tests/redirections.rs new file mode 100644 index 0000000..774196f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/redirections.rs @@ -0,0 +1,218 @@ +//! Tests for redirection parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// File redirections + +#[test] +fn parse_redirect_output() -> Result<()> { + let input = "echo hello > file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_input() -> Result<()> { + let input = "cat < input.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_append() -> Result<()> { + let input = "echo hello >> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_clobber() -> Result<()> { + let input = "echo hello >| file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_read_write() -> Result<()> { + let input = "cat <> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// FD operations + +#[test] +fn parse_redirect_stderr_to_stdout() -> Result<()> { + let input = "command 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_stdout_to_stderr() -> Result<()> { + let input = "command 1>&2"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_fd_close() -> Result<()> { + let input = "command 2>&-"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_stdin_dup() -> Result<()> { + let input = "command <&3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Combined redirections + +#[test] +fn parse_redirect_output_and_error() -> Result<()> { + let input = "command &> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_output_and_error_append() -> Result<()> { + let input = "command &>> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_multiple() -> Result<()> { + let input = "command < input.txt > output.txt 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Process substitution + +#[test] +fn parse_process_substitution_read() -> Result<()> { + let input = "diff <(sort file1) <(sort file2)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_process_substitution_write() -> Result<()> { + let input = "tee >(grep error > errors.txt)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Here string + +#[test] +fn parse_here_string() -> Result<()> { + let input = "cat <<< 'hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_string_with_variable() -> Result<()> { + let input = "cat <<< $variable"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Out-of-range I/O numbers + +#[test] +fn parse_io_number_out_of_range_does_not_panic() -> Result<()> { + // Regression: the `io_number` rule unwrapped the integer parse, so a run of + // digits too large for an `IoFd` panicked the parser rather than declining + // the rule. The digits are reinterpreted as an ordinary word instead. + let input = "echo 99999999999999999999>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_io_number_in_range_still_recognized() -> Result<()> { + let input = "echo 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/simple_commands.rs b/userland/brush/source/brush-parser/src/parser/tests/simple_commands.rs new file mode 100644 index 0000000..caeb494 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/simple_commands.rs @@ -0,0 +1,137 @@ +//! Tests for simple command parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_echo_hello() -> Result<()> { + let input = "echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_ls() -> Result<()> { + let input = "ls"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_colon() -> Result<()> { + let input = ":"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_true() -> Result<()> { + let input = "true"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_echo_hello_world() -> Result<()> { + let input = "echo hello world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_ls_la_home() -> Result<()> { + let input = "ls -la /home"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_quoted_args() -> Result<()> { + let input = r#"echo "hello world""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_single_quotes() -> Result<()> { + let input = "echo 'hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_backslash_escape() -> Result<()> { + let input = r"echo hello\ world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_variable() -> Result<()> { + let input = "echo $HOME"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_command_substitution() -> Result<()> { + let input = "echo $(whoami)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_backtick_substitution() -> Result<()> { + let input = "echo `whoami`"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap new file mode 100644 index 0000000..7bfa09a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap @@ -0,0 +1,61 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2; cmd3 || cmd4", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap new file mode 100644 index 0000000..97c601c --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 | cmd2 && cmd3 | cmd4", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap new file mode 100644 index 0000000..c3890b1 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2 && cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap new file mode 100644 index 0000000..44ae967 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 || cmd2 || cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap new file mode 100644 index 0000000..ed4abe2 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2 || cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap new file mode 100644 index 0000000..26650e5 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "true && echo yes", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap new file mode 100644 index 0000000..1dcdfbd --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "false || echo no", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap new file mode 100644 index 0000000..78cd368 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x+=more", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "more", + loc: "[location]", + )), + append: true, + loc: "[location]", + ), Word( + value: "x+=more", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap new file mode 100644 index 0000000..8b89ae7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x+=\" more text\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\" more text\"", + loc: "[location]", + )), + append: true, + loc: "[location]", + ), Word( + value: "x+=\" more text\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap new file mode 100644 index 0000000..cf04411 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap @@ -0,0 +1,44 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(a b c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "a", + loc: "[location]", + )), + (None, Word( + value: "b", + loc: "[location]", + )), + (None, Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(a b c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap new file mode 100644 index 0000000..7cef324 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr[0]=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: ArrayElementName("arr", "0"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "arr[0]=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap new file mode 100644 index 0000000..8e4777a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr[i+1]=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: ArrayElementName("arr", "i+1"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "arr[i+1]=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap new file mode 100644 index 0000000..037b554 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=()", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([]), + loc: "[location]", + ), Word( + value: "arr=()", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap new file mode 100644 index 0000000..0237f8b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(a [5]=b c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "a", + loc: "[location]", + )), + (Some(Word( + value: "5", + loc: "[location]", + )), Word( + value: "b", + loc: "[location]", + )), + (None, Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(a [5]=b c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap new file mode 100644 index 0000000..555578a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap @@ -0,0 +1,40 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(\"hello world\" \"foo bar\")", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "\"hello world\"", + loc: "[location]", + )), + (None, Word( + value: "\"foo bar\"", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(\"hello world\" \"foo bar\")", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap new file mode 100644 index 0000000..54b5c2d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap @@ -0,0 +1,53 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=([0]=a [1]=b [2]=c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (Some(Word( + value: "0", + loc: "[location]", + )), Word( + value: "a", + loc: "[location]", + )), + (Some(Word( + value: "1", + loc: "[location]", + )), Word( + value: "b", + loc: "[location]", + )), + (Some(Word( + value: "2", + loc: "[location]", + )), Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=([0]=a [1]=b [2]=c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap new file mode 100644 index 0000000..4449a8d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap new file mode 100644 index 0000000..53f51d3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=\"hello world\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\"hello world\"", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=\"hello world\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap new file mode 100644 index 0000000..ca21727 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap new file mode 100644 index 0000000..92c811c --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=\'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\'hello world\'", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=\'hello world\'", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap new file mode 100644 index 0000000..645129d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "VAR=value command arg1 arg2", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("VAR"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "VAR=value", + loc: "[location]", + )), + ])), + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "arg1", + loc: "[location]", + )), + Word(Word( + value: "arg2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap new file mode 100644 index 0000000..23bc324 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=$(pwd)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "$(pwd)", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=$(pwd)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap new file mode 100644 index 0000000..e89f102 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=$HOME/bin", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "$HOME/bin", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=$HOME/bin", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap new file mode 100644 index 0000000..830f154 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "declare -i x=5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "declare", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-i", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap new file mode 100644 index 0000000..bfc4ba5 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "export VAR=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "export", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("VAR"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "VAR=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap new file mode 100644 index 0000000..02bd0dd --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "local x=5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "local", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap new file mode 100644 index 0000000..5890d61 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=1 y=2 z=3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("y"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "y=2", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("z"), + value: Scalar(Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "z=3", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap new file mode 100644 index 0000000..b3c1ed7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "A=1 B=2 command", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("A"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "A=1", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("B"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "B=2", + loc: "[location]", + )), + ])), + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap new file mode 100644 index 0000000..d305e57 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if (( x > 5 )); then echo big; else echo small; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x > 5", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "big", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "small", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap new file mode 100644 index 0000000..4ea1e11 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 & cmd2 & cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + ), Async), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + ), Async), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap new file mode 100644 index 0000000..1da85a7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo {a,b,c}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "{a,b,c}", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap new file mode 100644 index 0000000..0a40856 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\nx)\n echo y\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap new file mode 100644 index 0000000..c2326d0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\nx)\n echo y;;\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap new file mode 100644 index 0000000..e5c7e47 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \"$(echo $(pwd))\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"$(echo $(pwd))\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap new file mode 100644 index 0000000..12825db --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap @@ -0,0 +1,72 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=($(seq 1 10)); echo \"${arr[@]}\"; echo \"${#arr[@]}\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "$(seq 1 10)", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=($(seq 1 10))", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${arr[@]}\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${#arr[@]}\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap new file mode 100644 index 0000000..b1f242c --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap @@ -0,0 +1,27 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "coproc cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Coprocess(CoprocessCommand( + body: Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap new file mode 100644 index 0000000..df53a89 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls !(*.txt) +(foo|bar) ?(a|b)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "!(*.txt)", + loc: "[location]", + )), + Word(Word( + value: "+(foo|bar)", + loc: "[location]", + )), + Word(Word( + value: "?(a|b)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap new file mode 100644 index 0000000..84782e0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap @@ -0,0 +1,52 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for f in $(ls *.txt); do cat \"$f\"; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "$(ls *.txt)", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"$f\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap new file mode 100644 index 0000000..1faee39 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap @@ -0,0 +1,110 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "myfunc() {\n if [[ -z \"$1\" ]]; then\n echo \"No argument\"\n return 1\n fi\n echo \"Got: $1\"\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "myfunc", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasZeroLength, Word( + value: "\"$1\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"No argument\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "return", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"Got: $1\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap new file mode 100644 index 0000000..b78eb0f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls *.txt **/foo.* file?.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "*.txt", + loc: "[location]", + )), + Word(Word( + value: "**/foo.*", + loc: "[location]", + )), + Word(Word( + value: "file?.txt", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap new file mode 100644 index 0000000..472be4b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1; cmd2 && cmd3 || cmd4; cmd5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd5", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap new file mode 100644 index 0000000..6f9a45a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd < /dev/null 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "pattern", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tee", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "output.txt", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "/dev/null", + loc: "[location]", + )))), + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap new file mode 100644 index 0000000..f9cc814 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap @@ -0,0 +1,64 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "#!/usr/bin/env bash\n\nfor f in A B C; do\n\n # sdfsdf\n echo \"${f@L}\" >&2\n\n done\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: "[location]", + ), + Word( + value: "B", + loc: "[location]", + ), + Word( + value: "C", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: "[location]", + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap new file mode 100644 index 0000000..a2a662e --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap @@ -0,0 +1,84 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( x=1; y=2; echo $((x + y)) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("y"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "y=2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$((x + y))", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap new file mode 100644 index 0000000..a8dc8ea --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cd ~/projects; ls ~user/home", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cd", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "~/projects", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "~user/home", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap new file mode 100644 index 0000000..f1c7bd5 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( : && ( (( 0 )) || : ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "0", + ), + loc: "[location]", + )), None), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + )), + ], + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap new file mode 100644 index 0000000..36edfca --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( x = 5 + 3 * 2 ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x = 5 + 3 * 2", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap new file mode 100644 index 0000000..1a1ac14 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for (( i = 0; i < 10; i++ )); do echo $i; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "i = 0", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "i < 10", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "i++", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$i", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_adjacent_semicolons.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_adjacent_semicolons.snap new file mode 100644 index 0000000..1b93ef0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_adjacent_semicolons.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for ((;;)); do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_condition.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_condition.snap new file mode 100644 index 0000000..c183d62 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_condition.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for ((i = 0;;i++)); do echo $i; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "i = 0", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "i++", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$i", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap new file mode 100644 index 0000000..a6b38ca --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for (( ; ; )); do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap new file mode 100644 index 0000000..e8a0496 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( x++ ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x++", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_open_with_line_continuations.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_open_with_line_continuations.snap new file mode 100644 index 0000000..a322ff3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_open_with_line_continuations.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(\\\n(\\\n10+3))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "10+3", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap new file mode 100644 index 0000000..2daebf1 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( 1 + 2 ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "1 + 2", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap new file mode 100644 index 0000000..61b35c1 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "{ echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap new file mode 100644 index 0000000..1bcd08f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "{\n echo hello\n echo world\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap new file mode 100644 index 0000000..9692d49 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap @@ -0,0 +1,88 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;;& b) echo b;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ContinueEvaluatingCases, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "b", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap new file mode 100644 index 0000000..4d783ff --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap @@ -0,0 +1,88 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;& b) echo b;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: UnconditionallyExecuteNextCaseItem, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "b", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap new file mode 100644 index 0000000..b90c58c --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap @@ -0,0 +1,122 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\n a|b) echo ab;;\n c) echo c;;\n *) echo default;;\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "ab", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "c", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "c", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "*", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "default", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap new file mode 100644 index 0000000..2684840 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap new file mode 100644 index 0000000..778d9d6 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x in a b c; do echo $x; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: Some([ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + Word( + value: "c", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap new file mode 100644 index 0000000..e45166f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x in a b c\ndo\n echo $x\ndone", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: Some([ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + Word( + value: "c", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap new file mode 100644 index 0000000..8e46717 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x; do echo $x; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: None, + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap new file mode 100644 index 0000000..c4bc077 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap @@ -0,0 +1,116 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if false; then echo one; elif true; then echo two; else echo three; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "one", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + condition: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ])), + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "two", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "three", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap new file mode 100644 index 0000000..985e206 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true; then echo yes; else echo no; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap new file mode 100644 index 0000000..7dd0e5b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true\nthen\n echo yes\nelse\n echo no\nfi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap new file mode 100644 index 0000000..34d3f95 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true; then echo yes; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap new file mode 100644 index 0000000..bf5a10a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap @@ -0,0 +1,53 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( ( echo nested ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "nested", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_close.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_close.snap new file mode 100644 index 0000000..eab1a88 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_close.snap @@ -0,0 +1,53 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "((sleep 6) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sleep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "6", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_open.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_open.snap new file mode 100644 index 0000000..af08f57 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell_split_open.snap @@ -0,0 +1,53 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( (sleep 6))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sleep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "6", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap new file mode 100644 index 0000000..3ac2f12 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( echo hello )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap new file mode 100644 index 0000000..6766506 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( echo hello; echo world )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap new file mode 100644 index 0000000..5f3e052 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "until false; do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(UntilClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 31, + line: 1, + column: 32, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap new file mode 100644 index 0000000..e3e7b96 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "while true; do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(WhileClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 30, + line: 1, + column: 31, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap new file mode 100644 index 0000000..df570c7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap @@ -0,0 +1,78 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "while true\ndo\n echo loop\n break\ndone", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(WhileClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "break", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 42, + line: 5, + column: 5, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap new file mode 100644 index 0000000..96ae96d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file && -r file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: And(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap new file mode 100644 index 0000000..3597b5d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -eq 5 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticEqualTo, Word( + value: "5", + loc: "[location]", + ), Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap new file mode 100644 index 0000000..e02f20a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -gt 3 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticGreaterThan, Word( + value: "5", + loc: "[location]", + ), Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap new file mode 100644 index 0000000..ca1d58b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 3 -lt 5 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticLessThan, Word( + value: "3", + loc: "[location]", + ), Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap new file mode 100644 index 0000000..d6199b0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -ne 3 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticNotEqualTo, Word( + value: "5", + loc: "[location]", + ), Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap new file mode 100644 index 0000000..8281952 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap @@ -0,0 +1,32 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ( -f file && -r file ) || -d file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: Or(Parenthesized(And(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + )))), UnaryTest(FileExistsAndIsDir, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap new file mode 100644 index 0000000..ce554df --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -d /path/to/dir ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsDir, Word( + value: "/path/to/dir", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap new file mode 100644 index 0000000..b27b73c --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -x file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsExecutable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap new file mode 100644 index 0000000..98e5ecb --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap new file mode 100644 index 0000000..b21bb0b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" > \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(LeftSortsAfterRight, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap new file mode 100644 index 0000000..45707dd --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" < \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(LeftSortsBeforeRight, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap new file mode 100644 index 0000000..016c6c8 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ! -f file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: Not(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap new file mode 100644 index 0000000..3fd03ad --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file || -d file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: Or(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsDir, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap new file mode 100644 index 0000000..fb30cb4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ( -f file ) ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: Parenthesized(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap new file mode 100644 index 0000000..42cbf8d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -r file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap new file mode 100644 index 0000000..67b55f9 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$str\" =~ ^[0-9]+$ ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringMatchesRegex, Word( + value: "\"$str\"", + loc: "[location]", + ), Word( + value: "^[0-9]+$", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap new file mode 100644 index 0000000..e60b436 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"x\" =~ (a| *) ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringMatchesRegex, Word( + value: "\"x\"", + loc: "[location]", + ), Word( + value: "(a| *)", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap new file mode 100644 index 0000000..5fc49d2 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" == \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringExactlyMatchesPattern, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap new file mode 100644 index 0000000..da0846e --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -n \"$var\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasNonZeroLength, Word( + value: "\"$var\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap new file mode 100644 index 0000000..1ad1456 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" != \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringDoesNotExactlyMatchPattern, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap new file mode 100644 index 0000000..8e83db4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$str\" == *pattern* ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringExactlyMatchesPattern, Word( + value: "\"$str\"", + loc: "[location]", + ), Word( + value: "*pattern*", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap new file mode 100644 index 0000000..1fb6fe4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -z \"$var\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasZeroLength, Word( + value: "\"$var\"", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap new file mode 100644 index 0000000..95718fa --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -w file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsWritable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap new file mode 100644 index 0000000..1325a1a --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap new file mode 100644 index 0000000..0d11367 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "function foo { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap new file mode 100644 index 0000000..688d979 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "function foo() { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap new file mode 100644 index 0000000..d158fc3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() {\n echo hello\n echo world\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body.snap new file mode 100644 index 0000000..4d3c801 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body.snap @@ -0,0 +1,32 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() [[ -n $1 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasNonZeroLength, Word( + value: "$1", + loc: "[location]", + )), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body_and_redirect.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body_and_redirect.snap new file mode 100644 index 0000000..e9c2c9b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_extended_test_body_and_redirect.snap @@ -0,0 +1,37 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() [[ -n $1 ]] 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasNonZeroLength, Word( + value: "$1", + loc: "[location]", + )), + loc: "[location]", + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + ))), + ]))), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap new file mode 100644 index 0000000..373d85d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap @@ -0,0 +1,73 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() {\n local x=1\n echo $x\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "local", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap new file mode 100644 index 0000000..8286009 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap @@ -0,0 +1,59 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } 2>&1 | cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + ))), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap new file mode 100644 index 0000000..4cc77f8 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } |& cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Fd(1)), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap new file mode 100644 index 0000000..7ad87c3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() ( echo subshell )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "subshell", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap new file mode 100644 index 0000000..0f9cf17 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/here_docs.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <&2\n\n done\n\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: Some(SourceSpan( + start: SourcePosition( + index: 32, + line: 5, + column: 10, + ), + end: SourcePosition( + index: 33, + line: 5, + column: 11, + ), + )), + ), + Word( + value: "B", + loc: Some(SourceSpan( + start: SourcePosition( + index: 34, + line: 5, + column: 12, + ), + end: SourcePosition( + index: 35, + line: 5, + column: 13, + ), + )), + ), + Word( + value: "C", + loc: Some(SourceSpan( + start: SourcePosition( + index: 36, + line: 5, + column: 14, + ), + end: SourcePosition( + index: 37, + line: 5, + column: 15, + ), + )), + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 60, + line: 8, + column: 5, + ), + end: SourcePosition( + index: 64, + line: 8, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: Some(SourceSpan( + start: SourcePosition( + index: 65, + line: 8, + column: 10, + ), + end: SourcePosition( + index: 73, + line: 8, + column: 18, + ), + )), + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: Some(SourceSpan( + start: SourcePosition( + index: 76, + line: 8, + column: 21, + ), + end: SourcePosition( + index: 77, + line: 8, + column: 22, + ), + )), + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 39, + line: 5, + column: 17, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + ), + loc: SourceSpan( + start: SourcePosition( + index: 23, + line: 5, + column: 1, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap new file mode 100644 index 0000000..021e267 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap @@ -0,0 +1,55 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat file | grep pattern | wc -l", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "pattern", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-l", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap new file mode 100644 index 0000000..7c79806 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap @@ -0,0 +1,32 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "! echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + bang: true, + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap new file mode 100644 index 0000000..bb592be --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap @@ -0,0 +1,44 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time ! echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(Timed(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + ))), + bang: true, + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap new file mode 100644 index 0000000..667b54d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap @@ -0,0 +1,55 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls -la | head -10 | tail -5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-la", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "head", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-10", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tail", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap new file mode 100644 index 0000000..8b4fb0e --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo |& wc", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Fd(1))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap new file mode 100644 index 0000000..4aa6fa8 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello | grep world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap new file mode 100644 index 0000000..b8d248d --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(Timed(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + ))), + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap new file mode 100644 index 0000000..a4c0c91 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time -p echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(TimedWithPosixOutput(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + ))), + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap new file mode 100644 index 0000000..07b4689 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <<< \'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(HereString(None, Word( + value: "\'hello world\'", + loc: "[location]", + ))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap new file mode 100644 index 0000000..5a97f36 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <<< $variable", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(HereString(None, Word( + value: "$variable", + loc: "[location]", + ))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_in_range_still_recognized.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_in_range_still_recognized.snap new file mode 100644 index 0000000..a14b4c0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_in_range_still_recognized.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_out_of_range_does_not_panic.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_out_of_range_does_not_panic.snap new file mode 100644 index 0000000..5d28217 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_io_number_out_of_range_does_not_panic.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo 99999999999999999999>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "99999999999999999999", + loc: "[location]", + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap new file mode 100644 index 0000000..57188e3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap @@ -0,0 +1,73 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "diff <(sort file1) <(sort file2)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "diff", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + ProcessSubstitution(Read, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sort", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ProcessSubstitution(Read, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sort", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap new file mode 100644 index 0000000..33d14c3 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "tee >(grep error > errors.txt)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tee", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + ProcessSubstitution(Write, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "error", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "errors.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap new file mode 100644 index 0000000..032f591 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello >> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Append, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap new file mode 100644 index 0000000..cf63f38 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello >| file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Clobber, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap new file mode 100644 index 0000000..93bb442 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 2>&-", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "-", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap new file mode 100644 index 0000000..0f66aab --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat < input.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap new file mode 100644 index 0000000..65b3673 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command < input.txt > output.txt 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + IoRedirect(File(None, Write, Filename(Word( + value: "output.txt", + loc: "[location]", + )))), + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap new file mode 100644 index 0000000..c6c1fec --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello > file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap new file mode 100644 index 0000000..b832fd5 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command &> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(OutputAndError(Word( + value: "file.txt", + loc: "[location]", + ), false)), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap new file mode 100644 index 0000000..5cce283 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command &>> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(OutputAndError(Word( + value: "file.txt", + loc: "[location]", + ), true)), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap new file mode 100644 index 0000000..1cfffa8 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, ReadAndWrite, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap new file mode 100644 index 0000000..d879d25 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap new file mode 100644 index 0000000..c4d4c85 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command <&3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, DuplicateInput, Duplicate(Word( + value: "3", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap new file mode 100644 index 0000000..ecae6b4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 1>&2", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(1), DuplicateOutput, Duplicate(Word( + value: "2", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap new file mode 100644 index 0000000..2a03efb --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: ":", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap new file mode 100644 index 0000000..7b869b1 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello\\ world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello\\ world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap new file mode 100644 index 0000000..75c0765 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo `whoami`", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "`whoami`", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap new file mode 100644 index 0000000..a0bcdad --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo $(whoami)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$(whoami)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap new file mode 100644 index 0000000..52879a0 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \"hello world\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"hello world\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap new file mode 100644 index 0000000..f661e62 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\'hello world\'", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap new file mode 100644 index 0000000..2ecad8f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo $HOME", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$HOME", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap new file mode 100644 index 0000000..5fcbcee --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap new file mode 100644 index 0000000..955ff91 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap new file mode 100644 index 0000000..3165c93 --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap new file mode 100644 index 0000000..c624bee --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls -la /home", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-la", + loc: "[location]", + )), + Word(Word( + value: "/home", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap new file mode 100644 index 0000000..7415d3b --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "true", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/userland/brush/source/brush-parser/src/parser/winnow_str.rs b/userland/brush/source/brush-parser/src/parser/winnow_str.rs new file mode 100644 index 0000000..bae0b9f --- /dev/null +++ b/userland/brush/source/brush-parser/src/parser/winnow_str.rs @@ -0,0 +1,20 @@ +//! String-based winnow parser + +use winnow::error::ContextError; + +use crate::ast; +use crate::parser::{ParserOptions, SourceInfo}; + +/// Type alias for parser error +type PError = winnow::error::ErrMode; + +/// Parse a shell program from a string with full source location tracking +/// +/// This is not yet implemented. +pub fn parse_program( + _input: &str, + _options: &ParserOptions, + _source_info: &SourceInfo, +) -> Result { + unimplemented!("winnow string parser is not yet implemented") +} diff --git a/userland/brush/source/brush-parser/src/pattern.rs b/userland/brush/source/brush-parser/src/pattern.rs new file mode 100644 index 0000000..51b5053 --- /dev/null +++ b/userland/brush/source/brush-parser/src/pattern.rs @@ -0,0 +1,386 @@ +//! Implements parsing for shell glob and extglob patterns. + +use crate::error; + +/// Represents the kind of an extended glob. +pub enum ExtendedGlobKind { + /// The `+` extended glob; matches one or more occurrences of the inner pattern. + Plus, + /// The `@` extended glob; allows matching an alternation of inner patterns. + At, + /// The `!` extended glob; matches the negation of the inner pattern. + Exclamation, + /// The `?` extended glob; matches zero or one occurrence of the inner pattern. + Question, + /// The `*` extended glob; matches zero or more occurrences of the inner pattern. + Star, +} + +/// Converts a shell pattern to a regular expression string. +/// +/// # Arguments +/// +/// * `pattern` - The shell pattern to convert. +/// * `enable_extended_globbing` - Whether to enable extended globbing (extglob). +pub fn pattern_to_regex_str( + pattern: &str, + enable_extended_globbing: bool, +) -> Result { + let regex_str = pattern_to_regex_translator::pattern(pattern, enable_extended_globbing) + .map_err(|e| error::WordParseError::Pattern(e.into()))?; + Ok(regex_str) +} + +/// Formats a range between two bracket expression members, returning `None` if the +/// range is reversed (and therefore matches nothing). +fn bracket_range(from: (String, char), to: (String, char)) -> Option { + let (from_str, from_c) = from; + let (to_str, to_c) = to; + + if from_c <= to_c { + Some(std::format!("{from_str}-{to_str}")) + } else { + None + } +} + +peg::parser! { + grammar pattern_to_regex_translator(enable_extended_globbing: bool) for str { + pub(crate) rule pattern() -> String = + pieces:(pattern_piece()*) { + pieces.join("") + } + + rule pattern_piece() -> String = + escape_sequence() / + bracket_expression() / + extglob_enabled() s:extended_glob_pattern() { s } / + wildcard() / + [c if regex_char_needs_escaping(c)] { + let mut s = '\\'.to_string(); + s.push(c); + s + } / + [c] { c.to_string() } + + rule escape_sequence() -> String = + sequence:$(['\\'] [c if regex_char_needs_escaping(c)]) { sequence.to_owned() } / + ['\\'] [c] { c.to_string() } + + rule bracket_expression() -> String = + "[" invert:(invert_char()?) leading:(leading_bracket_member()?) members:bracket_member()* "]" { + let mut members = leading + .into_iter() + .chain(members) + .flatten() + .collect::>(); + + // If we completed the parse but ended up with no valid members + // of the bracket expression, then return a regex that matches nothing. + // (Or in the inverted case, matches everything.) + if members.is_empty() { + if invert.is_some() { + String::from(".") + } else { + String::from("(?!)") + } + } else { + if invert.is_some() { + members.insert(0, String::from("^")); + } + + std::format!("[{}]", members.join("")) + } + } + + // A ']' first in the expression (after any '!' or '^') is a member, not the end. + // It may also be the low end of a range, so it goes through the same range + // validation as any other member. + rule leading_bracket_member() -> Option = + from:close_bracket_member() "-" to:single_char_bracket_member() { + bracket_range(from, to) + } / + from:close_bracket_member() { Some(from.0) } + + rule close_bracket_member() -> (String, char) = + "]" { (String::from(r"\]"), ']') } + + rule invert_char() -> bool = + ['!' | '^'] { true } + + rule bracket_member() -> Option = + e:char_class_expression() { Some(e) } / + r:char_range() { r } / + m:single_char_bracket_member() { + let (char_str, _) = m; + Some(char_str) + } + + rule char_class_expression() -> String = + e:$("[:" char_class() ":]") { e.to_owned() } + + rule char_class() = + "alnum" / "alpha" / "blank" / "cntrl" / "digit" / "graph" / "lower" / "print" / "punct" / "space" / "upper"/ "xdigit" + + rule char_range() -> Option = + from:single_char_bracket_member() "-" to:single_char_bracket_member() { + bracket_range(from, to) + } + + rule single_char_bracket_member() -> (String, char) = + // Preserve escaped characters as-is. + ['\\'] [c] { (std::format!("\\{c}"), c) } / + // Escape the characters that are special inside a regex character class; + // left bare, a '^' first in the class would negate it and a '-' would be + // read as a range separator. + [c if matches!(c, '[' | '^' | '-')] { (std::format!("\\{c}"), c) } / + // Any other character except closing bracket gets added as-is. + [c if c != ']'] { (c.to_string(), c) } + + rule wildcard() -> String = + "?" { String::from(".") } / + "*" { String::from(".*") } + + rule extglob_enabled() -> () = + &[_] {? if enable_extended_globbing { Ok(()) } else { Err("extglob disabled") } } + + pub(crate) rule extended_glob_pattern() -> String = + kind:extended_glob_prefix() "(" branches:extended_glob_body() ")" { + let mut s = String::new(); + + // fancy_regex uses ?! to indicate a negative lookahead. + if matches!(kind, ExtendedGlobKind::Exclamation) { + if !branches.is_empty() { + s.push_str("(?:(?!"); + s.push_str(&branches.join("|")); + s.push_str(").*|(?>"); + s.push_str(&branches.join("|")); + s.push_str(").+?|)"); + } else { + s.push_str("(?:.+)"); + } + } else { + s.push('('); + s.push_str(&branches.join("|")); + s.push(')'); + + match kind { + ExtendedGlobKind::Plus => s.push('+'), + ExtendedGlobKind::Question => s.push('?'), + ExtendedGlobKind::Star => s.push('*'), + ExtendedGlobKind::At | ExtendedGlobKind::Exclamation => (), + } + } + + s + } + + rule extended_glob_prefix() -> ExtendedGlobKind = + "+" { ExtendedGlobKind::Plus } / + "@" { ExtendedGlobKind::At } / + "!" { ExtendedGlobKind::Exclamation } / + "?" { ExtendedGlobKind::Question } / + "*" { ExtendedGlobKind::Star } + + pub(crate) rule extended_glob_body() -> Vec = + // Cover case with *no* branches. + &[')'] { vec![] } / + // Otherwise, look for branches separated by '|'. + extended_glob_branch() ** "|" + + rule extended_glob_branch() -> String = + // Cover case of empty branch. + &['|' | ')'] { String::new() } / + pieces:(!['|' | ')'] piece:pattern_piece() { piece })+ { + pieces.join("") + } + + // A glob metacharacter construct: wildcard, bracket expression, or extglob. + rule glob_piece() = + bracket_expression() / + extglob_enabled() extended_glob_pattern() / + wildcard() + + // A non-glob piece: an escape sequence or any character not starting a glob. + rule non_glob_piece() = + escape_sequence() / + !glob_piece() [_] + + // Succeeds (returning true) if the pattern contains at least one glob + // metacharacter. The same bracket_expression, wildcard, and + // extended_glob_pattern rules used for regex conversion are reused here + // via negative lookaheads, keeping a single source of truth. + pub(crate) rule has_glob_metacharacters() -> bool = + non_glob_piece()* glob_piece() [_]* { true } + } +} + +/// Returns whether a pattern string contains any glob metacharacters. +/// +/// Uses the same PEG grammar rules that `pattern_to_regex_str` uses, keeping +/// a single source of truth for what constitutes a glob metacharacter. +/// +/// # Arguments +/// +/// * `pattern` - The shell pattern to check. +/// * `enable_extended_globbing` - Whether to enable extended globbing (extglob). +pub fn pattern_has_glob_metacharacters(pattern: &str, enable_extended_globbing: bool) -> bool { + pattern_to_regex_translator::has_glob_metacharacters(pattern, enable_extended_globbing) + .unwrap_or(false) +} + +/// Returns whether or not a given character needs to be escaped in a regular expression. +/// +/// # Arguments +/// +/// * `c` - The character to check. +pub const fn regex_char_needs_escaping(c: char) -> bool { + matches!( + c, + '[' | ']' | '(' | ')' | '{' | '}' | '*' | '?' | '.' | '+' | '^' | '$' | '|' | '\\' | '-' + ) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + #[test] + fn test_bracket_exprs() -> Result<()> { + assert_eq!(pattern_to_regex_str("[a-z]", true)?, "[a-z]"); + assert_eq!(pattern_to_regex_str("[z-a]", true)?, "(?!)"); + assert_eq!(pattern_to_regex_str("[+-/]", true)?, "[+-/]"); + assert_eq!(pattern_to_regex_str(r"[\*-/]", true)?, r"[\*-/]"); + assert_eq!(pattern_to_regex_str("[abc]", true)?, "[abc]"); + assert_eq!(pattern_to_regex_str(r"[\(]", true)?, r"[\(]"); + assert_eq!(pattern_to_regex_str(r"[(]", true)?, "[(]"); + assert_eq!(pattern_to_regex_str("[[:digit:]]", true)?, "[[:digit:]]"); + assert_eq!(pattern_to_regex_str(r"[-(),!]*", true)?, r"[\-(),!].*"); + assert_eq!( + pattern_to_regex_str(r"[-\(\),\!]*", true)?, + r"[\-\(\),\!].*" + ); + assert_eq!(pattern_to_regex_str(r"[a\-b]", true)?, r"[a\-b]"); + assert_eq!(pattern_to_regex_str(r"[a\-\*]", true)?, r"[a\-\*]"); + + // A ']' in the first member position (after any '!' or '^') is a member of the + // bracket expression, not its terminator. + assert_eq!(pattern_to_regex_str("[]]", true)?, r"[\]]"); + assert_eq!(pattern_to_regex_str("[][]", true)?, r"[\]\[]"); + assert_eq!(pattern_to_regex_str("[]abc[]", true)?, r"[\]abc\[]"); + assert_eq!(pattern_to_regex_str("[]]]", true)?, r"[\]]\]"); + assert_eq!(pattern_to_regex_str("[!]]", true)?, r"[^\]]"); + assert_eq!(pattern_to_regex_str("[^][]", true)?, r"[^\]\[]"); + + // A range starting at that ']' is validated like any other range. + assert_eq!(pattern_to_regex_str("[]-a]", true)?, r"[\]-a]"); + assert_eq!(pattern_to_regex_str("[]-a[]", true)?, r"[\]-a\[]"); + assert_eq!(pattern_to_regex_str("[]-A]", true)?, "(?!)"); + assert_eq!(pattern_to_regex_str("[!]-A]", true)?, "."); + + // '^' and '-' are escaped as members, so a '^' left first by a dropped + // range can't negate the class and a '-' endpoint can't be read as a + // range separator. + assert_eq!(pattern_to_regex_str("[9-0^a]", true)?, r"[\^a]"); + assert_eq!(pattern_to_regex_str("[z-a^]", true)?, r"[\^]"); + assert_eq!(pattern_to_regex_str("[+--]", true)?, r"[+-\-]"); + assert_eq!(pattern_to_regex_str("[--/]", true)?, r"[\--/]"); + + // An unterminated bracket expression is still just literal text. + assert_eq!(pattern_to_regex_str("[]", true)?, r"\[\]"); + assert_eq!(pattern_to_regex_str("[!]", true)?, r"\[!\]"); + + Ok(()) + } + + #[test] + fn test_extended_glob() -> Result<()> { + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(a|b)", true)?, + "(a|b)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(|a)", true)?, + "(|a)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(|)", true)?, + "(|)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_body("ab|ac", true)?, + vec!["ab", "ac"], + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("*(ab|ac)", true)?, + "(ab|ac)*" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_body("", true)?, + Vec::::new(), + ); + + Ok(()) + } + + #[test] + fn test_has_glob_metacharacters() { + // Basic metacharacters. + assert!(pattern_has_glob_metacharacters("*", false)); + assert!(pattern_has_glob_metacharacters("?", false)); + assert!(pattern_has_glob_metacharacters("a*b", false)); + assert!(pattern_has_glob_metacharacters("a?b", false)); + + // Valid bracket expressions. + assert!(pattern_has_glob_metacharacters("[abc]", false)); + assert!(pattern_has_glob_metacharacters("[a-z]", false)); + assert!(pattern_has_glob_metacharacters("[!a]", false)); + + // A leading ']' is a member, so these are bracket expressions too. + assert!(pattern_has_glob_metacharacters("[]]", false)); + assert!(pattern_has_glob_metacharacters("[][]", false)); + assert!(pattern_has_glob_metacharacters("[!]]", false)); + + // Lone `]` is NOT a glob metacharacter. + assert!(!pattern_has_glob_metacharacters("]", false)); + assert!(!pattern_has_glob_metacharacters("foo]", false)); + assert!(!pattern_has_glob_metacharacters("a]b", false)); + + // Lone `[` without matching `]` is NOT a glob metacharacter. + assert!(!pattern_has_glob_metacharacters("[", false)); + assert!(!pattern_has_glob_metacharacters("[]", false)); + assert!(!pattern_has_glob_metacharacters("[!]", false)); + assert!(!pattern_has_glob_metacharacters("[abc", false)); + assert!(!pattern_has_glob_metacharacters("a[b", false)); + + // Plain text — no glob chars. + assert!(!pattern_has_glob_metacharacters("hello", false)); + assert!(!pattern_has_glob_metacharacters("", false)); + + // Backslash-escaped metacharacters are not globs. + assert!(!pattern_has_glob_metacharacters(r"\*", false)); + assert!(!pattern_has_glob_metacharacters(r"\?", false)); + assert!(!pattern_has_glob_metacharacters(r"\[abc]", false)); + + // Extglob patterns — not detected without extended globbing. + assert!(!pattern_has_glob_metacharacters("@(a)", false)); + assert!(!pattern_has_glob_metacharacters("!(a)", false)); + assert!(!pattern_has_glob_metacharacters("+(a)", false)); + + // Extglob patterns — detected with extended globbing. + assert!(pattern_has_glob_metacharacters("@(a)", true)); + assert!(pattern_has_glob_metacharacters("!(a)", true)); + assert!(pattern_has_glob_metacharacters("+(a)", true)); + + // *( and ?( are already caught by * and ? checks. + assert!(pattern_has_glob_metacharacters("*(a)", false)); + assert!(pattern_has_glob_metacharacters("?(a)", false)); + } +} diff --git a/userland/brush/source/brush-parser/src/prompt.rs b/userland/brush/source/brush-parser/src/prompt.rs new file mode 100644 index 0000000..53b2b82 --- /dev/null +++ b/userland/brush/source/brush-parser/src/prompt.rs @@ -0,0 +1,204 @@ +//! Parser for shell prompt syntax (e.g., `PS1`). + +use crate::error; + +/// A piece of a prompt string. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptPiece { + /// An ASCII character. + AsciiCharacter(u32), + /// A backslash character. + Backslash, + /// The bell character. + BellCharacter, + /// A carriage return character. + CarriageReturn, + /// The current command number. + CurrentCommandNumber, + /// The current history number. + CurrentHistoryNumber, + /// The name of the current user. + CurrentUser, + /// Path to the current working directory. + CurrentWorkingDirectory { + /// Whether or not to apply tilde-replacement before expanding. + tilde_replaced: bool, + /// Whether or not to only expand to the basename of the directory. + basename: bool, + }, + /// The current date, using the given format. + Date(PromptDateFormat), + /// The dollar or pound character. + DollarOrPound, + /// Special marker indicating the end of a non-printing sequence of characters. + EndNonPrintingSequence, + /// The escape character. + EscapeCharacter, + /// An escaped sequence not otherwise recognized. + EscapedSequence(String), + /// The hostname of the system. + Hostname { + /// Whether or not to include only up to the first dot of the name. + only_up_to_first_dot: bool, + }, + /// A literal string. + Literal(String), + /// A newline character. + Newline, + /// The number of actively managed jobs. + NumberOfManagedJobs, + /// The base name of the shell. + ShellBaseName, + /// The release of the shell. + ShellRelease, + /// The version of the shell. + ShellVersion, + /// Special marker indicating the start of a non-printing sequence of characters. + StartNonPrintingSequence, + /// The base name of the terminal device. + TerminalDeviceBaseName, + /// The current time, using the given format. + Time(PromptTimeFormat), +} + +/// Format for a date in a prompt. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptDateFormat { + /// A format including weekday, month, and date. + WeekdayMonthDate, + /// A customer string format. + Custom(String), +} + +/// Format for a time in a prompt. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptTimeFormat { + /// A twelve-hour time format with AM/PM. + TwelveHourAM, + /// A twelve-hour time format (HHMMSS). + TwelveHourHHMMSS, + /// A twenty-four-hour time format (HHMM). + TwentyFourHourHHMM, + /// A twenty-four-hour time format (HHMMSS). + TwentyFourHourHHMMSS, +} + +peg::parser! { + grammar prompt_parser() for str { + pub(crate) rule prompt() -> Vec = + pieces:prompt_piece()* + + rule prompt_piece() -> PromptPiece = + special_sequence() / + literal_sequence() + + // + // Reference: https://www.gnu.org/software/bash/manual/bash.html#Controlling-the-Prompt + // + rule special_sequence() -> PromptPiece = + "\\a" { PromptPiece::BellCharacter } / + "\\A" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMM) } / + "\\d" { PromptPiece::Date(PromptDateFormat::WeekdayMonthDate) } / + "\\D{" f:date_format() "}" { PromptPiece::Date(PromptDateFormat::Custom(f)) } / + "\\e" { PromptPiece::EscapeCharacter } / + "\\h" { PromptPiece::Hostname { only_up_to_first_dot: true } } / + "\\H" { PromptPiece::Hostname { only_up_to_first_dot: false } } / + "\\j" { PromptPiece::NumberOfManagedJobs } / + "\\l" { PromptPiece::TerminalDeviceBaseName } / + "\\n" { PromptPiece::Newline } / + "\\r" { PromptPiece::CarriageReturn } / + "\\s" { PromptPiece::ShellBaseName } / + "\\t" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMMSS ) } / + "\\T" { PromptPiece::Time(PromptTimeFormat::TwelveHourHHMMSS ) } / + "\\@" { PromptPiece::Time(PromptTimeFormat::TwelveHourAM ) } / + "\\u" { PromptPiece::CurrentUser } / + "\\v" { PromptPiece::ShellVersion } / + "\\V" { PromptPiece::ShellRelease } / + "\\w" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: false, } } / + "\\W" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: true, } } / + "\\!" { PromptPiece::CurrentHistoryNumber } / + "\\#" { PromptPiece::CurrentCommandNumber } / + "\\$" { PromptPiece::DollarOrPound } / + "\\" n:octal_number() { PromptPiece::AsciiCharacter(n) } / + "\\\\" { PromptPiece::Backslash } / + "\\[" { PromptPiece::StartNonPrintingSequence } / + "\\]" { PromptPiece::EndNonPrintingSequence } / + s:$("\\" [_]) { PromptPiece::EscapedSequence(s.to_owned()) } + + rule literal_sequence() -> PromptPiece = + s:$((!special_sequence() [c])+) { PromptPiece::Literal(s.to_owned()) } + + rule date_format() -> String = + s:$([c if c != '}']*) { s.to_owned() } + + rule octal_number() -> u32 = + s:$(['0'..='7']*<1,3>) {? u32::from_str_radix(s, 8).or(Err("invalid octal number")) } + } +} + +/// Parses a shell prompt string. +/// +/// # Arguments +/// +/// * `s` - The prompt string to parse. +pub fn parse(s: &str) -> Result, error::WordParseError> { + let result = prompt_parser::prompt(s).map_err(|e| error::WordParseError::Prompt(e.into()))?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + + #[test] + fn basic_prompt() -> Result<()> { + assert_eq!( + parse(r"\u@\h:\w$ ")?, + &[ + PromptPiece::CurrentUser, + PromptPiece::Literal("@".to_owned()), + PromptPiece::Hostname { + only_up_to_first_dot: true + }, + PromptPiece::Literal(":".to_owned()), + PromptPiece::CurrentWorkingDirectory { + tilde_replaced: true, + basename: false + }, + PromptPiece::Literal("$ ".to_owned()), + ] + ); + + Ok(()) + } + + #[test] + fn brackets_and_vars() -> Result<()> { + assert_eq!( + parse(r"\[$foo\]\u > ")?, + &[ + PromptPiece::StartNonPrintingSequence, + PromptPiece::Literal("$foo".to_owned()), + PromptPiece::EndNonPrintingSequence, + PromptPiece::CurrentUser, + PromptPiece::Literal(" > ".to_owned()), + ] + ); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-parser/src/readline_binding.rs b/userland/brush/source/brush-parser/src/readline_binding.rs new file mode 100644 index 0000000..57f6658 --- /dev/null +++ b/userland/brush/source/brush-parser/src/readline_binding.rs @@ -0,0 +1,241 @@ +//! Implements a parser for readline binding syntax. + +use crate::error; + +/// Represents a key-sequence-to-shell-command binding. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequenceShellCommandBinding { + /// Key sequence to bind + pub seq: KeySequence, + /// Shell command to bind to the sequence + pub shell_cmd: String, +} + +/// Represents a key-sequence-to-readline-command binding. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequenceReadlineBinding { + /// Key sequence to bind + pub seq: KeySequence, + /// Readline target to bind to the sequence + pub target: ReadlineTarget, +} + +/// Represents a readline target. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum ReadlineTarget { + /// A named readline function. + Function(String), + /// A readline command macro. + Macro(String), +} + +/// Represents a key sequence. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequence(pub Vec); + +/// Represents an element of a key sequence. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum KeySequenceItem { + /// Control + Control, + /// Meta + Meta, + /// Regular character + Byte(u8), +} + +/// Represents a single key stroke. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeyStroke { + /// Meta key is held down + pub meta: bool, + /// Control key is held down + pub control: bool, + /// Primary key code + pub key_code: Vec, +} + +/// Parses a key sequence. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence(input: &str) -> Result { + readline_binding::key_sequence(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Parses a binding specification that maps a key sequence +/// to a shell command. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence_shell_cmd_binding( + input: &str, +) -> Result { + readline_binding::key_sequence_shell_cmd_binding(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Parses a binding specification that maps a key sequence +/// to a readline target. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence_readline_binding( + input: &str, +) -> Result { + readline_binding::key_sequence_readline_binding(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Converts a `KeySequence` to a vector of `KeyStroke`. +/// +/// # Arguments +/// +/// * `seq` - The key sequence to convert +pub fn key_sequence_to_strokes( + seq: &KeySequence, +) -> Result, error::BindingParseError> { + let mut strokes = vec![]; + let mut current_stroke = KeyStroke::default(); + + for item in &seq.0 { + if matches!( + item, + KeySequenceItem::Control | KeySequenceItem::Meta | KeySequenceItem::Byte(b'\x1b') + ) && !current_stroke.key_code.is_empty() + { + strokes.push(current_stroke); + current_stroke = KeyStroke::default(); + } + + match item { + KeySequenceItem::Control => current_stroke.control = true, + KeySequenceItem::Meta => current_stroke.meta = true, + KeySequenceItem::Byte(b) => { + current_stroke.key_code.push(*b); + // If this is a control or meta stroke, the modifier only applies to this one byte, + // so we need to push the stroke and start fresh for subsequent bytes. + if current_stroke.control || current_stroke.meta { + strokes.push(current_stroke); + current_stroke = KeyStroke::default(); + } + } + } + } + + if current_stroke.key_code.is_empty() { + if current_stroke.control || current_stroke.meta { + return Err(error::BindingParseError::MissingKeyCode); + } + } else { + strokes.push(current_stroke); + } + + Ok(strokes) +} + +peg::parser! { + grammar readline_binding() for str { + rule _() = [' ' | '\t' | '\n']* + + pub rule key_sequence_shell_cmd_binding() -> KeySequenceShellCommandBinding = + _ "\"" seq:key_sequence() "\"" _ ":" _ cmd:shell_cmd() _ { KeySequenceShellCommandBinding { seq, shell_cmd: cmd } } + + pub rule key_sequence_readline_binding() -> KeySequenceReadlineBinding = + _ "\"" seq:key_sequence() "\"" _ ":" _ "\"" cmd:readline_cmd() "\"" _ { + KeySequenceReadlineBinding { seq, target: ReadlineTarget::Macro(cmd) } + } / + _ "\"" seq:key_sequence() "\"" _ ":" _ func:readline_function() _ { + KeySequenceReadlineBinding { seq, target: ReadlineTarget::Function(func) } + } + + rule readline_cmd() -> String = s:$([^'"']*) { s.to_string() } + rule shell_cmd() -> String = s:$([_]*) { s.to_string() } + rule readline_function() -> String = s:$([_]*) { s.to_string() } + + // Main rule for parsing a key sequence + pub rule key_sequence() -> KeySequence = + items:key_sequence_item()* { KeySequence(items) } + + rule key_sequence_item() -> KeySequenceItem = + "\\C-" { KeySequenceItem::Control } / + "\\M-" { KeySequenceItem::Meta } / + "\\e" { KeySequenceItem::Byte(b'\x1b') } / + "\\\\" { KeySequenceItem::Byte(b'\\') } / + "\\\"" { KeySequenceItem::Byte(b'"') } / + "\\'" { KeySequenceItem::Byte(b'\'') } / + "\\a" { KeySequenceItem::Byte(b'\x07') } / + "\\b" { KeySequenceItem::Byte(b'\x08') } / + "\\d" { KeySequenceItem::Byte(b'\x7f') } / + "\\f" { KeySequenceItem::Byte(b'\x0c') } / + "\\n" { KeySequenceItem::Byte(b'\n') } / + "\\r" { KeySequenceItem::Byte(b'\r') } / + "\\t" { KeySequenceItem::Byte(b'\t') } / + "\\v" { KeySequenceItem::Byte(b'\x0b') } / + "\\" n:octal_number() { KeySequenceItem::Byte(n) } / + "\\" n:hex_number() { KeySequenceItem::Byte(n) } / + [c if c != '"'] { KeySequenceItem::Byte(c as u8) } + + rule octal_number() -> u8 = + s:$(['0'..='7']*<1,3>) {? u8::from_str_radix(s, 8).or(Err("invalid octal number")) } + + rule hex_number() -> u8 = + s:$(['0'..='9' | 'a'..='f' | 'A'..='F']*<1,2>) {? u8::from_str_radix(s, 16).or(Err("invalid hex number")) } + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + #[test] + fn test_basic_shell_cmd_binding_parse() -> Result<()> { + let binding = parse_key_sequence_shell_cmd_binding(r#""\C-k": xyz"#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Control, KeySequenceItem::Byte(b'k')] + ); + assert_eq!(binding.shell_cmd, "xyz"); + + Ok(()) + } + + #[test] + fn test_basic_readline_func_binding_parse() -> Result<()> { + let binding = parse_key_sequence_readline_binding(r#""\M-x": some-function"#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Meta, KeySequenceItem::Byte(b'x')] + ); + assert_eq!( + binding.target, + ReadlineTarget::Function("some-function".to_string()) + ); + + Ok(()) + } + + #[test] + fn test_basic_readline_cmd_binding_parse() -> Result<()> { + let binding = parse_key_sequence_readline_binding(r#""\C-k": "xyz""#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Control, KeySequenceItem::Byte(b'k')] + ); + assert_eq!(binding.target, ReadlineTarget::Macro(String::from("xyz"))); + + Ok(()) + } +} diff --git a/userland/brush/source/brush-parser/src/snapshot_tests.rs b/userland/brush/source/brush-parser/src/snapshot_tests.rs new file mode 100644 index 0000000..7a74802 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshot_tests.rs @@ -0,0 +1,124 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TestCaseSet { + /// Name of the test case set + pub name: Option, + /// Set of test cases + pub cases: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TestCase { + /// Name of the test case + pub name: Option, + #[serde(default)] + pub stdin: Option, +} + +#[test] +#[ignore = "not yet ready for default-enablement"] +fn test_parser_using_yaml_test_cases() { + insta::glob!("../../brush-shell", "tests/cases/**/*.yaml", |path| { + test_parser_using_yaml(path).unwrap(); + }); +} + +fn test_parser_using_yaml(path: &Path) -> Result<()> { + let yaml_file = std::fs::File::open(path)?; + let test_case_set: TestCaseSet = yaml_serde::from_reader(yaml_file) + .context(format!("parsing {}", path.to_string_lossy()))?; + + test_parser_using_test_case_set(&test_case_set); + + Ok(()) +} + +fn test_parser_using_test_case_set(test_case_set: &TestCaseSet) { + let name = test_case_set.name.as_deref().unwrap_or_default(); + + for test_case in &test_case_set.cases { + parse(name, test_case); + } +} + +// NOTE: The name of this function affects the name of the snapshot generated. +fn parse(test_case_set_name: &str, test_case: &TestCase) { + #[derive(serde::Serialize, serde::Deserialize)] + struct TestCaseInfo { + test_case_set: String, + test_case: String, + } + + let name = test_case.name.as_deref().unwrap_or_default(); + let script_content = test_case.stdin.as_deref().unwrap_or_default(); + + if script_content.is_empty() { + return; + } + + let summary = parse_script_content(script_content); + + let info = TestCaseInfo { + test_case_set: test_case_set_name.to_string(), + test_case: name.to_string(), + }; + + // Generate a cleaned-up name. + let snapshot_suffix = std::format!("{test_case_set_name}-{name}") + .to_lowercase() + .replace(' ', "_") + .replace(|c: char| !c.is_ascii_alphanumeric(), "_"); + + insta::with_settings!({ + info => &info, + prepend_module_to_snapshot => false, + omit_expression => true, + snapshot_suffix => snapshot_suffix, + }, { + insta::assert_ron_snapshot!(summary); + }); +} + +#[cfg_attr(test, derive(serde::Serialize))] +struct ParseSummary<'a> { + input: Vec<&'a str>, + result: ParseResult, +} + +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +enum ParseResult { + Success(crate::ast::Program), + Failure(String), +} + +fn parse_script_content(s: &str) -> ParseSummary<'_> { + let input_lines: Vec<_> = s.lines().collect(); + + let tokens = match crate::tokenize_str_with_options(s, &crate::TokenizerOptions::default()) { + Ok(tokens) => tokens, + Err(err) => { + return ParseSummary { + input: input_lines, + result: ParseResult::Failure(err.to_string()), + }; + } + }; + + let parsed_program = match crate::parse_tokens(&tokens, &crate::ParserOptions::default()) { + Ok(parsed_program) => parsed_program, + Err(err) => { + return ParseSummary { + input: input_lines, + result: ParseResult::Failure(err.to_string()), + }; + } + }; + + ParseSummary { + input: input_lines, + result: ParseResult::Success(parsed_program), + } +} diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap new file mode 100644 index 0000000..0d82351 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$((1+2))b c\")?" +--- +TokenizerResult( + input: "a$((1+2))b c", + result: [ + Word("a$((1+2))b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + Word("c", SourceSpan( + start: SourcePosition( + index: 11, + line: 1, + column: 12, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap new file mode 100644 index 0000000..e19b70b --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$(( (0) ))\")?" +--- +TokenizerResult( + input: "$(( (0) ))", + result: [ + Word("$(( (0) ))", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap new file mode 100644 index 0000000..686294c --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$(( 1 ))\")?" +--- +TokenizerResult( + input: "$(( 1 ))", + result: [ + Word("$(( 1 ))", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap new file mode 100644 index 0000000..64e861c --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo `echo\\`hi`\")?" +--- +TokenizerResult( + input: "echo `echo\\`hi`", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("`echo\\`hi`", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 15, + line: 1, + column: 16, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap new file mode 100644 index 0000000..4d5765e --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a${x}b\")?" +--- +TokenizerResult( + input: "a${x}b", + result: [ + Word("a${x}b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap new file mode 100644 index 0000000..d351246 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"${x}\")?" +--- +TokenizerResult( + input: "${x}", + result: [ + Word("${x}", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap new file mode 100644 index 0000000..b02f5a7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a${x\\}}b\")?" +--- +TokenizerResult( + input: "a${x\\}}b", + result: [ + Word("a${x\\}}b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap new file mode 100644 index 0000000..cccd129 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$(echo hi)b c\")?" +--- +TokenizerResult( + input: "a$(echo hi)b c", + result: [ + Word("a$(echo hi)b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + Word("c", SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap new file mode 100644 index 0000000..df02257 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"echo $(echo !(x))\")?" +--- +TokenizerResult( + input: "echo $(echo !(x))", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("$(echo !(x))", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap new file mode 100644 index 0000000..26f495a --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$( (:) )\")?" +--- +TokenizerResult( + input: "$( (:) )", + result: [ + Word("$( (:) )", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap new file mode 100644 index 0000000..a2ade98 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a #comment\n\")?" +--- +TokenizerResult( + input: "a #comment\n", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Operator("\n", SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 11, + line: 2, + column: 1, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap new file mode 100644 index 0000000..82f249d --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a #comment\")?" +--- +TokenizerResult( + input: "a #comment", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap new file mode 100644 index 0000000..5507f7f --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo $(cat <>b\")?" +--- +TokenizerResult( + input: "a>>b", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Operator(">>", SourceSpan( + start: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + Word("b", SourceSpan( + start: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap new file mode 100644 index 0000000..f29c9ef --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo `echo hi`\")?" +--- +TokenizerResult( + input: "echo `echo hi`", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("`echo hi`", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap new file mode 100644 index 0000000..e9c397c --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"x'a b'y\")?" +--- +TokenizerResult( + input: "x\'a b\'y", + result: [ + Word("x\'a b\'y", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap new file mode 100644 index 0000000..0fc4868 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$@\")?" +--- +TokenizerResult( + input: "$@", + result: [ + Word("$@", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap new file mode 100644 index 0000000..d2b636b --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$!\")?" +--- +TokenizerResult( + input: "$!", + result: [ + Word("$!", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap new file mode 100644 index 0000000..01d525c --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$?\")?" +--- +TokenizerResult( + input: "$?", + result: [ + Word("$?", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap new file mode 100644 index 0000000..f2d7e2b --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$*\")?" +--- +TokenizerResult( + input: "$*", + result: [ + Word("$*", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap new file mode 100644 index 0000000..7849617 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$$\")?" +--- +TokenizerResult( + input: "$$", + result: [ + Word("$$", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap new file mode 100644 index 0000000..41e8881 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$x\")?" +--- +TokenizerResult( + input: "a$x", + result: [ + Word("a$x", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap new file mode 100644 index 0000000..d704508 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$x\")?" +--- +TokenizerResult( + input: "$x", + result: [ + Word("$x", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap new file mode 100644 index 0000000..5a39c2e --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap @@ -0,0 +1,45 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"1 2 3\")?" +--- +TokenizerResult( + input: "1 2 3", + result: [ + Word("1", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Word("2", SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + Word("3", SourceSpan( + start: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + end: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + )), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap new file mode 100644 index 0000000..04e682b --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap @@ -0,0 +1,22 @@ +--- +source: brush-parser/src/word.rs +expression: "super::parse_brace_expansions(input,\n&options)?.ok_or_else(||\nanyhow::anyhow!(\"Expected brace expansion to be parsed successfully\"))?" +--- +[ + Expr([ + Child([ + Text("a"), + ]), + Child([ + Text("b"), + Expr([ + Child([ + Text("1"), + ]), + Child([ + Text("2"), + ]), + ]), + ]), + ]), +] diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap new file mode 100644 index 0000000..7da13fa --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "super::parse_brace_expansions(input,\n&options)?.ok_or_else(||\nanyhow::anyhow!(\"Expected brace expansion to be parsed successfully\"))?" +--- +[ + Text("x"), + Expr([ + Child([ + Text("a"), + ]), + Child([ + Text("b"), + ]), + ]), + Text("y"), +] diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap new file mode 100644 index 0000000..00ccbf9 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r\"$'\\\\'\")?" +--- +ParseTestResults( + input: "$\'\\\\\'", + result: [ + WordPieceWithSource( + piece: AnsiCQuotedText("\\\\"), + start_index: 0, + end_index: 5, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap new file mode 100644 index 0000000..0082b31 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r\"$'hi\\nthere\\t'\")?" +--- +ParseTestResults( + input: "$\'hi\\nthere\\t\'", + result: [ + WordPieceWithSource( + piece: AnsiCQuotedText("hi\\nthere\\t"), + start_index: 0, + end_index: 14, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap new file mode 100644 index 0000000..89f7bfc --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$((0))\")?" +--- +ParseTestResults( + input: "$((0))", + result: [ + WordPieceWithSource( + piece: ArithmeticExpression(UnexpandedArithmeticExpr( + value: "0", + )), + start_index: 0, + end_index: 6, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap new file mode 100644 index 0000000..dbc2077 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$((((1+2)*3)))\")?" +--- +ParseTestResults( + input: "$((((1+2)*3)))", + result: [ + WordPieceWithSource( + piece: ArithmeticExpression(UnexpandedArithmeticExpr( + value: "((1+2)*3)", + )), + start_index: 0, + end_index: 14, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap new file mode 100644 index 0000000..7efb821 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"`echo hi`\")?" +--- +ParseTestResults( + input: "`echo hi`", + result: [ + WordPieceWithSource( + piece: BackquotedCommandSubstitution("echo hi"), + start_index: 0, + end_index: 9, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap new file mode 100644 index 0000000..24152e9 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"\"`echo hi`\"\"#)?" +--- +ParseTestResults( + input: "\"`echo hi`\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: BackquotedCommandSubstitution("echo hi"), + start_index: 1, + end_index: 10, + ), + ]), + start_index: 0, + end_index: 11, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap new file mode 100644 index 0000000..bf70e03 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n`hello`\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n`hello`\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n`hello`\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap new file mode 100644 index 0000000..b80d925 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n\\\"hello\\\"\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n\"hello\"\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n\"hello\"\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap new file mode 100644 index 0000000..aa5cb5e --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n'hello'\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n\'hello\'\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n\'hello\'\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap new file mode 100644 index 0000000..2228ee9 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\na ` b\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\na ` b\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\na ` b\nEOF\n"), + start_index: 1, + end_index: 26, + ), + ]), + start_index: 0, + end_index: 27, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap new file mode 100644 index 0000000..621b599 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\nit's here\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\nit\'s here\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\nit\'s here\nEOF\n"), + start_index: 1, + end_index: 30, + ), + ]), + start_index: 0, + end_index: 31, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap new file mode 100644 index 0000000..6b66ed7 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$(echo hi)\")?" +--- +ParseTestResults( + input: "$(echo hi)", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo hi"), + start_index: 0, + end_index: 10, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap new file mode 100644 index 0000000..2f52d2f --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$(echo !(x))\")?" +--- +ParseTestResults( + input: "$(echo !(x))", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo !(x)"), + start_index: 0, + end_index: 12, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap new file mode 100644 index 0000000..4601f7a --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"$(echo \"hi\")\"#)?" +--- +ParseTestResults( + input: "$(echo \"hi\")", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo \"hi\""), + start_index: 0, + end_index: 12, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_multibyte_chars.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_multibyte_chars.snap new file mode 100644 index 0000000..f3cc2a4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_multibyte_chars.snap @@ -0,0 +1,24 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"é$(echo “ü”)ñ\")?" +--- +ParseTestResults( + input: "é$(echo “ü”)ñ", + result: [ + WordPieceWithSource( + piece: Text("é"), + start_index: 0, + end_index: 2, + ), + WordPieceWithSource( + piece: CommandSubstitution("echo “ü”"), + start_index: 2, + end_index: 18, + ), + WordPieceWithSource( + piece: Text("ñ"), + start_index: 18, + end_index: 20, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap new file mode 100644 index 0000000..e9a846b --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"\"a ${b} c\"\"#)?" +--- +ParseTestResults( + input: "\"a ${b} c\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: Text("a "), + start_index: 1, + end_index: 3, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("b"), + indirect: false, + )), + start_index: 3, + end_index: 7, + ), + WordPieceWithSource( + piece: Text(" c"), + start_index: 7, + end_index: 9, + ), + ]), + start_index: 0, + end_index: 10, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap new file mode 100644 index 0000000..d7c271d --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap @@ -0,0 +1,27 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"+([$var])\")?" +--- +ParseTestResults( + input: "+([$var])", + result: [ + WordPieceWithSource( + piece: Text("+(["), + start_index: 0, + end_index: 3, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("var"), + indirect: false, + )), + start_index: 3, + end_index: 7, + ), + WordPieceWithSource( + piece: Text("])"), + start_index: 7, + end_index: 9, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap new file mode 100644 index 0000000..0d3f6ce --- /dev/null +++ b/userland/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"$\"a ${b} c\"\"#)?" +--- +ParseTestResults( + input: "$\"a ${b} c\"", + result: [ + WordPieceWithSource( + piece: GettextDoubleQuotedSequence([ + WordPieceWithSource( + piece: Text("a "), + start_index: 2, + end_index: 4, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("b"), + indirect: false, + )), + start_index: 4, + end_index: 8, + ), + WordPieceWithSource( + piece: Text(" c"), + start_index: 8, + end_index: 10, + ), + ]), + start_index: 0, + end_index: 11, + ), + ], +) diff --git a/userland/brush/source/brush-parser/src/source.rs b/userland/brush/source/brush-parser/src/source.rs new file mode 100644 index 0000000..f6279f4 --- /dev/null +++ b/userland/brush/source/brush-parser/src/source.rs @@ -0,0 +1,94 @@ +use std::{fmt::Display, sync::Arc}; + +/// Represents a position in source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourcePosition { + /// The 0-based index of the character in the input stream. + pub index: usize, + /// The 1-based line number. + pub line: usize, + /// The 1-based column number. + pub column: usize, +} + +impl Display for SourcePosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{},{}", self.line, self.column)) + } +} + +impl SourcePosition { + /// Returns a new `SourcePosition` offset by the given `SourcePositionOffset`. + /// + /// # Arguments + /// + /// * `offset` - The offset to apply. + #[must_use] + pub const fn offset(&self, offset: &SourcePositionOffset) -> Self { + Self { + index: self.index + offset.index, + line: self.line + offset.line, + column: if offset.line == 0 { + self.column + offset.column + } else { + offset.column + 1 + }, + } + } +} + +#[cfg(feature = "diagnostics")] +impl From<&SourcePosition> for miette::SourceOffset { + #[allow(clippy::cast_sign_loss)] + fn from(position: &SourcePosition) -> Self { + position.index.into() + } +} + +/// Represents an offset in source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourcePositionOffset { + /// The 0-based character offset. + pub index: usize, + /// The 0-based line offset. + pub line: usize, + /// The 0-based column offset. + pub column: usize, +} + +/// Represents a span within source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourceSpan { + /// The start position. + pub start: Arc, + /// The end position of the span (exclusive). + pub end: Arc, +} + +impl SourceSpan { + /// Returns the length of the token in characters. + pub fn length(&self) -> usize { + self.end.index - self.start.index + } + pub(crate) fn within(start: &Self, end: &Self) -> Self { + Self { + start: start.start.clone(), + end: end.end.clone(), + } + } +} diff --git a/userland/brush/source/brush-parser/src/test_command.rs b/userland/brush/source/brush-parser/src/test_command.rs new file mode 100644 index 0000000..fdffc4c --- /dev/null +++ b/userland/brush/source/brush-parser/src/test_command.rs @@ -0,0 +1,105 @@ +//! Parser for shell test commands. + +use crate::{ast, error}; + +/// Parses a test command expression. +/// +/// # Arguments +/// +/// * `input` - The test command expression to parse, in string form. +pub fn parse>(input: &[S]) -> Result { + let expr = + test_command::full_expression(&input.iter().map(AsRef::as_ref).collect::>())?; + + Ok(expr) +} + +peg::parser! { + grammar test_command<'a>() for [&'a str] { + pub(crate) rule full_expression() -> ast::TestExpr = + end() { ast::TestExpr::False } / + e:one_arg_expr() end() { e } / + e:two_arg_expr() end() { e } / + e:three_arg_expr() end() { e } / + e:four_arg_expr() end() { e } / + expression() + + rule one_arg_expr() -> ast::TestExpr = + [s] { ast::TestExpr::Literal(s.to_owned()) } + + rule two_arg_expr() -> ast::TestExpr = + ["!"] e:one_arg_expr() { ast::TestExpr::Not(Box::from(e)) } / + op:unary_op() [s] { ast::TestExpr::UnaryTest(op, s.to_owned()) } + + rule three_arg_expr() -> ast::TestExpr = + [left] ["-a"] [right] { ast::TestExpr::And(Box::from(ast::TestExpr::Literal(left.to_owned())), Box::from(ast::TestExpr::Literal(right.to_owned()))) } / + [left] ["-o"] [right] { ast::TestExpr::Or(Box::from(ast::TestExpr::Literal(left.to_owned())), Box::from(ast::TestExpr::Literal(right.to_owned()))) } / + [left] op:binary_op() [right] { ast::TestExpr::BinaryTest(op, left.to_owned(), right.to_owned()) } / + ["!"] e:two_arg_expr() { ast::TestExpr::Not(Box::from(e)) } / + ["("] e:one_arg_expr() [")"] { e } + + rule four_arg_expr() -> ast::TestExpr = + ["!"] e:three_arg_expr() { ast::TestExpr::Not(Box::from(e)) } + + rule expression() -> ast::TestExpr = precedence! { + left:(@) ["-a"] right:@ { ast::TestExpr::And(Box::from(left), Box::from(right)) } + left:(@) ["-o"] right:@ { ast::TestExpr::Or(Box::from(left), Box::from(right)) } + -- + ["("] e:expression() [")"] { ast::TestExpr::Parenthesized(Box::from(e)) } + -- + ["!"] e:@ { ast::TestExpr::Not(Box::from(e)) } + -- + [left] op:binary_op() [right] { ast::TestExpr::BinaryTest(op, left.to_owned(), right.to_owned()) } + -- + op:unary_op() [operand] { ast::TestExpr::UnaryTest(op, operand.to_owned()) } + -- + [s] { ast::TestExpr::Literal(s.to_owned()) } + } + + rule unary_op() -> ast::UnaryPredicate = + ["-a"] { ast::UnaryPredicate::FileExists } / + ["-b"] { ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile } / + ["-c"] { ast::UnaryPredicate::FileExistsAndIsCharSpecialFile } / + ["-d"] { ast::UnaryPredicate::FileExistsAndIsDir } / + ["-e"] { ast::UnaryPredicate::FileExists } / + ["-f"] { ast::UnaryPredicate::FileExistsAndIsRegularFile } / + ["-g"] { ast::UnaryPredicate::FileExistsAndIsSetgid } / + ["-h"] { ast::UnaryPredicate::FileExistsAndIsSymlink } / + ["-k"] { ast::UnaryPredicate::FileExistsAndHasStickyBit } / + ["-n"] { ast::UnaryPredicate::StringHasNonZeroLength } / + ["-o"] { ast::UnaryPredicate::ShellOptionEnabled } / + ["-p"] { ast::UnaryPredicate::FileExistsAndIsFifo } / + ["-r"] { ast::UnaryPredicate::FileExistsAndIsReadable } / + ["-s"] { ast::UnaryPredicate::FileExistsAndIsNotZeroLength } / + ["-t"] { ast::UnaryPredicate::FdIsOpenTerminal } / + ["-u"] { ast::UnaryPredicate::FileExistsAndIsSetuid } / + ["-v"] { ast::UnaryPredicate::ShellVariableIsSetAndAssigned } / + ["-w"] { ast::UnaryPredicate::FileExistsAndIsWritable } / + ["-x"] { ast::UnaryPredicate::FileExistsAndIsExecutable } / + ["-z"] { ast::UnaryPredicate::StringHasZeroLength } / + ["-G"] { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId } / + ["-L"] { ast::UnaryPredicate::FileExistsAndIsSymlink } / + ["-N"] { ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead } / + ["-O"] { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId } / + ["-R"] { ast::UnaryPredicate::ShellVariableIsSetAndNameRef } / + ["-S"] { ast::UnaryPredicate::FileExistsAndIsSocket } + + rule binary_op() -> ast::BinaryPredicate = + ["=="] { ast::BinaryPredicate::StringExactlyMatchesString } / + ["-ef"] { ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers } / + ["-eq"] { ast::BinaryPredicate::ArithmeticEqualTo } / + ["-ge"] { ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo } / + ["-gt"] { ast::BinaryPredicate::ArithmeticGreaterThan } / + ["-le"] { ast::BinaryPredicate::ArithmeticLessThanOrEqualTo } / + ["-lt"] { ast::BinaryPredicate::ArithmeticLessThan } / + ["-ne"] { ast::BinaryPredicate::ArithmeticNotEqualTo } / + ["-nt"] { ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot } / + ["-ot"] { ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes } / + ["="] { ast::BinaryPredicate::StringExactlyMatchesString } / + ["!="] { ast::BinaryPredicate::StringDoesNotExactlyMatchString } / + ["<"] { ast::BinaryPredicate::LeftSortsBeforeRight } / + [">"] { ast::BinaryPredicate::LeftSortsAfterRight } + + rule end() = ![_] + } +} diff --git a/userland/brush/source/brush-parser/src/tokenizer.rs b/userland/brush/source/brush-parser/src/tokenizer.rs new file mode 100644 index 0000000..bea004e --- /dev/null +++ b/userland/brush/source/brush-parser/src/tokenizer.rs @@ -0,0 +1,2634 @@ +use std::borrow::Cow; +use std::sync::Arc; +use utf8_chars::BufReadCharsExt; + +use crate::{SourcePosition, SourceSpan}; + +mod char_reader; +use char_reader::CharReader; + +#[derive(Clone, Debug)] +pub(crate) enum TokenEndReason { + /// End of input was reached. + EndOfInput, + /// An unescaped newline char was reached. + UnescapedNewLine, + /// The next char (not yet consumed) closes the nested expansion being tokenized. Never + /// carries a token, so here-document bookkeeping can't hold it back. + ExpansionClosingChar, + /// A non-newline blank char was reached. + NonNewLineBlank, + /// A here-document's body is starting. + HereDocumentBodyStart, + /// A here-document's body was terminated. + HereDocumentBodyEnd, + /// A here-document's end tag was reached. + HereDocumentEndTag, + /// An operator was started. + OperatorStart, + /// An operator was terminated. + OperatorEnd, + /// Some other condition was reached. + Other, +} + +/// Compatibility alias for `SourceSpan`. +pub type TokenLocation = SourceSpan; + +/// Represents a token extracted from a shell script. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Token { + /// An operator token. + Operator(String, SourceSpan), + /// A word token. + Word(String, SourceSpan), +} + +impl Token { + /// Returns the string value of the token. + pub fn to_str(&self) -> &str { + match self { + Self::Operator(s, _) => s, + Self::Word(s, _) => s, + } + } + + /// Returns the location of the token in the source script. + pub const fn location(&self) -> &SourceSpan { + match self { + Self::Operator(_, l) => l, + Self::Word(_, l) => l, + } + } +} + +#[cfg(feature = "diagnostics")] +impl From<&Token> for miette::SourceSpan { + fn from(token: &Token) -> Self { + let start = token.location().start.as_ref(); + Self::new(start.into(), token.location().length()) + } +} + +/// Encapsulates the result of tokenizing a shell script. +#[derive(Clone, Debug)] +pub(crate) struct TokenizeResult { + /// Reason for tokenization ending. + pub reason: TokenEndReason, + /// The token that was extracted, if any. + pub token: Option, +} + +/// Represents an error that occurred during tokenization. +#[derive(thiserror::Error, Debug)] +pub enum TokenizerError { + /// An unterminated escape sequence was encountered at the end of the input stream. + #[error("unterminated escape sequence")] + UnterminatedEscapeSequence, + + /// An unterminated single-quoted substring was encountered at the end of the input stream. + #[error("unterminated single quote at {0}")] + UnterminatedSingleQuote(SourcePosition), + + /// An unterminated ANSI C-quoted substring was encountered at the end of the input stream. + #[error("unterminated ANSI C quote at {0}")] + UnterminatedAnsiCQuote(SourcePosition), + + /// An unterminated double-quoted substring was encountered at the end of the input stream. + #[error("unterminated double quote at {0}")] + UnterminatedDoubleQuote(SourcePosition), + + /// An unterminated back-quoted substring was encountered at the end of the input stream. + #[error("unterminated backquote near {0}")] + UnterminatedBackquote(SourcePosition), + + /// An unterminated extended glob (extglob) pattern was encountered at the end of the input + /// stream. + #[error("unterminated extglob near {0}")] + UnterminatedExtendedGlob(SourcePosition), + + /// An unterminated variable expression was encountered at the end of the input stream. + #[error("unterminated variable expression")] + UnterminatedVariable, + + /// An unterminated command substitiion was encountered at the end of the input stream. + #[error("unterminated command substitution")] + UnterminatedCommandSubstitution, + + /// An unterminated arithmetic or other expansion was encountered at the end of the input + /// stream. + #[error("unterminated expansion")] + UnterminatedExpansion, + + /// An error occurred decoding UTF-8 characters in the input stream. + #[error("failed to decode UTF-8 characters")] + FailedDecoding, + + /// A here-document body was due with no here tag recorded for it. This reflects an + /// inconsistency in the tokenizer's own state, not a problem with the input. + #[error("missing here tag for here document body")] + MissingHereTagForDocumentBody, + + /// A here-document operator (`<<` or `<<-`) was followed by an operator (e.g., a newline, + /// `;` or `)`) where its tag belongs; the string is that operator. + #[error("missing here-document tag before {0:?}")] + MissingHereTag(String), + + /// The input ended after a here-document operator (`<<` or `<<-`) and before its tag, but + /// not right at the operator (e.g., after blanks following it). Input ending right at the + /// operator (even after a line continuation, which is removed) tokenizes, leaving the parser + /// to report its end. Unlike [`TokenizerError::MissingHereTag`], more input could still + /// supply the tag. + #[error("missing here-document tag at end of input")] + MissingHereTagAtEndOfInput, + + /// An unterminated here document sequence was encountered at the end of the input stream. + #[error("unterminated here document; tag(s) {0} declared at {1}")] + UnterminatedHereDocuments(String, String), + + /// Expansions delimited by brackets (`$(...)`, `$((...))`, `$[...]` and `${...}`) were + /// nested in one another more deeply than the tokenizer supports. + #[error("expansions nested too deeply (limit: {})", MAX_EXPANSION_NESTING)] + ExpansionNestingTooDeep, + + /// An I/O error occurred while reading from the input stream. + #[error("failed to read input")] + ReadError(#[from] std::io::Error), +} + +impl TokenizerError { + /// Returns true if the error represents an error that could possibly be due + /// to an incomplete input stream. + pub const fn is_incomplete(&self) -> bool { + matches!( + self, + Self::UnterminatedEscapeSequence + | Self::UnterminatedAnsiCQuote(..) + | Self::UnterminatedSingleQuote(..) + | Self::UnterminatedDoubleQuote(..) + | Self::UnterminatedBackquote(..) + | Self::UnterminatedCommandSubstitution + | Self::UnterminatedExpansion + | Self::UnterminatedVariable + | Self::UnterminatedExtendedGlob(..) + | Self::UnterminatedHereDocuments(..) + | Self::MissingHereTagAtEndOfInput + ) + } +} + +/// Encapsulates a sequence of tokens. +#[derive(Debug)] +pub(crate) struct Tokens<'a> { + /// Sequence of tokens. + pub tokens: &'a [Token], +} + +#[derive(Clone, Debug)] +enum QuoteMode { + None, + AnsiC(SourcePosition), + Single(SourcePosition), + Double(SourcePosition), +} + +#[derive(Clone, Debug, Default)] +enum HereState { + /// In this state, we are not currently tracking any here-documents. + #[default] + None, + /// In this state, a here-document operator is being delimited, and the next token will be + /// its tag. + NextTokenIsHereTag { remove_tabs: bool }, + /// In this state, the operator has been read, and the token now being read (once it ends) + /// will be its tag. + CurrentTokenIsHereTag { + remove_tabs: bool, + operator_token_result: TokenizeResult, + }, + /// In this state, we expect that the *next line* will be the body of + /// a here-document. + NextLineIsHereDoc, + /// In this state, we are in the set of lines that comprise 1 or more + /// consecutive here-document bodies. + InHereDocs, +} + +impl HereState { + /// Returns true if a here-document operator has been read but its tag hasn't (yet). + const fn awaiting_tag(&self) -> bool { + matches!( + self, + Self::NextTokenIsHereTag { .. } | Self::CurrentTokenIsHereTag { .. } + ) + } +} + +#[derive(Clone, Debug)] +struct HereTag { + tag: String, + tag_was_escaped_or_quoted: bool, + remove_tabs: bool, + position: SourcePosition, + tokens: Vec, + pending_tokens_after: Vec, +} + +#[derive(Clone, Debug)] +struct CrossTokenParseState { + /// Cursor within the overall token stream; used for error reporting. + cursor: SourcePosition, + /// Tokens already tokenized that should be used first to serve requests for tokens. + queued_tokens: Vec, + /// State of the expansion frame being tokenized; see [`ExpansionFrame`]. + frame: ExpansionFrame, +} + +/// An expansion delimited by brackets, whose contents are tokenized recursively (as a +/// [`ExpansionFrame`] of their own) while it's consumed as part of a word. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NestedExpansion { + /// `$(...)` + CommandSubstitution, + /// `$((...))` + ArithmeticExpansion, + /// `$[...]` + LegacyArithmeticExpansion, + /// `${...}` + ParameterExpansion, +} + +impl NestedExpansion { + /// Returns the character that closes the expansion. + const fn closing_char(self) -> char { + match self { + Self::CommandSubstitution | Self::ArithmeticExpansion => ')', + Self::LegacyArithmeticExpansion => ']', + Self::ParameterExpansion => '}', + } + } + + /// Returns whether the expansion's contents are arithmetic, where `<<` is a shift. + const fn is_arithmetic(self) -> bool { + matches!( + self, + Self::ArithmeticExpansion | Self::LegacyArithmeticExpansion + ) + } + + /// Returns the character that, in the expansion's contents, pairs with its closing char: + /// the `(` of `$( (a) )`, or the `[` of `$[ a[1] ]`. Bash doesn't pair braces in `${...}`. + const fn opening_char(self) -> Option { + match self { + Self::CommandSubstitution | Self::ArithmeticExpansion => Some('('), + Self::LegacyArithmeticExpansion => Some('['), + Self::ParameterExpansion => None, + } + } + + /// Returns how many closing chars end the expansion, counting from just after its + /// opening: two for `$((`, whose opening leaves two parens open, and one otherwise. + const fn closers_after_opening(self) -> usize { + match self { + Self::ArithmeticExpansion => 2, + _ => 1, + } + } + + /// Returns the error for input that ends before the expansion is closed. + const fn unterminated_error(self) -> TokenizerError { + match self { + Self::ParameterExpansion => TokenizerError::UnterminatedVariable, + _ => TokenizerError::UnterminatedExpansion, + } + } +} + +/// Tokenizer state belonging to one frame of the stack of nested expansions being tokenized; +/// the bottom frame is the top level of the input, outside any expansion. Consuming a nested +/// expansion pushes a fresh frame for its contents and pops it at the expansion's end, as +/// bash saves and restores its parser state around a command substitution. The stack is the +/// recursion itself: each enclosing frame is held by the call consuming the expansion nested +/// in it. +#[derive(Clone, Debug, Default)] +struct ExpansionFrame { + /// The expansion whose contents this frame is, if any (none at the bottom). + expansion: Option, + /// How many frames enclose this one; see [`MAX_EXPANSION_NESTING`]. + depth: u32, + /// Current state of parsing here-documents. + here_state: HereState, + /// Ordered queue of here tags for which we're still looking for matching here-document bodies. + current_here_tags: Vec, + /// How many closing chars (see [`NestedExpansion::closing_char`]) the expansion needs + /// before it ends. Its contents may use the closing char too, paired with an opener of + /// their own (see [`NestedExpansion::opening_char`]); each opener read needs one more. + closers_needed: usize, + /// How many parens of the `((...))` command being read are still open, if any (zero + /// outside one). Within one, `<<` is a shift rather than a here-document operator. (In an + /// arithmetic expansion's contents, `<<` is always a shift; see + /// [`NestedExpansion::is_arithmetic`].) + arithmetic_command_parens: usize, +} + +impl ExpansionFrame { + /// Notes that `c` was read unquoted from the frame's own contents (not from anything + /// nested in them), keeping count of the closing chars the expansion needs: an opener + /// needs one more, and a closing char that doesn't end the expansion closes an opener. + fn note_unquoted_char(&mut self, c: char) { + let Some(expansion) = self.expansion else { + return; + }; + if expansion.opening_char() == Some(c) { + self.closers_needed += 1; + } else if expansion.closing_char() == c { + // A closing char that ends the expansion is taken as such before it's read as + // contents, so it never gets here. + debug_assert!(self.closers_needed > 1, "read the expansion's closing char"); + self.closers_needed -= 1; + } + } +} + +/// Options controlling how the tokenizer operates. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct TokenizerOptions { + /// Whether or not to enable extended globbing patterns (extglob). + pub enable_extended_globbing: bool, + /// Whether or not to operate in POSIX compliance mode. + pub posix_mode: bool, + /// Whether or not we're running in SH emulation mode. + pub sh_mode: bool, +} + +impl Default for TokenizerOptions { + fn default() -> Self { + Self { + enable_extended_globbing: true, + posix_mode: false, + sh_mode: false, + } + } +} + +/// Maximum depth of nested expansions (see [`NestedExpansion`]) the tokenizer will descend +/// into, so deeply nested input can't overflow the stack while it's tokenized. Real scripts +/// rarely nest more than a few levels: of ~2,900 surveyed (including bash-completion and +/// ble.sh), none nested more than 4. +/// +/// This bounds only the tokenizer's own recursion. Words keep their nesting, so later passes +/// over them (e.g., expanding them) recurse as deeply, with stack frames of their own that may +/// be far larger (in an unoptimized build, over 100 KiB per level of `${...}` expansion). +pub(crate) const MAX_EXPANSION_NESTING: u32 = 32; + +/// A tokenizer for shell scripts. +pub(crate) struct Tokenizer<'a, R: ?Sized + std::io::BufRead> { + char_reader: CharReader>, + /// Chars read ahead and put back, to be read again before any further input; see + /// [`Tokenizer::read_here_doc_bodies_left_pending`]. + unread: Option, + /// Whether to read the bodies of here-documents left pending when a nested expansion ends; + /// see [`Tokenizer::read_here_doc_bodies_left_pending`]. [`command_substitution_body`] + /// doesn't: it wants only the text before the `)`, and the bodies follow it. + read_bodies_left_pending: bool, + cross_state: CrossTokenParseState, + options: TokenizerOptions, +} + +/// Chars read ahead of the tokenizer and put back. +struct UnreadChars { + /// The chars, in reverse order (the next to be read last). + chars: Vec, + /// Where the cursor resumes once they've all been read again: where it was after reading + /// ahead, not where it would be after the chars themselves. + resume_at: SourcePosition, +} + +/// Encapsulates the current token parsing state. +#[derive(Clone, Debug)] +struct TokenParseState { + pub start_position: SourcePosition, + pub token_so_far: String, + pub token_is_operator: bool, + pub in_escape: bool, + pub quote_mode: QuoteMode, +} + +impl TokenParseState { + pub fn new(start_position: &SourcePosition) -> Self { + Self { + start_position: start_position.to_owned(), + token_so_far: String::new(), + token_is_operator: false, + in_escape: false, + quote_mode: QuoteMode::None, + } + } + + pub fn pop(&mut self, end_position: &SourcePosition) -> Token { + let end = Arc::new(end_position.to_owned()); + let token_location = SourceSpan { + start: Arc::new(std::mem::take(&mut self.start_position)), + end, + }; + + let token = if std::mem::take(&mut self.token_is_operator) { + Token::Operator(std::mem::take(&mut self.token_so_far), token_location) + } else { + Token::Word(std::mem::take(&mut self.token_so_far), token_location) + }; + + end_position.clone_into(&mut self.start_position); + self.in_escape = false; + self.quote_mode = QuoteMode::None; + + token + } + + pub const fn started_token(&self) -> bool { + !self.token_so_far.is_empty() + } + + /// Returns true if the token so far consists only of blanks. + /// + /// This can only happen when tokenizing with `include_space`, where blanks are accumulated + /// into the token so that the original text of a nested expansion can be reproduced. Such + /// blanks are not a word, so a `#` following them still begins a comment. + pub fn only_blanks_so_far(&self) -> bool { + !self.token_so_far.is_empty() && self.token_so_far.chars().all(is_blank) + } + + pub fn append_char(&mut self, c: char) { + self.token_so_far.push(c); + } + + pub fn append_str(&mut self, s: &str) { + self.token_so_far.push_str(s); + } + + pub const fn unquoted(&self) -> bool { + !self.in_escape && matches!(self.quote_mode, QuoteMode::None) + } + + pub fn current_token(&self) -> &str { + &self.token_so_far + } + + pub fn is_specific_operator(&self, operator: &str) -> bool { + self.token_is_operator && self.current_token() == operator + } + + pub const fn in_operator(&self) -> bool { + self.token_is_operator + } + + fn is_newline(&self) -> bool { + self.token_so_far == "\n" + } + + fn replace_with_here_doc(&mut self, s: String) { + self.token_so_far = s; + } + + #[allow(clippy::too_many_lines)] + pub fn delimit_current_token( + &mut self, + reason: TokenEndReason, + cross_token_state: &mut CrossTokenParseState, + ) -> Result, TokenizerError> { + // If we don't have anything in the token, then don't yield an empty string token + // *unless* it's the body of a here document. + if !self.started_token() && !matches!(reason, TokenEndReason::HereDocumentBodyEnd) { + return Ok(Some(TokenizeResult { + reason, + token: None, + })); + } + + let current_here_state = std::mem::take(&mut cross_token_state.frame.here_state); + match current_here_state { + HereState::NextTokenIsHereTag { remove_tabs } => { + // Don't yield the operator as a token yet. We need to make sure we collect + // up everything we need for all the here-documents with tags on this line. + let operator_token_result = TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }; + + cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag { + remove_tabs, + operator_token_result, + }; + + return Ok(None); + } + HereState::CurrentTokenIsHereTag { + remove_tabs, + operator_token_result, + } => { + // A here tag must be a word; an operator in its place (e.g., a newline, `;` + // or `)`) means it's missing. + if self.token_is_operator { + return Err(TokenizerError::MissingHereTag( + self.current_token().to_owned(), + )); + } + + // Inside a nested expansion, where blanks are kept, blanks between the + // operator and the tag may be delimited as a token of their own (e.g., the + // first of the two blanks in `$(cat << EOF)`). They're not the tag; skip + // them and keep waiting for it. + if self.only_blanks_so_far() { + self.pop(&cross_token_state.cursor); + cross_token_state.frame.here_state = HereState::CurrentTokenIsHereTag { + remove_tabs, + operator_token_result, + }; + return Ok(None); + } + + cross_token_state.frame.here_state = HereState::NextLineIsHereDoc; + + // Include the trailing \n in the here tag so it's easier to check against. Kept + // blanks may lead the token (e.g., `$(cat << EOF)`); strip only those, since + // anything else (even a carriage return) is part of the tag. + let tag = std::format!("{}\n", self.current_token().trim_start_matches(is_blank)); + let tag_was_escaped_or_quoted = tag.contains(is_quoting_char); + + let tag_token_result = TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }; + + cross_token_state.frame.current_here_tags.push(HereTag { + tag, + tag_was_escaped_or_quoted, + remove_tabs, + position: cross_token_state.cursor.clone(), + tokens: vec![operator_token_result, tag_token_result], + pending_tokens_after: vec![], + }); + + return Ok(None); + } + HereState::NextLineIsHereDoc => { + if self.is_newline() { + cross_token_state.frame.here_state = HereState::InHereDocs; + } else { + cross_token_state.frame.here_state = HereState::NextLineIsHereDoc; + } + + if let Some(last_here_tag) = cross_token_state.frame.current_here_tags.last_mut() { + let token = self.pop(&cross_token_state.cursor); + let result = TokenizeResult { + reason, + token: Some(token), + }; + + last_here_tag.pending_tokens_after.push(result); + } else { + return Err(TokenizerError::MissingHereTagForDocumentBody); + } + + return Ok(None); + } + HereState::InHereDocs => { + // We hit the end of the current here-document. + let completed_here_tag = cross_token_state.frame.current_here_tags.remove(0); + + // First queue the redirection operator and (start) here-tag. + cross_token_state + .queued_tokens + .extend(completed_here_tag.tokens); + + // Leave a hint that we are about to start a here-document. + cross_token_state.queued_tokens.push(TokenizeResult { + reason: TokenEndReason::HereDocumentBodyStart, + token: None, + }); + + // Then queue the body document we just finished. + cross_token_state.queued_tokens.push(TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }); + + // Then queue up the (end) here-tag. + let end_tag = if completed_here_tag.tag_was_escaped_or_quoted { + unquote_str(&completed_here_tag.tag) + } else { + completed_here_tag.tag + }; + self.append_str(end_tag.trim_end_matches('\n')); + cross_token_state.queued_tokens.push(TokenizeResult { + reason: TokenEndReason::HereDocumentEndTag, + token: Some(self.pop(&cross_token_state.cursor)), + }); + + // Now we're ready to queue up any tokens that came between the completed + // here tag and the next here tag (or newline after it if it was the last). + cross_token_state + .queued_tokens + .extend(completed_here_tag.pending_tokens_after); + + if cross_token_state.frame.current_here_tags.is_empty() { + cross_token_state.frame.here_state = HereState::None; + } else { + cross_token_state.frame.here_state = HereState::InHereDocs; + } + + return Ok(None); + } + HereState::None => (), + } + + let token = self.pop(&cross_token_state.cursor); + let result = TokenizeResult { + reason, + token: Some(token), + }; + + Ok(Some(result)) + } +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +pub fn tokenize_str(input: &str) -> Result, TokenizerError> { + tokenize_str_with_options(input, &TokenizerOptions::default()) +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +/// * `options` - Options controlling how the tokenizer operates. +pub fn tokenize_str_with_options( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + uncached_tokenize_string(input, options) +} + +#[cached::macros::cached( + name = "TOKENIZE_CACHE", + max_size = 64, + key = "(String, TokenizerOptions)", + convert = r#"{ (input.to_owned(), options.to_owned()) }"# +)] +fn uncached_tokenize_string( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + uncached_tokenize_str(input, options) +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// No caching is performed. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +pub fn uncached_tokenize_str( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, options); + + let mut tokens = vec![]; + loop { + match tokenizer.next_token()? { + TokenizeResult { + token: Some(token), .. + } => tokens.push(token), + TokenizeResult { + reason: TokenEndReason::EndOfInput, + .. + } => break, + _ => (), + } + } + + Ok(tokens) +} + +/// Given the text following the `$(` that opens a command substitution, returns the +/// command's text up to (but not including) the `)` that closes it. The command is +/// tokenized to find that `)`, so quoting, nested expansions, and here-document bodies +/// are skipped over exactly as they are when tokenizing a full script. +/// +/// # Errors +/// +/// Returns an error if the closing `)` can't be found: either the input ends first +/// ([`TokenizerError::UnterminatedExpansion`]), or tokenizing the command fails before +/// reaching it (e.g., an unterminated quote or here-document). This is not a syntax +/// check of the command itself; it's parsed separately, when it is executed. +pub(crate) fn command_substitution_body<'a>( + input: &'a str, + options: &TokenizerOptions, +) -> Result<&'a str, TokenizerError> { + expansion_body(input, options, NestedExpansion::CommandSubstitution) +} + +/// Given the text following `$[`, returns the arithmetic expression before its closing `]`. +/// The tokenizer counts nested brackets without recursing and skips quoted brackets and +/// nested expansions. Arithmetic syntax is checked separately when the expression is evaluated. +pub(crate) fn legacy_arithmetic_expansion_body<'a>( + input: &'a str, + options: &TokenizerOptions, +) -> Result<&'a str, TokenizerError> { + expansion_body(input, options, NestedExpansion::LegacyArithmeticExpansion) +} + +fn expansion_body<'a>( + input: &'a str, + options: &TokenizerOptions, + expansion: NestedExpansion, +) -> Result<&'a str, TokenizerError> { + let mut reader = input.as_bytes(); + let mut tokenizer = Tokenizer::new(&mut reader, options); + tokenizer.read_bodies_left_pending = false; + + // Consume tokens through the expansion's closing character. We don't need the token + // text collected in `state`: it's a normalized rendering, not a slice of `input`. + let mut state = TokenParseState::new(&tokenizer.cross_state.cursor); + tokenizer.consume_nested_expansion(&mut state, expansion)?; + + // The cursor counts characters (not bytes) consumed, the last being the closing character; + // convert the characters before it to a byte length. + let body_len = input + .chars() + .take(tokenizer.cross_state.cursor.index - 1) + .map(char::len_utf8) + .sum(); + Ok(input.split_at(body_len).0) +} + +impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { + pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self { + Tokenizer { + options: options.clone(), + char_reader: CharReader::new(reader.chars()), + unread: None, + read_bodies_left_pending: true, + cross_state: CrossTokenParseState { + cursor: SourcePosition { + index: 0, + line: 1, + column: 1, + }, + queued_tokens: vec![], + frame: ExpansionFrame::default(), + }, + } + } + + #[expect(clippy::unnecessary_wraps)] + pub fn current_location(&self) -> Option { + Some(self.cross_state.cursor.clone()) + } + + fn next_char(&mut self) -> Result, TokenizerError> { + let c = match self.unread.as_mut().and_then(|unread| unread.chars.pop()) { + Some(c) => Some(c), + None => self + .char_reader + .next() + .transpose() + .map_err(TokenizerError::ReadError)?, + }; + + if let Some(ch) = c { + if ch == '\n' { + self.cross_state.cursor.line += 1; + self.cross_state.cursor.column = 1; + } else { + self.cross_state.cursor.column += 1; + } + self.cross_state.cursor.index += 1; + } + + if let Some(unread) = self.unread.take_if(|unread| unread.chars.is_empty()) { + self.cross_state.cursor = unread.resume_at; + } + + Ok(c) + } + + fn consume_char(&mut self) -> Result<(), TokenizerError> { + let _ = self.next_char()?; + Ok(()) + } + + fn peek_char(&mut self) -> Result, TokenizerError> { + if let Some(c) = self.unread.as_ref().and_then(|unread| unread.chars.last()) { + return Ok(Some(*c)); + } + + match self.char_reader.peek() { + Some(result) => match result { + Ok(c) => Ok(Some(*c)), + Err(_) => Err(TokenizerError::FailedDecoding), + }, + None => Ok(None), + } + } + + /// Returns the char after the next one, consuming neither. + fn peek_second_char(&mut self) -> Result, TokenizerError> { + // Any unread chars come first. They're kept in reverse order (the next one last). + let peeked = match self + .unread + .as_ref() + .map_or(&[][..], |unread| unread.chars.as_slice()) + { + [.., second, _] => return Ok(Some(*second)), + [_] => self.char_reader.peek(), + [] => self.char_reader.peek_second(), + }; + + match peeked { + Some(Ok(c)) => Ok(Some(*c)), + Some(Err(_)) => Err(TokenizerError::FailedDecoding), + None => Ok(None), + } + } + + /// Consumes any line continuations (backslash-newline pairs) next in the input. Bash removes + /// them as it reads input, before anything else sees it, except where a backslash is taken + /// literally: right after an escaping backslash, within single quotes (including `$'...'`), + /// in a comment, or in the body of a here-document whose tag is quoted. The tokenizer calls + /// this wherever it reads input in which they're removed. + fn skip_line_continuations(&mut self) -> Result<(), TokenizerError> { + while self.peek_char()? == Some('\\') && self.peek_second_char()? == Some('\n') { + self.consume_char()?; + self.consume_char()?; + } + Ok(()) + } + + /// Returns whether a backslash next in the input would be taken literally, given the state of + /// the token in progress, rather than possibly starting a line continuation; see + /// [`Tokenizer::skip_line_continuations`]. + fn backslash_is_literal(&self, state: &TokenParseState) -> bool { + if state.in_escape || matches!(state.quote_mode, QuoteMode::Single(_) | QuoteMode::AnsiC(_)) + { + return true; + } + + // A here-document body follows the newline ending the line holding its tag. + let frame = &self.cross_state.frame; + let in_here_doc_body = match frame.here_state { + HereState::InHereDocs => true, + HereState::NextLineIsHereDoc => state.is_newline(), + _ => false, + }; + if !in_here_doc_body { + return false; + } + + // The whole body is literal if its tag was quoted. (Otherwise, a backslash escaping + // another is read along with it, so the next one is never escaped.) + frame + .current_here_tags + .first() + .is_some_and(|tag| tag.tag_was_escaped_or_quoted) + } + + /// Consumes the rest of a nested expansion, whose opening (e.g., `$(`) has already been + /// appended to `state`'s token, appending the rest to it as well. + fn consume_nested_expansion( + &mut self, + state: &mut TokenParseState, + expansion: NestedExpansion, + ) -> Result<(), TokenizerError> { + // This function and `next_token` are mutually recursive, as deep as the *input* nests + // expansions; bound it so pathological input can't overflow the stack. + let depth = self.cross_state.frame.depth + 1; + if depth > MAX_EXPANSION_NESTING { + return Err(TokenizerError::ExpansionNestingTooDeep); + } + + // Push a frame for the expansion's contents, where none of the enclosing frame's state + // applies: they're all part of one word, so none of their tokens can be (or end) a + // here tag pending before it, and they can't be in a `((...))` command. Pop it + // (restoring the enclosing frame) once they end. + let frame = ExpansionFrame { + expansion: Some(expansion), + depth, + closers_needed: expansion.closers_after_opening(), + ..ExpansionFrame::default() + }; + + // Frames share the queue of tokens, but none are queued across a push or pop: tokens + // are only read from the input (and so expansions only consumed) once the queue is + // empty, and consuming one leaves it empty. + debug_assert!(self.cross_state.queued_tokens.is_empty()); + let enclosing = std::mem::replace(&mut self.cross_state.frame, frame); + let result = self.consume_expansion_contents(state, expansion); + debug_assert!(result.is_err() || self.cross_state.queued_tokens.is_empty()); + self.cross_state.frame = enclosing; + result + } + + /// Consumes the tokens of a nested expansion's contents, through its closing char, + /// appending their text to `state`'s token. + fn consume_expansion_contents( + &mut self, + state: &mut TokenParseState, + expansion: NestedExpansion, + ) -> Result<(), TokenizerError> { + let mut bodies = String::new(); + + loop { + let result = self.next_token()?; + + match result.reason { + // The bodies set aside go after the rest of the line holding their tags. + TokenEndReason::UnescapedNewLine => state.append_str(&std::mem::take(&mut bodies)), + TokenEndReason::ExpansionClosingChar => { + // The closing char comes next; consume it too. + let closing_char = self + .next_char()? + .ok_or_else(|| expansion.unterminated_error())?; + + let here_state = &self.cross_state.frame.here_state; + if here_state.awaiting_tag() { + // The expansion ended right after a here-document operator, as in + // `$(cat <<)`. + return Err(TokenizerError::MissingHereTag(closing_char.to_string())); + } else if matches!(here_state, HereState::NextLineIsHereDoc) + && self.read_bodies_left_pending + { + self.read_here_doc_bodies_left_pending(state)?; + } + + state.append_char(closing_char); + return Ok(()); + } + TokenEndReason::EndOfInput => return Err(expansion.unterminated_error()), + _ => (), + } + + append_expansion_token(state, &mut bodies, &result); + } + } + + /// Reads the bodies of here-documents still pending when a nested expansion's contents + /// end, as in `$(cat < Result<(), TokenizerError> { + let rest_of_line_start = self.cross_state.cursor.clone(); + let mut rest_of_line = vec![]; + while let Some(c) = self.next_char()? { + rest_of_line.push(c); + if c == '\n' { + break; + } + } + + // As when a line ends, the held tokens come out in order, with the bodies (and end + // tags) interleaved; the bodies go after all of them. + self.cross_state.frame.here_state = HereState::InHereDocs; + let mut bodies = String::new(); + while !matches!(self.cross_state.frame.here_state, HereState::None) + || !self.cross_state.queued_tokens.is_empty() + { + let result = self.next_token()?; + append_expansion_token(state, &mut bodies, &result); + } + state.append_str(&bodies); + state.append_char('\n'); + + if !rest_of_line.is_empty() { + rest_of_line.reverse(); + let resume_at = std::mem::replace(&mut self.cross_state.cursor, rest_of_line_start); + self.unread = Some(UnreadChars { + chars: rest_of_line, + resume_at, + }); + } + + Ok(()) + } + + /// Consumes a `$` or backquote (`c`, the next char) and whatever expansion it begins + /// (e.g., `$(...)`, `${...}`, `` `...` ``), appending all of it to `state`'s token. + /// `in_double_quotes` says whether `c` is inside double quotes, which `state` doesn't + /// always track (e.g., within an extglob pattern). + #[allow(clippy::unwrap_in_result)] + fn consume_dollar_or_backquote( + &mut self, + state: &mut TokenParseState, + c: char, + in_double_quotes: bool, + ) -> Result<(), TokenizerError> { + if c == '$' { + // Consume the '$' so we can peek beyond. + self.consume_char()?; + self.skip_line_continuations()?; + + // Now peek beyond to see what we have. + let char_after_dollar_sign = self.peek_char()?; + match char_after_dollar_sign { + Some('(') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '(' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + // Check to see if this is possibly an arithmetic expression + // (i.e., one that starts with `$((`). + self.skip_line_continuations()?; + let expansion = if matches!(self.peek_char()?, Some('(')) { + // Consume the second '(' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + NestedExpansion::ArithmeticExpansion + } else { + NestedExpansion::CommandSubstitution + }; + + self.consume_nested_expansion(state, expansion)?; + } + + Some('[') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '[' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + self.consume_nested_expansion( + state, + NestedExpansion::LegacyArithmeticExpansion, + )?; + } + + Some('{') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '{' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + self.consume_nested_expansion(state, NestedExpansion::ParameterExpansion)?; + } + // `$$` is a parameter of its own; whatever follows it starts nothing. + Some('$') => { + self.consume_char()?; + state.append_str("$$"); + } + + // `$'...'` is ANSI-C quoting, but only outside double quotes. + Some('\'') if !in_double_quotes => { + state.append_char('$'); + state.quote_mode = QuoteMode::AnsiC(self.cross_state.cursor.clone()); + state.append_char(self.next_char()?.unwrap()); + } + + _ => { + // This is either a different character, or else the end of the string. + // Either way, add the '$' we already consumed to the token. + state.append_char('$'); + } + } + } else { + // We look for the terminating backquote. First disable normal consumption and + // consume the starting backquote. + let backquote_pos = self.cross_state.cursor.clone(); + self.consume_char()?; + + // Add the opening backquote to the token. + state.append_char(c); + + // Now continue until we see an unescaped backquote. + let mut escaping_enabled = false; + let mut done = false; + while !done { + // As in bash, line continuations are removed throughout (even within quotes in + // the command), except right after an escaping backslash. + if !escaping_enabled { + self.skip_line_continuations()?; + } + + // Read (and consume) the next char. + let next_char_in_backquote = self.next_char()?; + if let Some(cib) = next_char_in_backquote { + // Include it in the token no matter what. + state.append_char(cib); + + // Watch out for escaping. + if !escaping_enabled && cib == '\\' { + escaping_enabled = true; + } else { + // Look for an unescaped backquote to terminate. + if !escaping_enabled && cib == '`' { + done = true; + } + escaping_enabled = false; + } + } else { + return Err(TokenizerError::UnterminatedBackquote(backquote_pos)); + } + } + } + + Ok(()) + } + + /// Returns the next token from the input stream. + /// + /// Within a nested expansion's contents, the closing char that ends the expansion ends the + /// token in progress and is then reported on its own (see + /// [`TokenEndReason::ExpansionClosingChar`]), and blanks are kept in tokens so that the + /// expansion's original text can be reproduced. + #[expect(clippy::cognitive_complexity)] + #[expect(clippy::if_same_then_else)] + #[expect(clippy::panic_in_result_fn)] + #[expect(clippy::too_many_lines)] + #[allow(clippy::unwrap_in_result)] + pub fn next_token(&mut self) -> Result { + let mut state = TokenParseState::new(&self.cross_state.cursor); + let mut result: Option = None; + + let expansion = self.cross_state.frame.expansion; + let closing_char = expansion.map(NestedExpansion::closing_char); + let include_space = expansion.is_some(); + // Directly inside `${...}`, text is a word, not a command: bash recognizes no + // here-documents, comments or extglobs there. + let in_parameter_expansion = expansion == Some(NestedExpansion::ParameterExpansion); + let in_arithmetic_expansion = expansion.is_some_and(NestedExpansion::is_arithmetic); + + while result.is_none() { + // First satisfy token results from our queue. Once we exhaust the queue then + // we'll look at the input stream. + if !self.cross_state.queued_tokens.is_empty() { + return Ok(self.cross_state.queued_tokens.remove(0)); + } + + // As bash does as it reads input, drop any line continuations before looking at the + // next char, unless the backslash would be taken literally. A token not yet started + // starts after them. + let mut next = self.peek_char()?; + if next == Some('\\') + && self.peek_second_char()? == Some('\n') + && !self.backslash_is_literal(&state) + { + self.skip_line_continuations()?; + if !state.started_token() { + state.start_position = self.cross_state.cursor.clone(); + } + next = self.peek_char()?; + } + let c = next.unwrap_or('\0'); + + // When we hit the end of the input, then we're done with the current token (if there is + // one). + if next.is_none() { + // TODO(tokenizer): Verify we're not waiting on some terminating character? + // Verify we're out of all quotes. + if state.in_escape { + return Err(TokenizerError::UnterminatedEscapeSequence); + } + match state.quote_mode { + QuoteMode::None => (), + QuoteMode::AnsiC(pos) => { + return Err(TokenizerError::UnterminatedAnsiCQuote(pos)); + } + QuoteMode::Single(pos) => { + return Err(TokenizerError::UnterminatedSingleQuote(pos)); + } + QuoteMode::Double(pos) => { + return Err(TokenizerError::UnterminatedDoubleQuote(pos)); + } + } + + // Verify we're not in a here document. + if !matches!(self.cross_state.frame.here_state, HereState::None) { + // If the input ends while a here tag is expected, finish the tag in + // progress so it's recorded (and reported below); without one, the tag + // is missing. + if self.cross_state.frame.here_state.awaiting_tag() && state.started_token() { + state.delimit_current_token( + TokenEndReason::EndOfInput, + &mut self.cross_state, + )?; + } + if self.cross_state.frame.here_state.awaiting_tag() { + return Err(TokenizerError::MissingHereTagAtEndOfInput); + } + + // Only a body can end at an end tag. Outside one, an empty tag would + // "match" on every pass without making progress, looping forever. + if matches!(self.cross_state.frame.here_state, HereState::InHereDocs) + && self.remove_here_end_tag(&mut state, &mut result, false)? + { + // If we hit end tag without a trailing newline, try to get next token. + continue; + } + + let tag_names = self + .cross_state + .frame + .current_here_tags + .iter() + .map(|tag| std::format!("`{}`", tag.tag.trim_end_matches('\n'))) + .collect::>() + .join(", "); + let tag_positions = self + .cross_state + .frame + .current_here_tags + .iter() + .map(|tag| std::format!("{}", tag.position)) + .collect::>() + .join(", "); + return Err(TokenizerError::UnterminatedHereDocuments( + tag_names, + tag_positions, + )); + } + + result = state + .delimit_current_token(TokenEndReason::EndOfInput, &mut self.cross_state)?; + // + // Handle being in a here document. + // + } else if matches!(self.cross_state.frame.here_state, HereState::InHereDocs) { + // + // For now, just include the character in the current token. We also check + // if there are leading tabs to be removed. + // + if !self.cross_state.frame.current_here_tags.is_empty() + && self.cross_state.frame.current_here_tags[0].remove_tabs + && (!state.started_token() || state.current_token().ends_with('\n')) + && c == '\t' + { + // Consume it but don't include it. + self.consume_char()?; + } else { + self.consume_char()?; + state.append_char(c); + + // See if this was a newline character following the terminating here tag. + if c == '\n' { + self.remove_here_end_tag(&mut state, &mut result, true)?; + } else if c == '\\' + && self + .cross_state + .frame + .current_here_tags + .first() + .is_some_and(|tag| !tag.tag_was_escaped_or_quoted) + { + // In the body of a here-document whose tag isn't quoted, a backslash + // escapes the next char; take that as-is too. (It isn't a newline: that + // would have been a line continuation, already removed.) + if let Some(escaped) = self.next_char()? { + state.append_char(escaped); + } + } + } + // + // Look for the closing char that ends the nested expansion being consumed (one that + // closes an opener in its contents is read like any other char, below). An operator + // in progress is delimited first, below, so that it takes effect: a newline may start + // a here-doc body, and a `<<` expects its tag (and reports it missing). Any other + // token in progress is delimited here, leaving the closing char to be reported on + // its own next time around. + // + } else if state.unquoted() + && !state.in_operator() + && closing_char == Some(c) + && self.cross_state.frame.closers_needed == 1 + { + let reason = if state.started_token() { + TokenEndReason::Other + } else { + TokenEndReason::ExpansionClosingChar + }; + result = state.delimit_current_token(reason, &mut self.cross_state)?; + } else if state.in_operator() { + // + // We're in an operator. See if this character continues an operator, or if it + // must be a separate token (because it wouldn't make a prefix of an operator). + // + + let mut hypothetical_token = state.current_token().to_owned(); + hypothetical_token.push(c); + + if state.unquoted() && self.is_operator(hypothetical_token.as_ref()) { + self.consume_char()?; + state.append_char(c); + } else { + assert!(state.started_token()); + + // + // N.B. If the completed operator indicates a here-document, then keep + // track that the *next* token should be the here-tag. + // + // In an arithmetic context, don't consider << and <<- special. They're + // not here-docs, they're either a left-shift operator or a left-shift + // operator followed by a unary minus operator. + // + if in_arithmetic_expansion || in_parameter_expansion { + // Operators have no effect here: arithmetic contents are arithmetic + // throughout, and `${...}` contents are a word, not a command. + } else if self.cross_state.frame.arithmetic_command_parens > 0 { + // A `((...))` command ends at the `)` that balances its first `(`. + let parens = &mut self.cross_state.frame.arithmetic_command_parens; + if state.is_specific_operator("(") { + *parens += 1; + } else if state.is_specific_operator(")") { + *parens -= 1; + } + } else if state.is_specific_operator("<<") { + self.cross_state.frame.here_state = + HereState::NextTokenIsHereTag { remove_tabs: false }; + } else if state.is_specific_operator("<<-") { + self.cross_state.frame.here_state = + HereState::NextTokenIsHereTag { remove_tabs: true }; + } else if state.is_specific_operator("(") && c == '(' { + // A `((...))` command starts with this `(`. + self.cross_state.frame.arithmetic_command_parens = 1; + } + + let reason = if state.current_token() == "\n" { + TokenEndReason::UnescapedNewLine + } else { + TokenEndReason::OperatorEnd + }; + + result = state.delimit_current_token(reason, &mut self.cross_state)?; + } + // + // See if this is a character that changes the current escaping/quoting state. + // + } else if does_char_newly_affect_quoting(&state, c) { + if c == '\\' { + self.consume_char()?; + state.in_escape = true; + state.append_char(c); + } else if c == '\'' { + // (A `$'` was already taken as ANSI-C quoting where its `$` was read.) + state.quote_mode = QuoteMode::Single(self.cross_state.cursor.clone()); + self.consume_char()?; + state.append_char(c); + } else if c == '\"' { + state.quote_mode = QuoteMode::Double(self.cross_state.cursor.clone()); + self.consume_char()?; + state.append_char(c); + } + } + // + // Handle end of single-quote, double-quote, or ANSI-C quote. + else if !state.in_escape + && matches!( + state.quote_mode, + QuoteMode::Single(..) | QuoteMode::AnsiC(..) + ) + && c == '\'' + { + state.quote_mode = QuoteMode::None; + self.consume_char()?; + state.append_char(c); + } else if !state.in_escape + && matches!(state.quote_mode, QuoteMode::Double(..)) + && c == '\"' + { + state.quote_mode = QuoteMode::None; + self.consume_char()?; + state.append_char(c); + } + // + // Handle end of escape sequence. + // TODO(tokenizer): Handle double-quote specific escape sequences. + else if state.in_escape { + state.in_escape = false; + self.consume_char()?; + state.append_char(c); + } else if (state.unquoted() + || (matches!(state.quote_mode, QuoteMode::Double(_)) && !state.in_escape)) + && (c == '$' || c == '`') + { + // TODO(tokenizer): handle quoted $ or ` in a double quote + let in_double_quotes = matches!(state.quote_mode, QuoteMode::Double(_)); + self.consume_dollar_or_backquote(&mut state, c, in_double_quotes)?; + } + // + // [Extension] + // If extended globbing is enabled, the last consumed character is an + // unquoted start of an extglob pattern, *and* if the current character + // is an open parenthesis, then this begins an extglob pattern. + else if c == '(' + && self.options.enable_extended_globbing + && !in_parameter_expansion + && state.unquoted() + && !state.in_operator() + && state + .current_token() + .ends_with(|x| Self::can_start_extglob(x)) + { + // Consume the '(' and append it. + self.consume_char()?; + state.append_char(c); + + let mut paren_depth = 1; + // The char that closes the current quote, and whether backslash + // escapes within it (it doesn't in '...', but does in "..." and $'...'). + let mut quote: Option<(char, bool)> = None; + let mut after_dollar = false; + + // Keep consuming until we see the matching end ')'. Parens inside + // quotes are literal pattern characters, not delimiters. As in bash, + // `$(...)` and `${...}` are only nested constructs inside double + // quotes; elsewhere their parens simply count toward the nesting. + while paren_depth > 0 { + // Only single quotes (including `$'...'`) keep line continuations; double + // quotes don't. + if !matches!(quote, Some(('\'', _))) { + self.skip_line_continuations()?; + } + let Some(extglob_char) = self.peek_char()? else { + return Err(TokenizerError::UnterminatedExtendedGlob( + self.cross_state.cursor.clone(), + )); + }; + let was_after_dollar = std::mem::take(&mut after_dollar); + + let starts_nested_construct = match quote { + None => extglob_char == '`', + Some(('"', _)) => matches!(extglob_char, '`' | '$'), + Some(_) => false, + }; + if starts_nested_construct { + // A `$` only gets here inside double quotes, so it's never taken as + // starting `$'...'`, which would set `state`'s quote mode rather than + // this loop's own `quote`. + let in_double_quotes = quote.is_some(); + self.consume_dollar_or_backquote( + &mut state, + extglob_char, + in_double_quotes, + )?; + continue; + } + + // Include it in the token. + self.consume_char()?; + state.append_char(extglob_char); + + match extglob_char { + // Take the escaped char as-is. If the input ends instead, the next + // iteration reports the unterminated extglob. + '\\' if quote.is_none_or(|(_, escapes)| escapes) => { + if let Some(escaped_char) = self.next_char()? { + state.append_char(escaped_char); + } + } + c if quote.is_some_and(|(close, _)| close == c) => quote = None, + _ if quote.is_some() => (), + '\'' => quote = Some(('\'', was_after_dollar)), + '"' => quote = Some(('"', true)), + // `$$` is a parameter, not a `$` that could start `$'`. + '$' => after_dollar = !was_after_dollar, + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + _ => (), + } + } + // + // If the character *can* start an operator, then it will. + // + } else if state.unquoted() && Self::can_start_operator(c) { + if state.started_token() { + result = state.delimit_current_token( + TokenEndReason::OperatorStart, + &mut self.cross_state, + )?; + } else { + state.token_is_operator = true; + self.consume_char()?; + state.append_char(c); + self.cross_state.frame.note_unquoted_char(c); + } + // + // Whitespace gets discarded (and delimits tokens). + // + } else if state.unquoted() && is_blank(c) { + if state.started_token() { + result = state.delimit_current_token( + TokenEndReason::NonNewLineBlank, + &mut self.cross_state, + )?; + + // Where blanks are kept, this one begins the next token, as written. + if include_space { + continue; + } + } else if include_space { + state.append_char(c); + } else { + // Make sure we don't include this char in the token range. + state.start_position.column += 1; + state.start_position.index += 1; + } + + self.consume_char()?; + } + // + // N.B. Inside a parameter expansion, everything left belongs to the word, even + // where no token was started: a `#` there never starts a comment (e.g., `${#x}`). + // + // Elsewhere, the `!only_blanks_so_far` clause keeps a comment recognizable inside a + // nested expansion. Blanks are kept in tokens there, so a `#` after them follows a + // token in progress; being only blanks, that token isn't a word, and the `#` still + // starts a comment (e.g., in `$( #')`). + // + else if !state.token_is_operator + && (in_parameter_expansion + || (state.started_token() && !(c == '#' && state.only_blanks_so_far()))) + { + self.consume_char()?; + state.append_char(c); + if state.unquoted() { + self.cross_state.frame.note_unquoted_char(c); + } + } else if c == '#' { + // Consume the '#'. + self.consume_char()?; + + let mut done = false; + while !done { + done = match self.peek_char()? { + Some('\n') => true, + None => true, + _ => { + // Consume the peeked char; it's part of the comment. + self.consume_char()?; + false + } + }; + } + // Re-start loop as if the comment never happened. + } else if state.started_token() { + // In all other cases where we have an in-progress token, we delimit here. + result = + state.delimit_current_token(TokenEndReason::Other, &mut self.cross_state)?; + } else { + // If we got here, then we don't have a token in progress and we're not starting an + // operator. Add the character to a new token. + self.consume_char()?; + state.append_char(c); + self.cross_state.frame.note_unquoted_char(c); + } + } + + let result = result.unwrap(); + + Ok(result) + } + + fn remove_here_end_tag( + &mut self, + state: &mut TokenParseState, + result: &mut Option, + ends_with_newline: bool, + ) -> Result { + // Bail immediately if we don't even have a *starting* here tag. + if self.cross_state.frame.current_here_tags.is_empty() { + return Ok(false); + } + + let next_here_tag = &self.cross_state.frame.current_here_tags[0]; + + let tag_str: Cow<'_, str> = if next_here_tag.tag_was_escaped_or_quoted { + unquote_str(next_here_tag.tag.as_str()).into() + } else { + next_here_tag.tag.as_str().into() + }; + + let tag_str = if !ends_with_newline { + tag_str + .strip_suffix('\n') + .unwrap_or_else(|| tag_str.as_ref()) + } else { + tag_str.as_ref() + }; + + // At the end of input, the tag's line may lack its newline (`cat < bool { + matches!(c, '@' | '!' | '?' | '+' | '*') + } + + const fn can_start_operator(c: char) -> bool { + matches!(c, '&' | '(' | ')' | ';' | '\n' | '|' | '<' | '>') + } + + fn is_operator(&self, s: &str) -> bool { + // Handle non-POSIX operators. + if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") { + return true; + } + + matches!( + s, + "&" | "&&" + | "(" + | ")" + | ";" + | ";;" + | "\n" + | "|" + | "||" + | "<" + | ">" + | ">|" + | "<<" + | ">>" + | "<&" + | ">&" + | "<<-" + | "<>" + ) + } +} + +impl Iterator for Tokenizer<'_, R> { + type Item = Result; + + fn next(&mut self) -> Option { + match self.next_token() { + #[expect(clippy::manual_map)] + Ok(result) => match result.token { + Some(_) => Some(Ok(result)), + None => None, + }, + Err(e) => Some(Err(e)), + } + } +} + +/// Appends the text of a token from a nested expansion's contents to the expansion's text in +/// `state`'s token, except for here-document bodies (and end tags): they come out ahead of the +/// rest of the line holding their tags, so they're set aside in `bodies`, to follow it. +fn append_expansion_token( + state: &mut TokenParseState, + bodies: &mut String, + result: &TokenizeResult, +) { + let text = result.token.as_ref().map_or("", Token::to_str); + match result.reason { + TokenEndReason::HereDocumentBodyStart => bodies.push('\n'), + TokenEndReason::HereDocumentBodyEnd | TokenEndReason::HereDocumentEndTag => { + bodies.push_str(text); + } + _ => state.append_str(text), + } +} + +const fn is_blank(c: char) -> bool { + c == ' ' || c == '\t' +} + +const fn does_char_newly_affect_quoting(state: &TokenParseState, c: char) -> bool { + // If we're currently escaped, then nothing affects quoting. + if state.in_escape { + return false; + } + + match state.quote_mode { + // When we're in a double quote or ANSI-C quote, only a subset of escape + // sequences are recognized. + QuoteMode::Double(_) | QuoteMode::AnsiC(_) => { + if c == '\\' { + // TODO(tokenizer): handle backslash in double quote + true + } else { + false + } + } + // When we're in a single quote, nothing affects quoting. + QuoteMode::Single(_) => false, + // When we're not already in a quote, then we can straightforwardly look for a + // quote mark or backslash. + QuoteMode::None => is_quoting_char(c), + } +} + +const fn is_quoting_char(c: char) -> bool { + matches!(c, '\\' | '\'' | '\"') +} + +/// Returns `s` with its quoting removed, as the shell's quote removal does. +/// +/// Removes quote chars that open or close a quote, and backslashes that escape the char +/// after them. As in the shell, a backslash is literal in single quotes, and in double +/// quotes it escapes only `$`, `` ` ``, `"`, `\`, and newline. Unlike the shell's line +/// continuation, an escaped newline is kept. Nothing is expanded, and `$'...'` and +/// `$"..."` aren't treated specially. +/// +/// # Arguments +/// +/// * `s` - The string to unquote. +pub fn unquote_str(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + // The quote (`'` or `"`) the next char is in, if any. + let mut quote = None; + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + match (c, quote) { + ('\\', None) => result.extend(chars.next()), + ('\\', Some('"')) => { + if !matches!(chars.peek(), Some('$' | '`' | '"' | '\\' | '\n')) { + result.push(c); + } + result.extend(chars.next()); + } + (_, Some(q)) if c == q => quote = None, + ('\'' | '"', None) => quote = Some(c), + _ => result.push(c), + } + } + + result +} + +#[cfg(test)] +mod tests { + + use super::*; + use anyhow::Result; + use insta::assert_ron_snapshot; + use pretty_assertions::{assert_eq, assert_matches}; + + #[derive(serde::Serialize, serde::Deserialize)] + struct TokenizerResult<'a> { + input: &'a str, + result: Vec, + } + + fn test_tokenizer(input: &str) -> Result> { + Ok(TokenizerResult { + input, + result: tokenize_str(input)?, + }) + } + + #[test] + fn tokenize_empty() -> Result<()> { + let tokens = tokenize_str("")?; + assert_eq!(tokens.len(), 0); + Ok(()) + } + + #[test] + fn tokenize_line_continuation() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"a\ +bc" + )?); + Ok(()) + } + + #[test] + fn tokenize_operators() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("a>>b")?); + Ok(()) + } + + #[test] + fn tokenize_line_continuations() -> Result<()> { + // As in bash, a line continuation is removed wherever the backslash isn't quoted, even + // splitting an operator or following a `$`. + for (input, expected) in [ + ("a&\\\n&b", &["a", "&&", "b"][..]), + ("a|\\\n&b", &["a", "|&", "b"]), + ("a&&\\\n", &["a", "&&"]), + // At the end of input, it's as though the input ended right at the operator. + ("a <<\\\n", &["a", "<<"]), + ("a&&\\b", &["a", "&&", "\\b"]), + ("a<\\\n = tokens.iter().map(Token::to_str).collect(); + assert_eq!(token_strs, expected, "tokenizing {input:?}"); + } + Ok(()) + } + + #[test] + fn tokenize_token_after_blanks_and_line_continuation() -> Result<()> { + // The blanks before the line continuation don't count toward where `c` starts. + assert_matches!( + &tokenize_str("a; \\\n c")?[..], + [_, _, Token::Word(word, span)] + if word == "c" && (span.start.line, span.start.column, span.start.index) == (2, 3, 7) + ); + Ok(()) + } + + #[test] + fn tokenize_operator_followed_by_backslash() -> Result<()> { + // The `;` ends before the escaped `b`. The `|` ends after the line continuation following + // it (as a word would), and the `c` starts on the next line. + assert_ron_snapshot!(test_tokenizer("a;\\b|\\\n c")?); + Ok(()) + } + + #[test] + fn tokenize_comment() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"a #comment +" + )?); + Ok(()) + } + + #[test] + fn tokenize_comment_in_command_substitution() { + // A comment inside `$( )` is a comment however many blanks precede its `#`, even though + // blanks are kept in tokens there, so the apostrophe in its text doesn't open a quote. + // The comment is dropped from the text reconstructed for the substitution, leaving just + // the blanks that preceded it, as written. + for prefix in ["", " ", " ", " ", "\t", "\t\t", " \t", "\t "] { + let input = format!("$({prefix}# it's a comment\n)\n"); + let tokens = tokenize_str(input.as_str()).unwrap(); + let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect(); + assert_eq!( + token_strs, + [format!("$({prefix}\n)").as_str(), "\n"], + "tokenizing {input:?}" + ); + } + } + + #[test] + fn tokenize_comment_at_eof() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"a #comment")?); + Ok(()) + } + + #[test] + fn tokenize_empty_here_doc() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat <<-HERE + SOMETHING + HERE +" + )?); + Ok(()) + } + + #[test] + fn tokenize_here_doc_with_other_tokens() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + // The body is read from the lines after the substitution's, ahead of any other + // here-documents pending by then, and becomes part of the substitution's text. + for (input, expected) in [ + ( + "x=\"$(cat < = tokens.iter().map(Token::to_str).collect(); + assert_eq!(token_strs, expected, "for {input:?}"); + } + + // The rest of the line keeps its place in the input, and the next line follows the body. + let tokens = tokenize_str("echo \"$(cat < = tokens + .iter() + .map(|t| { + ( + t.to_str(), + t.location().start.line, + t.location().start.column, + ) + }) + .collect(); + assert_eq!( + positions, + [ + ("echo", 1, 1), + ("\"$(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"echo $(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r#"echo "$(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r#"echo "$(cat << HERE +TEXT +HERE +)""# + )?); + Ok(()) + } + + #[test] + fn tokenize_complex_here_docs_in_command_substitution() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"echo $(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer(r"echo `echo hi`")?); + Ok(()) + } + + #[test] + fn tokenize_backquote_with_escape() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"echo `echo\`hi`")?); + Ok(()) + } + + #[test] + fn tokenize_unterminated_backquote() { + assert_matches!( + tokenize_str("`"), + Err(TokenizerError::UnterminatedBackquote(_)) + ); + } + + #[test] + fn tokenize_unterminated_command_substitution() { + // $( is consumed before the tokenizer knows whether it's $( or $((, + // so it goes through consume_nested_expansion and yields UnterminatedExpansion. + assert_matches!( + tokenize_str("$("), + Err(TokenizerError::UnterminatedExpansion) + ); + } + + #[test] + fn command_substitution_body_stops_at_closing_paren() -> Result<()> { + let options = TokenizerOptions::default(); + assert_eq!( + command_substitution_body("echo hi) rest", &options)?, + "echo hi" + ); + assert_eq!( + command_substitution_body(r#"echo ")" (a)) rest"#, &options)?, + r#"echo ")" (a)"# + ); + assert_eq!( + command_substitution_body("cat <<'EOF'\n\"it's ) `\nEOF\n) rest", &options)?, + "cat <<'EOF'\n\"it's ) `\nEOF\n" + ); + // A `)` right after the newline that starts a here-doc body belongs to the body. + assert_eq!( + command_substitution_body("cat < Result<()> { + assert_ron_snapshot!(test_tokenizer("a$(echo hi)b c")?); + Ok(()) + } + + #[test] + fn tokenize_command_substitution_with_subshell() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$( (:) )")?); + Ok(()) + } + + #[test] + fn tokenize_command_substitution_containing_extglob() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("echo $(echo !(x))")?); + Ok(()) + } + + #[test] + fn tokenize_extglob_with_quotes_and_escapes() -> Result<()> { + for (input, expected) in [ + // Quoted parens don't close the pattern. + (r#"@(")") y"#, [r#"@(")")"#, "y"]), + (r"@(a|')'|b)x y", [r"@(a|')'|b)x", "y"]), + (r#"@("(") y"#, [r#"@("(")"#, "y"]), + // Escaped quote inside double quotes doesn't end the quoting. + (r#"@("\")") y"#, [r#"@("\")")"#, "y"]), + // Backslash is literal inside single quotes. + (r"@('\')x y", [r"@('\')x", "y"]), + // Escaped quote outside quotes doesn't start quoting. + (r#"@(\") y"#, [r#"@(\")"#, "y"]), + (r"@(\)) y", [r"@(\))", "y"]), + // Backslash escapes a quote inside ANSI-C quotes, but not inside `\$'...'`. + (r"@($'\'')x y", [r"@($'\'')x", "y"]), + (r"@($'a\')'|b) y", [r"@($'a\')'|b)", "y"]), + (r"@(\$'a\')x y", [r"@(\$'a\')x", "y"]), + // `$$` is a parameter, so a quote after it is ANSI-C only if a third `$` follows. + (r"@($$'a\')x y", [r"@($$'a\')x", "y"]), + (r"@($$$'\'')x y", [r"@($$$'\'')x", "y"]), + // Backquotes are a region of their own: quotes inside them don't nest. + (r"@(}`'`)x y", [r"@(}`'`)x", "y"]), + (r#"@(`echo \\"`)x y"#, [r#"@(`echo \\"`)x"#, "y"]), + (r"@(`$'\'`)x y", [r"@(`$'\'`)x", "y"]), + (r#"@(`echo ")"`)x y"#, [r#"@(`echo ")"`)x"#, "y"]), + // Inside double quotes, `$(...)` and backquotes are nested constructs. + (r#"@("$(echo ")")")x y"#, [r#"@("$(echo ")")")x"#, "y"]), + (r#"@("`echo "\)"`")x y"#, [r#"@("`echo "\)"`")x"#, "y"]), + (r#"@("${u:-"}"}")x y"#, [r#"@("${u:-"}"}")x"#, "y"]), + (r#"@($")")x y"#, [r#"@($")")x"#, "y"]), + // Nesting still counts unquoted parens; each quote kind hides the other. + (r#"+(a|@(b|")")|'"') y"#, [r#"+(a|@(b|")")|'"')"#, "y"]), + ] { + let tokens = tokenize_str(input)?; + let token_strs: Vec<_> = tokens.iter().map(Token::to_str).collect(); + assert_eq!(token_strs, expected, "input: {input}"); + } + Ok(()) + } + + #[test] + fn tokenize_unterminated_construct_in_extglob() { + // Each of these leaves a quote, backquote, or `$(` inside the extglob open. + for input in [r#"@("$(\$"\))x"#, r#"@($"$(\)")x"#, r"@(`)x", r#"@("`)")x"#] { + assert!(tokenize_str(input).is_err(), "input: {input}"); + } + } + + #[test] + fn tokenize_unterminated_extglob() { + for input in [r"@(a", r#"@(")""#, r"@(')'", r"@(\)"] { + assert_matches!( + tokenize_str(input), + Err(TokenizerError::UnterminatedExtendedGlob(_)), + "input: {input}" + ); + } + } + + #[test] + fn tokenize_arithmetic_expression() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("a$((1+2))b c")?); + Ok(()) + } + + #[test] + fn tokenize_arithmetic_expression_with_space() -> Result<()> { + // N.B. The spacing comes out a bit odd, but it gets processed okay + // by later stages. + assert_ron_snapshot!(test_tokenizer("$(( 1 ))")?); + Ok(()) + } + #[test] + fn tokenize_arithmetic_expression_with_parens() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$(( (0) ))")?); + Ok(()) + } + + #[test] + fn tokenize_special_parameters() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$$")?); + assert_ron_snapshot!(test_tokenizer("$@")?); + assert_ron_snapshot!(test_tokenizer("$!")?); + assert_ron_snapshot!(test_tokenizer("$?")?); + assert_ron_snapshot!(test_tokenizer("$*")?); + Ok(()) + } + + #[test] + fn tokenize_unbraced_parameter_expansion() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$x")?); + assert_ron_snapshot!(test_tokenizer("a$x")?); + Ok(()) + } + + #[test] + fn tokenize_unterminated_parameter_expansion() { + assert_matches!( + tokenize_str("${x"), + Err(TokenizerError::UnterminatedVariable) + ); + } + + #[test] + fn tokenize_braced_parameter_expansion() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("${x}")?); + assert_ron_snapshot!(test_tokenizer("a${x}b")?); + Ok(()) + } + + #[test] + fn tokenize_braced_parameter_expansion_with_escaping() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"a${x\}}b")?); + Ok(()) + } + + #[test] + fn tokenize_whitespace() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("1 2 3")?); + Ok(()) + } + + #[test] + fn tokenize_escaped_whitespace() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"1\ 2 3")?); + Ok(()) + } + + #[test] + fn tokenize_single_quote() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"x'a b'y")?); + Ok(()) + } + + #[test] + fn tokenize_double_quote() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"a b"y"#)?); + Ok(()) + } + + #[test] + fn tokenize_double_quoted_command_substitution() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"$(echo hi)"y"#)?); + Ok(()) + } + + #[test] + fn tokenize_double_quoted_arithmetic_expression() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"$((1+2))"y"#)?); + Ok(()) + } + + #[test] + fn test_quote_removal() { + assert_eq!(unquote_str(r#""hello""#), "hello"); + assert_eq!(unquote_str(r"'hello'"), "hello"); + assert_eq!(unquote_str(r#""hel\"lo""#), r#"hel"lo"#); + assert_eq!(unquote_str(r"hel\'lo"), r"hel'lo"); + assert_eq!(unquote_str(r"a\ b\\c"), r"a b\c"); + + // Quotes open and close anywhere, and each kind is literal in the other. + assert_eq!(unquote_str(r#"a'b c'd"e f"g"#), "ab cde fg"); + assert_eq!(unquote_str(r#"'a"b'"c'd""#), r#"a"bc'd"#); + + // A backslash is literal in single quotes, so it can't escape the closing quote. + assert_eq!(unquote_str(r"'a\b'"), r"a\b"); + assert_eq!(unquote_str(r"'a\'b"), r"a\b"); + + // In double quotes, a backslash escapes only chars that are special there. + assert_eq!(unquote_str(r#""a\$b\`c\"d\\e""#), r#"a$b`c"d\e"#); + assert_eq!(unquote_str(r#""a\b\'c""#), r"a\b\'c"); + assert_eq!(unquote_str("\"a\\\nb\""), "a\nb"); + assert_eq!(unquote_str("a\\\nb"), "a\nb"); + + // A trailing backslash is removed, unless it's literal (in quotes). + assert_eq!(unquote_str(r"a\"), "a"); + assert_eq!(unquote_str(r#""a\"#), r"a\"); + assert_eq!(unquote_str(r"'a\"), r"a\"); + + // An unclosed quote is removed; nothing is expanded. + assert_eq!(unquote_str(r#""a b"#), "a b"); + assert_eq!(unquote_str(r#"$HOME'~'"$x""#), "$HOME~$x"); + assert_eq!(unquote_str(""), ""); + } +} diff --git a/userland/brush/source/brush-parser/src/tokenizer/char_reader.rs b/userland/brush/source/brush-parser/src/tokenizer/char_reader.rs new file mode 100644 index 0000000..8ea744a --- /dev/null +++ b/userland/brush/source/brush-parser/src/tokenizer/char_reader.rs @@ -0,0 +1,60 @@ +//! Reading the tokenizer's input with two chars of lookahead. + +/// An iterator (of the chars of input) that can peek two items ahead: one more than +/// [`std::iter::Peekable`], to see whether a backslash starts a line continuation. +pub(super) struct CharReader { + iter: std::iter::Peekable, + /// The next item, if taken from `iter` to peek past it (only ever briefly). + taken: Option, +} + +impl CharReader { + pub(super) fn new(iter: I) -> Self { + Self { + iter: iter.peekable(), + taken: None, + } + } + + pub(super) fn peek(&mut self) -> Option<&I::Item> { + match &self.taken { + Some(item) => Some(item), + None => self.iter.peek(), + } + } + + /// Returns the item after the next one, consuming neither. + pub(super) fn peek_second(&mut self) -> Option<&I::Item> { + if self.taken.is_none() { + self.taken = Some(self.iter.next()?); + } + self.iter.peek() + } +} + +impl Iterator for CharReader { + type Item = I::Item; + + fn next(&mut self) -> Option { + self.taken.take().or_else(|| self.iter.next()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peeks_up_to_two_ahead() { + let mut reader = CharReader::new("ab".chars()); + assert_eq!(reader.peek_second(), Some(&'b')); + assert_eq!(reader.peek(), Some(&'a')); + assert_eq!(reader.next(), Some('a')); + assert_eq!(reader.peek_second(), None); + assert_eq!(reader.peek(), Some(&'b')); + assert_eq!(reader.next(), Some('b')); + assert_eq!(reader.peek(), None); + assert_eq!(reader.peek_second(), None); + assert_eq!(reader.next(), None); + } +} diff --git a/userland/brush/source/brush-parser/src/word.rs b/userland/brush/source/brush-parser/src/word.rs new file mode 100644 index 0000000..81f1e40 --- /dev/null +++ b/userland/brush/source/brush-parser/src/word.rs @@ -0,0 +1,1793 @@ +//! Parser for shell words, used in expansion and other contexts. +//! +//! Implements support for: +//! +//! - Text quoting (single, double, ANSI C). +//! - Escape sequences. +//! - Tilde prefixes. +//! - Parameter expansion expressions. +//! - Command substitution expressions. +//! - Arithmetic expansion expressions. + +use std::fmt::Debug; +use std::fmt::Display; + +use crate::ParserOptions; +use crate::SourceSpan; +use crate::ast; +use crate::error; + +/// Encapsulates a `WordPiece` together with its position in the string it came from. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct WordPieceWithSource { + /// The word piece. + pub piece: WordPiece, + /// The start index of the piece in the source string. + pub start_index: usize, + /// The end index of the piece in the source string. + pub end_index: usize, +} + +/// Represents a piece of a word. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum WordPiece { + /// A simple unquoted, unescaped string. + Text(String), + /// A string that is single-quoted. + SingleQuotedText(String), + /// A string that is ANSI-C quoted. + AnsiCQuotedText(String), + /// A sequence of pieces that are embedded in double quotes. + DoubleQuotedSequence(Vec), + /// Gettext enabled variant of [`WordPiece::DoubleQuotedSequence`]. + GettextDoubleQuotedSequence(Vec), + /// A tilde expansion. + TildeExpansion(TildeExpr), + /// A parameter expansion. + ParameterExpansion(ParameterExpr), + /// A command substitution. + CommandSubstitution(String), + /// A backquoted command substitution. + BackquotedCommandSubstitution(String), + /// An escape sequence. + EscapeSequence(String), + /// An arithmetic expression. + ArithmeticExpression(ast::UnexpandedArithmeticExpr), +} + +/// Represents an expandable tilde expression (e.g., ~). +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum TildeExpr { + /// `~` + Home, + /// `~` + UserHome(String), + /// `~+` + WorkingDir, + /// `~-` + OldWorkingDir, + /// Represents a tilde expansion of the form `~+N`, referring to the Nth directory in + /// the shell's directory stack, starting at the top of the stack. Note that the directory + /// stack is expected to contains the current working directory as its topmost entry. + NthDirFromTopOfDirStack { + /// Index into the directory stack (zero-based: 0 is the top of the stack). + n: usize, + /// Whether the '+' prefix was explicitly used. + plus_used: bool, + }, + /// Represents a tilde expansion of the form `~-N`, referring to the Nth directory in + /// the shell's directory stack, starting at the bottom of the stack. + NthDirFromBottomOfDirStack { + /// Index into the directory stack (zero-based: 0 is the bottom of the stack). + n: usize, + }, +} + +/// Type of a parameter test. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterTestType { + /// Check for unset or null. + UnsetOrNull, + /// Check for unset. + Unset, +} + +/// A parameter, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Parameter { + /// A 0-indexed positional parameter. + Positional(u32), + /// A special parameter. + Special(SpecialParameter), + /// A named variable. + Named(String), + /// An index into a named variable. + NamedWithIndex { + /// Variable name. + name: String, + /// Index. + index: String, + }, + /// A named array variable with all indices. + NamedWithAllIndices { + /// Variable name. + name: String, + /// Whether to concatenate the values. + concatenate: bool, + }, +} + +impl Display for Parameter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Positional(n) => write!(f, "${n}"), + Self::Special(s) => write!(f, "${s}"), + Self::Named(name) => write!(f, "${{{name}}}"), + Self::NamedWithIndex { name, index } => { + write!(f, "${{{name}[{index}]}}") + } + Self::NamedWithAllIndices { name, concatenate } => { + if *concatenate { + write!(f, "${{{name}[*]}}") + } else { + write!(f, "${{{name}[@]}}") + } + } + } + } +} + +/// A special parameter, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SpecialParameter { + /// All positional parameters. + AllPositionalParameters { + /// Whether to concatenate the values. + concatenate: bool, + }, + /// The count of positional parameters. + PositionalParameterCount, + /// The last exit status in the shell. + LastExitStatus, + /// The current shell option flags. + CurrentOptionFlags, + /// The current shell process ID. + ProcessId, + /// The last background process ID managed by the shell. + LastBackgroundProcessId, + /// The name of the shell. + ShellName, +} + +impl Display for SpecialParameter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AllPositionalParameters { concatenate } => { + if *concatenate { + write!(f, "*") + } else { + write!(f, "@") + } + } + Self::PositionalParameterCount => write!(f, "#"), + Self::LastExitStatus => write!(f, "?"), + Self::CurrentOptionFlags => write!(f, "-"), + Self::ProcessId => write!(f, "$"), + Self::LastBackgroundProcessId => write!(f, "!"), + Self::ShellName => write!(f, "0"), + } + } +} + +/// A parameter expression, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterExpr { + /// A parameter, with optional indirection. + Parameter { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + }, + /// Conditionally use default values. + UseDefaultValues { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Default value to conditionally use. + default_value: Option, + }, + /// Conditionally assign default values. + AssignDefaultValues { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Default value to conditionally assign. + default_value: Option, + }, + /// Indicate error if null or unset. + IndicateErrorIfNullOrUnset { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Error message to conditionally yield. + error_message: Option, + }, + /// Conditionally use an alternative value. + UseAlternativeValue { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Alternative value to conditionally use. + alternative_value: Option, + }, + /// Compute the length of the given parameter. + ParameterLength { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + }, + /// Remove the smallest suffix from the given string matching the given pattern. + RemoveSmallestSuffixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the largest suffix from the given string matching the given pattern. + RemoveLargestSuffixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the smallest prefix from the given string matching the given pattern. + RemoveSmallestPrefixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the largest prefix from the given string matching the given pattern. + RemoveLargestPrefixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Extract a substring from the given parameter. + Substring { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Arithmetic expression that will be expanded to compute the offset + /// at which the substring should be extracted. + offset: ast::UnexpandedArithmeticExpr, + /// Optionally provides an arithmetic expression that will be expanded + /// to compute the length of substring to be extracted; if left + /// unspecified, the remainder of the string will be extracted. + length: Option, + }, + /// Transform the given parameter. + Transform { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Type of transformation to apply. + op: ParameterTransformOp, + }, + /// Uppercase the first character of the given parameter. + UppercaseFirstChar { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Uppercase the portion of the given parameter matching the given pattern. + UppercasePattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Lowercase the first character of the given parameter. + LowercaseFirstChar { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Lowercase the portion of the given parameter matching the given pattern. + LowercasePattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Replace occurrences of the given pattern in the given parameter. + ReplaceSubstring { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Pattern to match. + pattern: String, + /// Replacement string. + replacement: Option, + /// Kind of match to perform. + match_kind: SubstringMatchKind, + }, + /// Select variable names from the environment with a given prefix. + VariableNames { + /// The prefix to match. + prefix: String, + /// Whether to concatenate the results. + concatenate: bool, + }, + /// Select member keys from the named array. + MemberKeys { + /// Name of the array variable. + variable_name: String, + /// Whether to concatenate the results. + concatenate: bool, + }, +} + +/// Kind of substring match. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SubstringMatchKind { + /// Match the prefix of the string. + Prefix, + /// Match the suffix of the string. + Suffix, + /// Match the first occurrence in the string. + FirstOccurrence, + /// Match all instances in the string. + Anywhere, +} + +/// Kind of operation to apply to a parameter. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterTransformOp { + /// Capitalizate initials. + CapitalizeInitial, + /// Expand escape sequences. + ExpandEscapeSequences, + /// Possibly quote with arrays expanded. + PossiblyQuoteWithArraysExpanded { + /// Whether or not to yield separate words. + separate_words: bool, + }, + /// Apply prompt expansion. + PromptExpand, + /// Quote the parameter. + Quoted, + /// Translate to a format usable in an assignment/declaration. + ToAssignmentLogic, + /// Translate to the parameter's attribute flags. + ToAttributeFlags, + /// Translate to lowercase. + ToLowerCase, + /// Translate to uppercase. + ToUpperCase, +} + +/// Represents a sub-word that is either a brace expression or some other word text. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BraceExpressionOrText { + /// A brace expression. + Expr(BraceExpression), + /// Other word text. + Text(String), +} + +/// Represents a brace expression to be expanded. +pub type BraceExpression = Vec; + +/// Member of a brace expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BraceExpressionMember { + /// An inclusive numerical sequence. + NumberSequence { + /// Start of the sequence. + start: i64, + /// Inclusive end of the sequence. + end: i64, + /// Increment value. + increment: i64, + /// Width to zero pad each member out to, set when either bound was + /// written with a leading zero. + zero_padded_width: Option, + }, + /// An inclusive character sequence. + CharSequence { + /// Start of the sequence. + start: char, + /// Inclusive end of the sequence. + end: char, + /// Increment value. + increment: i64, + }, + /// Child text or expressions. + Child(Vec), +} + +/// Parse a word into its constituent pieces. +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse( + word: &str, + options: &ParserOptions, +) -> Result, error::WordParseError> { + cacheable_parse(word, options) +} + +#[cached::macros::cached( + max_size = 64, + key = "(String, ParserOptions)", + convert = r#"{ (word.to_owned(), options.to_owned()) }"# +)] +fn cacheable_parse( + word: &str, + options: &ParserOptions, +) -> Result, error::WordParseError> { + tracing::debug!(target: "expansion", "Parsing word '{}'", word); + + let pieces = expansion_parser::unexpanded_word(word, options) + .map_err(|err| error::WordParseError::Word(word.to_owned(), err.into()))?; + + tracing::debug!(target: "expansion", "Parsed word '{}' => {{{:?}}}", word, pieces); + + Ok(pieces) +} + +/// Parse a heredoc body, treating `"` and `'` as literal characters. +/// +/// # Arguments +/// +/// * `word` - The heredoc body to parse. +/// * `options` - The parser options to use. +pub fn parse_heredoc( + word: &str, + options: &ParserOptions, +) -> Result, error::WordParseError> { + expansion_parser::unexpanded_heredoc_word(word, options) + .map_err(|err| error::WordParseError::Word(word.to_owned(), err.into())) +} + +/// Parse the given word into a parameter expression. +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse_parameter( + word: &str, + options: &ParserOptions, +) -> Result { + expansion_parser::parameter(word, options) + .map_err(|err| error::WordParseError::Parameter(word.to_owned(), err.into())) +} + +/// Parse brace expansion from a given word . +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse_brace_expansions( + word: &str, + options: &ParserOptions, +) -> Result>, error::WordParseError> { + expansion_parser::brace_expansions(word, options) + .map_err(|err| error::WordParseError::BraceExpansion(word.to_owned(), err.into())) +} + +/// Parses a scalar assignment from a given word. +/// +/// A parenthesized value is treated as scalar text; see +/// [`parse_compound_assignment_value`] for reinterpreting such a value as a compound one. +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse_scalar_assignment( + word: &str, + options: &ParserOptions, +) -> Result { + expansion_parser::name_equals_scalar_value(word, options) + .map_err(|err| error::WordParseError::Word(word.to_owned(), err.into())) +} + +/// Parses text as a compound assignment value, returning its optionally-keyed element words. +/// Returns `None` if the text is not a well-formed compound value. +/// +/// This exists so that text which only becomes recognizable as a compound value *after* expansion +/// can be reinterpreted without reconstructing and re-parsing a whole assignment word. +/// +/// # Arguments +/// +/// * `value` - The candidate compound value text, including its enclosing parentheses. +/// * `options` - The parser options to use. +#[must_use] +pub fn parse_compound_assignment_value( + value: &str, + options: &ParserOptions, +) -> Option, ast::Word)>> { + let elements = crate::parser::parse_compound_assignment_value(value, options)?; + parse_array_elements(elements.iter(), options).ok() +} + +/// Parses an array assignment from a given word and its array elements. +/// +/// # Arguments +/// +/// * `word` - The assignment name and equals sign to parse. +/// * `elements` - The array element words to parse. +/// * `options` - The parser options to use. +pub(crate) fn parse_array_assignment( + word: &str, + elements: &[&String], + options: &ParserOptions, +) -> Result { + let (assignment_name, append) = + expansion_parser::name_equals(word, options).map_err(|_| "not array assignment word")?; + + Ok(ast::Assignment { + name: assignment_name, + value: ast::AssignmentValue::Array(parse_array_elements( + elements.iter().copied(), + options, + )?), + append, + loc: SourceSpan::default(), + }) +} + +/// Parses one bound of a numeric brace sequence, e.g. `-007`, or its increment. +/// +/// Returns `None` when the number does not fit in an `i64`, which lets the sequence +/// rule decline rather than panic on something like `{99999999999999999999..1}`. +/// +/// The sign goes to the parser along with the digits, so `i64::MIN` is accepted even +/// though its digits alone are one past `i64::MAX`. +/// +/// # Arguments +/// +/// * `text` - The bound as it was written, sign included. +fn parse_sequence_bound(text: &str) -> Option { + text.parse().ok() +} + +/// Returns the width the members of a numeric brace sequence should be zero +/// padded out to, or `None` if they should be written as plain numbers. +/// +/// A leading zero in either bound asks for padding, and the width is then the +/// longer of the two bounds as they were written, sign included. A lone `0` is +/// not a leading zero, and neither is a zero that follows a `+`. +/// +/// # Arguments +/// +/// * `start` - The start bound as it was written. +/// * `end` - The end bound as it was written. +fn zero_padded_width(start: &str, end: &str) -> Option { + fn has_leading_zero(bound: &str) -> bool { + let digits = bound.strip_prefix('-').unwrap_or(bound); + digits.len() > 1 && digits.starts_with('0') + } + + (has_leading_zero(start) || has_leading_zero(end)).then(|| start.len().max(end.len())) +} + +/// Parses literal array element text into optionally-keyed element words. +/// +/// # Arguments +/// +/// * `elements` - The array element texts to parse. +/// * `options` - The parser options to use. +fn parse_array_elements<'a>( + elements: impl IntoIterator, + options: &ParserOptions, +) -> Result, ast::Word)>, &'static str> { + elements + .into_iter() + .map(|element| { + let (key, value) = expansion_parser::literal_array_element(element, options) + .map_err(|_| "invalid array element in literal")?; + Ok(( + key.map(|key| ast::Word::new(key.as_str())), + ast::Word::new(value.as_str()), + )) + }) + .collect() +} + +peg::parser! { + grammar expansion_parser(parser_options: &ParserOptions) for str { + // Helper rule that enables pegviz to be used to visualize debug peg traces. + rule traced(e: rule) -> T = + &(input:$([_]*) { + #[cfg(feature = "debug-tracing")] + println!("[PEG_INPUT_START]\n{input}\n[PEG_TRACE_START]"); + }) + e:e()? {? + #[cfg(feature = "debug-tracing")] + println!("[PEG_TRACE_STOP]"); + e.ok_or("") + } + + pub(crate) rule unexpanded_word() -> Vec = traced()>) + + rule word(stop_condition: rule) -> Vec = + tilde:tilde_expr_prefix_with_source()? pieces:word_piece_with_source()* { + let mut all_pieces = Vec::new(); + if let Some(tilde) = tilde { + all_pieces.push(tilde); + } + all_pieces.extend(pieces); + all_pieces + } + + // Takes a word as input. + pub(crate) rule brace_expansions() -> Option> = + pieces:(brace_expansion_piece()+) { Some(pieces) } / + [_]* { None } + + // Returns either a complete brace expression (without any prefix or suffix), or a + // non-brace-expression string. + rule brace_expansion_piece(stop_condition: rule) -> BraceExpressionOrText = + expr:brace_expr() { + BraceExpressionOrText::Expr(expr) + } / + text:$(non_brace_expr_text()+) { BraceExpressionOrText::Text(text.to_owned()) } + + // Parses text that is not considered to contain a brace expression. + rule non_brace_expr_text(stop_condition: rule) -> () = + !"{" word_piece(<['{'] {} / stop_condition() {}>) {} / + !brace_expr() !stop_condition() "{" {} + + // Parses a complete brace expression, with no prefix or suffix. + pub(crate) rule brace_expr() -> BraceExpression = + "{" inner:brace_expr_inner() "}" { inner } + + // Parses the text inside a complete brace expression; basically the complete brace + // expression without the opening and closing brace characters. + pub(crate) rule brace_expr_inner() -> BraceExpression = + brace_text_list_expr() / + seq:brace_sequence_expr() { vec![seq] } + + // Parses a list of brace expression members, including the separating commas; does + // not include the opening and closing braces. + pub(crate) rule brace_text_list_expr() -> BraceExpression = + brace_text_list_member() **<2,> "," + + // Parses an element that can occur in a brace expression member list, not including the + // terminating comma or closing brace. + pub(crate) rule brace_text_list_member() -> BraceExpressionMember = + // Matches an empty-string member, without consuming the comma or closing brace that terminates it. + &[',' | '}'] { BraceExpressionMember::Child(vec![BraceExpressionOrText::Text(String::new())]) } / + // Matches a nested string that may include some combination of concatenated textual strings + // and brace expressions. + child_pieces:(brace_expansion_piece(<[',' | '}']>)+) { + BraceExpressionMember::Child(child_pieces) + } + + // As in bash 5.3, a sequence with an increment of `i64::MIN` is left as text, + // whichever way it goes: its magnitude can't be taken without overflowing. + pub(crate) rule brace_sequence_expr() -> BraceExpressionMember = + start:sequence_bound() ".." end:sequence_bound() increment:(".." n:number() { n })? {? + if increment == Some(i64::MIN) { + Err("increment out of range") + } else { + Ok(BraceExpressionMember::NumberSequence { + start: start.0, + end: end.0, + increment: increment.unwrap_or(1), + zero_padded_width: zero_padded_width(start.1, end.1), + }) + } + } / + start:character() ".." end:character() increment:(".." n:number() { n })? {? + if increment == Some(i64::MIN) { + Err("increment out of range") + } else { + Ok(BraceExpressionMember::CharSequence { start, end, increment: increment.unwrap_or(1) }) + } + } + + // A bound of a numeric sequence, kept alongside the text it was written as. + // Whether the members come out zero padded is decided by that text and not + // by the value, so `{01..3}` and `{1..3}` have to stay distinguishable. + rule sequence_bound() -> (i64, &'input str) = text:$(number_sign()? ['0'..='9']+) {? + parse_sequence_bound(text).map(|n| (n, text)).ok_or("number out of range") + } + + // The increment of a brace sequence. One that does not fit in an `i64` makes + // the sequence rule decline, so the expression is left as text, as bash does. + rule number() -> i64 = text:$(number_sign()? ['0'..='9']+) {? + parse_sequence_bound(text).ok_or("number out of range") + } + + rule number_sign() -> i64 = + ['-'] { -1 } / + ['+'] { 1 } + + rule character() -> char = ['a'..='z' | 'A'..='Z'] + + pub(crate) rule is_arithmetic_word() = + arithmetic_word() + + // N.B. We don't bother returning the word pieces, as all users of this rule + // only try to extract the consumed input string and not the parse result. + rule arithmetic_word(stop_condition: rule) = + arithmetic_word_piece()* {} + + pub(crate) rule is_arithmetic_word_piece() = + arithmetic_word_piece() + + // This rule matches an individual "piece" of an arithmetic expression. It needs to handle + // matching nested parenthesized expressions as well. We stop consuming the input when + // we reach the provided stop condition, which typically denotes the end of the containing + // arithmetic expression. + rule arithmetic_word_piece(stop_condition: rule) = + // This branch matches a parenthesized piece; we consume the opening parenthesis and + // delegate the rest to a helper rule. We don't worry about the stop condition passed + // into us, because if we see an opening parenthesis then we *must* find its closing + // partner. + "(" arithmetic_word_plus_right_paren() {} / + // An array subscript may contain the stop condition. Other brackets are left + // as text, so malformed arithmetic doesn't fall back to command substitution. + array_element_name() {} / + // This branch matches any standard piece of a word, stopping as soon as we reach + // either the overall stop condition *OR* an opening parenthesis. We add this latter + // condition to ensure that *we* handle matching parentheses. + !"(" word_piece()>) {} + + // This is a helper rule that matches either the provided stop condition or an opening parenthesis. + rule param_rule_or_open_paren(stop_condition: rule) -> () = + stop_condition() {} / + "(" {} + + // This rule matches an arithmetic word followed by a right parenthesis. It must consume the right parenthesis. + rule arithmetic_word_plus_right_paren() = + arithmetic_word(<[')']>) ")" + + rule word_piece_with_source(stop_condition: rule) -> WordPieceWithSource = + start_index:position!() piece:word_piece() end_index:position!() { + WordPieceWithSource { piece, start_index, end_index } + } + + rule word_piece(stop_condition: rule) -> WordPiece = + // Rules that match quoted text. + s:double_quoted_sequence() { WordPiece::DoubleQuotedSequence(s) } / + s:single_quoted_literal_text() { WordPiece::SingleQuotedText(s.to_owned()) } / + s:ansi_c_quoted_text() { WordPiece::AnsiCQuotedText(s.to_owned()) } / + s:gettext_double_quoted_sequence() { WordPiece::GettextDoubleQuotedSequence(s) } / + // Rules that match pieces starting with a dollar sign ('$'). + dollar_sign_word_piece() / + // Rules that match unquoted text that doesn't start with an unescaped dollar sign. + normal_escape_sequence() / + // Allow tilde expression to be matched as a word piece (for tilde-after-colon expansion) + enabled_tilde_expr_after_colon() / + // Finally, match unquoted literal text. + unquoted_literal_text() + + rule dollar_sign_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() + + rule double_quoted_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() / + double_quoted_escape_sequence() / + double_quoted_text() + + rule double_quoted_sequence() -> Vec = + "\"" i:double_quoted_sequence_inner()* "\"" { i } + + rule gettext_double_quoted_sequence() -> Vec = + "$\"" i:double_quoted_sequence_inner()* "\"" { i } + + rule double_quoted_sequence_inner() -> WordPieceWithSource = + start_index:position!() piece:double_quoted_word_piece() end_index:position!() { + WordPieceWithSource { + piece, + start_index, + end_index + } + } + + rule single_quoted_literal_text() -> &'input str = + "\'" inner:$([^'\'']*) "\'" { inner } + + rule ansi_c_quoted_text() -> &'input str = + r"$'" inner:$((r"\\" / r"\'" / [^'\''])*) r"'" { inner } + + rule unquoted_literal_text(stop_condition: rule) -> WordPiece = + s:$(unquoted_literal_text_piece()+) { WordPiece::Text(s.to_owned()) } + + rule unquoted_literal_text_piece(stop_condition: rule) = + !stop_condition() !normal_escape_sequence() !enabled_tilde_expr_after_colon() [^'\'' | '\"' | '$' | '`'] {} + + rule enabled_tilde_expr_after_colon() -> WordPiece = + tilde_exprs_after_colon_enabled() last_char_is_colon() piece:tilde_expression_piece() { piece } + + rule last_char_is_colon() = #{|input, pos| { + if pos == 0 { + // No preceding character - can't be preceded by ':' + peg::RuleResult::Failed + } else { + // Check the byte directly (`:` is ASCII, single byte) + if input.as_bytes()[pos - 1] == b':' { + peg::RuleResult::Matched(pos, ()) + } else { + peg::RuleResult::Failed + } + } + }} + + rule double_quoted_text() -> WordPiece = + s:double_quote_body_text() { WordPiece::Text(s.to_owned()) } + + rule double_quote_body_text() -> &'input str = + $((!double_quoted_escape_sequence() !dollar_sign_word_piece() !expansion_opener() [^'\"'])+) + + // Heredoc body parsing: like double-quoted content, but " and ' are literal characters. + pub(crate) rule unexpanded_heredoc_word() -> Vec = + traced()>) + + rule heredoc_word(stop_condition: rule) -> Vec = + pieces:heredoc_word_piece_with_source()* { pieces } + + rule heredoc_word_piece_with_source(stop_condition: rule) -> WordPieceWithSource = + !stop_condition() start_index:position!() piece:heredoc_word_piece() end_index:position!() { + WordPieceWithSource { piece, start_index, end_index } + } + + rule heredoc_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() / + heredoc_escape_sequence() / + heredoc_literal_text() + + rule heredoc_escape_sequence() -> WordPiece = + s:$("\\" ['$' | '`' | '\\']) { WordPiece::EscapeSequence(s.to_owned()) } + + rule heredoc_literal_text() -> WordPiece = + s:$((!heredoc_escape_sequence() !dollar_sign_word_piece() !expansion_opener() [^'`'])+) { + WordPiece::Text(s.to_owned()) + } + + rule normal_escape_sequence() -> WordPiece = + s:$("\\" [c]) { WordPiece::EscapeSequence(s.to_owned()) } + + rule double_quoted_escape_sequence() -> WordPiece = + s:$("\\" ['$' | '`' | '\"' | '\\']) { WordPiece::EscapeSequence(s.to_owned()) } + + rule tilde_expr_prefix_with_source() -> WordPieceWithSource = + start_index:position!() piece:tilde_expr_prefix() end_index:position!() { + WordPieceWithSource { + piece, + start_index, + end_index + } + } + + rule tilde_expr_prefix() -> WordPiece = + tilde_exprs_at_word_start_enabled() piece:tilde_expression_piece() { piece } + + rule tilde_expr_after_colon() -> WordPiece = + tilde_exprs_after_colon_enabled() piece:tilde_expression_piece() { piece } + + rule tilde_expression_piece() -> WordPiece = + "~" expr:tilde_expression() { WordPiece::TildeExpansion(expr) } + + rule tilde_expression() -> TildeExpr = + &tilde_terminator() { TildeExpr::Home } / + "+" &tilde_terminator() { TildeExpr::WorkingDir } / + // N.B. A run of digits need not fit in a `usize`; decline the rule + // when it does not, so the word falls through to being treated as a + // (non-existent) user name instead of panicking the parser. + plus:("+"?) n:$(['0'..='9']*) &tilde_terminator() {? n.parse().map_or_else(|_| Err("dir stack index out of range"), |n| Ok(TildeExpr::NthDirFromTopOfDirStack { n, plus_used: plus.is_some() })) } / + "-" &tilde_terminator() { TildeExpr::OldWorkingDir } / + "-" n:$(['0'..='9']*) &tilde_terminator() {? n.parse().map_or_else(|_| Err("dir stack index out of range"), |n| Ok(TildeExpr::NthDirFromBottomOfDirStack { n })) } / + user:$(portable_filename_char()*) &tilde_terminator() { TildeExpr::UserHome(user.to_owned()) } + + rule tilde_terminator() = ['/' | ':' | ';' | '}'] / ![_] + + rule portable_filename_char() = ['A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-'] + + // TODO(parser): Deal with fact that there may be a quoted word or escaped closing brace chars. + // TODO(parser): Improve on how we handle a '$' not followed by a valid variable name or parameter. + // Cached: each `${...}` can match any of ~20 parameter expression forms, and a failure + // within one (e.g., an unterminated `$(`) would otherwise be re-discovered by every form + // at every enclosing level, taking time exponential in the nesting depth. + #[cache] + rule parameter_expansion() -> WordPiece = + "${" e:parameter_expression() "}" { + WordPiece::ParameterExpansion(e) + } / + "$" parameter:unbraced_parameter() { + WordPiece::ParameterExpansion(ParameterExpr::Parameter { parameter, indirect: false }) + } / + !expansion_opener() "$" !['\''] { + WordPiece::Text("$".to_owned()) + } + + // As in bash, these always start an expansion, whose end the tokenizer finds (so it + // always does, in a word the tokenizer accepted). If none parses, the text is malformed + // (e.g., unterminated, or nested too deeply), so its `$` mustn't be taken as literal text. + // TODO(expansion): #540: the same holds for `${`, which is still taken as text. + rule expansion_opener() = "$" ['(' | '['] + + rule parameter_expression() -> ParameterExpr = + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "-" default_value:parameter_expression_word()? { + ParameterExpr::UseDefaultValues { parameter, indirect, test_type, default_value } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "=" default_value:parameter_expression_word()? { + ParameterExpr::AssignDefaultValues { parameter, indirect, test_type, default_value } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "?" error_message:parameter_expression_word()? { + ParameterExpr::IndicateErrorIfNullOrUnset { parameter, indirect, test_type, error_message } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "+" alternative_value:parameter_expression_word()? { + ParameterExpr::UseAlternativeValue { parameter, indirect, test_type, alternative_value } + } / + "#" parameter:parameter() { + ParameterExpr::ParameterLength { parameter, indirect: false } + } / + indirect:parameter_indirection() parameter:parameter() "%%" pattern:parameter_expression_word()? { + ParameterExpr::RemoveLargestSuffixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "%" pattern:parameter_expression_word()? { + ParameterExpr::RemoveSmallestSuffixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "##" pattern:parameter_expression_word()? { + ParameterExpr::RemoveLargestPrefixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "#" pattern:parameter_expression_word()? { + ParameterExpr::RemoveSmallestPrefixPattern { parameter, indirect, pattern } + } / + // N.B. The following case is for non-sh extensions. + non_posix_extensions_enabled() e:non_posix_parameter_expression() { e } / + indirect:parameter_indirection() parameter:parameter() { + ParameterExpr::Parameter { parameter, indirect } + } + + rule parameter_test_type() -> ParameterTestType = + colon:":"? { + if colon.is_some() { + ParameterTestType::UnsetOrNull + } else { + ParameterTestType::Unset + } + } + + rule non_posix_parameter_expression() -> ParameterExpr = + "!" variable_name:variable_name() "[*]" { + ParameterExpr::MemberKeys { variable_name: variable_name.to_owned(), concatenate: true } + } / + "!" variable_name:variable_name() "[@]" { + ParameterExpr::MemberKeys { variable_name: variable_name.to_owned(), concatenate: false } + } / + indirect:parameter_indirection() parameter:parameter() ":" offset:substring_offset() length:(":" l:substring_length() { l })? { + ParameterExpr::Substring { parameter, indirect, offset, length } + } / + indirect:parameter_indirection() parameter:parameter() "@" op:non_posix_parameter_transformation_op() { + ParameterExpr::Transform { parameter, indirect, op } + } / + "!" prefix:variable_name() "*" { + ParameterExpr::VariableNames { prefix: prefix.to_owned(), concatenate: true } + } / + "!" prefix:variable_name() "@" { + ParameterExpr::VariableNames { prefix: prefix.to_owned(), concatenate: false } + } / + indirect:parameter_indirection() parameter:parameter() "/#" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Prefix } + } / + indirect:parameter_indirection() parameter:parameter() "/%" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Suffix } + } / + indirect:parameter_indirection() parameter:parameter() "//" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Anywhere } + } / + indirect:parameter_indirection() parameter:parameter() "/" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::FirstOccurrence } + } / + indirect:parameter_indirection() parameter:parameter() "^^" pattern:parameter_expression_word()? { + ParameterExpr::UppercasePattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "^" pattern:parameter_expression_word()? { + ParameterExpr::UppercaseFirstChar { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() ",," pattern:parameter_expression_word()? { + ParameterExpr::LowercasePattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "," pattern:parameter_expression_word()? { + ParameterExpr::LowercaseFirstChar { parameter, indirect, pattern } + } + + rule parameter_indirection() -> bool = + non_posix_extensions_enabled() "!" { true } / + { false } + + rule non_posix_parameter_transformation_op() -> ParameterTransformOp = + "U" { ParameterTransformOp::ToUpperCase } / + "u" { ParameterTransformOp::CapitalizeInitial } / + "L" { ParameterTransformOp::ToLowerCase } / + "Q" { ParameterTransformOp::Quoted } / + "E" { ParameterTransformOp::ExpandEscapeSequences } / + "P" { ParameterTransformOp::PromptExpand } / + "A" { ParameterTransformOp::ToAssignmentLogic } / + "K" { ParameterTransformOp::PossiblyQuoteWithArraysExpanded { separate_words: false } } / + "a" { ParameterTransformOp::ToAttributeFlags } / + "k" { ParameterTransformOp::PossiblyQuoteWithArraysExpanded { separate_words: true } } + + + rule unbraced_parameter() -> Parameter = + p:unbraced_positional_parameter() { Parameter::Positional(p) } / + p:special_parameter() { Parameter::Special(p) } / + p:variable_name() { Parameter::Named(p.to_owned()) } + + // N.B. The indexing syntax is not a standard sh-ism. + pub(crate) rule parameter() -> Parameter = + p:positional_parameter() { Parameter::Positional(p) } / + p:special_parameter() { Parameter::Special(p) } / + non_posix_extensions_enabled() p:variable_name() "[@]" { Parameter::NamedWithAllIndices { name: p.to_owned(), concatenate: false } } / + non_posix_extensions_enabled() p:variable_name() "[*]" { Parameter::NamedWithAllIndices { name: p.to_owned(), concatenate: true } } / + non_posix_extensions_enabled() p:variable_name() "[" index:array_index() "]" {? + Ok(Parameter::NamedWithIndex { name: p.to_owned(), index: index.to_owned() }) + } / + p:variable_name() { Parameter::Named(p.to_owned()) } + + rule positional_parameter() -> u32 = + n:$(['1'..='9'](['0'..='9']*)) {? n.parse().or(Err("u32")) } + rule unbraced_positional_parameter() -> u32 = + n:$(['1'..='9']) {? n.parse().or(Err("u32")) } + + rule special_parameter() -> SpecialParameter = + "@" { SpecialParameter::AllPositionalParameters { concatenate: false } } / + "*" { SpecialParameter::AllPositionalParameters { concatenate: true } } / + "#" { SpecialParameter::PositionalParameterCount } / + "?" { SpecialParameter::LastExitStatus } / + "-" { SpecialParameter::CurrentOptionFlags } / + "$" { SpecialParameter::ProcessId } / + "!" { SpecialParameter::LastBackgroundProcessId } / + "0" { SpecialParameter::ShellName } + + rule variable_name() -> &'input str = + $(!['0'..='9'] ['_' | '0'..='9' | 'a'..='z' | 'A'..='Z']+) + + pub(crate) rule command_substitution() -> WordPiece = + "$(" c:command_substitution_body() ")" { WordPiece::CommandSubstitution(c.to_owned()) } / + "`" c:backquoted_command() "`" { WordPiece::BackquotedCommandSubstitution(c) } + + // The tokenizer already has the logic to find where the command ends (e.g., skipping + // over here-doc bodies), so we leverage it here. `pos` and `body.len()` are both + // byte offsets. + rule command_substitution_body() -> &'input str = #{|input, pos| { + let rest = input.split_at(pos).1; + match crate::tokenizer::command_substitution_body(rest, &parser_options.tokenizer_options()) { + Ok(body) => peg::RuleResult::Matched(pos + body.len(), body), + Err(_) => peg::RuleResult::Failed, + } + }} + + rule backquoted_command() -> String = + chars:(backquoted_char()*) { + let mut command: String = chars.into_iter().collect(); + // TODO(tokenizer): A backslash ending the input is reported as an unterminated + // escape, where bash takes it literally; escape it so it stays literal. + if command.chars().rev().take_while(|c| *c == '\\').count() % 2 == 1 { + command.push('\\'); + } + command + } + + // As in bash, a backslash is removed only before `$`, `` ` ``, or `\`. (The tokenizer + // already removed line continuations.) + rule backquoted_char() -> &'input str = + "\\" s:$(['$' | '`' | '\\']) { s } / + s:$([^'`']) { s } + + rule arithmetic_expansion() -> WordPiece = + "$((" e:$(arithmetic_word(<"))">)) "))" { WordPiece::ArithmeticExpression(ast::UnexpandedArithmeticExpr { value: e.to_owned() } ) } + + rule legacy_arithmetic_expansion() -> WordPiece = + "$[" e:legacy_arithmetic_expansion_body() "]" { WordPiece::ArithmeticExpression(ast::UnexpandedArithmeticExpr { value: e.to_owned() } ) } + + // Reuse the tokenizer's iterative bracket counting rather than recursively parsing + // each bracket in the expression. As with command substitutions, preserve the source. + rule legacy_arithmetic_expansion_body() -> &'input str = #{|input, pos| { + let rest = input.split_at(pos).1; + match crate::tokenizer::legacy_arithmetic_expansion_body(rest, &parser_options.tokenizer_options()) { + Ok(body) => peg::RuleResult::Matched(pos + body.len(), body), + Err(_) => peg::RuleResult::Failed, + } + }} + + rule substring_offset() -> ast::UnexpandedArithmeticExpr = + s:$(arithmetic_word(<[':' | '}']>)) { ast::UnexpandedArithmeticExpr { value: s.to_owned() } } + + rule substring_length() -> ast::UnexpandedArithmeticExpr = + s:$(arithmetic_word(<[':' | '}']>)) { ast::UnexpandedArithmeticExpr { value: s.to_owned() } } + + rule parameter_replacement_str() -> String = + "/" s:$(word(<['}']>)) { s.to_owned() } + + rule parameter_search_pattern() -> String = + s:$(word(<['}' | '/']>)) { s.to_owned() } + + rule parameter_expression_word() -> String = + s:$(word(<['}']>)) { s.to_owned() } + + rule extglob_enabled() -> () = + &[_] {? if parser_options.enable_extended_globbing { Ok(()) } else { Err("no extglob") } } + + rule non_posix_extensions_enabled() -> () = + &[_] {? if !parser_options.sh_mode { Ok(()) } else { Err("posix") } } + + rule tilde_exprs_at_word_start_enabled() -> () = + &[_] {? if parser_options.tilde_expansion_at_word_start { Ok(()) } else { Err("no tilde expansion at word start") } } + + rule tilde_exprs_after_colon_enabled() -> () = + &[_] {? if parser_options.tilde_expansion_after_colon { Ok(()) } else { Err("no tilde expansion after colon") } } + + // Assignment rules. + + pub(crate) rule name_equals_scalar_value() -> ast::Assignment = + nae:name_equals() value:assigned_scalar_value() { + let (name, append) = nae; + ast::Assignment { name, value, append, loc: SourceSpan::default() } + } + + pub(crate) rule name_equals() -> (ast::AssignmentName, bool) = + name:assignment_name() append:("+"?) "=" { + (name, append.is_some()) + } + + pub(crate) rule literal_array_element() -> (Option, String) = + "[" inner:$((!"]" [_])*) "]=" value:$([_]*) { + (Some(inner.to_owned()), value.to_owned()) + } / + value:$([_]+) { + (None, value.to_owned()) + } + + rule assignment_name() -> ast::AssignmentName = + aen:array_element_name() { + let (name, index) = aen; + ast::AssignmentName::ArrayElementName(name.to_owned(), index.to_owned()) + } / + name:assigned_scalar_name() { + ast::AssignmentName::VariableName(name.to_owned()) + } + + rule array_element_name() -> (&'input str, &'input str) = + name:assigned_scalar_name() "[" ai:array_index() "]" { (name, ai) } + + rule array_index() -> &'input str = + $(arithmetic_word(<"]">)) + + rule assigned_scalar_name() -> &'input str = + $(alpha_or_underscore() non_first_variable_char()*) + + rule non_first_variable_char() -> () = + ['_' | '0'..='9' | 'a'..='z' | 'A'..='Z'] {} + + rule alpha_or_underscore() -> () = + ['_' | 'a'..='z' | 'A'..='Z'] {} + + rule assigned_scalar_value() -> ast::AssignmentValue = + v:$([_]*) { ast::AssignmentValue::Scalar(ast::Word::from(v.to_owned())) } + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use insta::assert_ron_snapshot; + use pretty_assertions::assert_matches; + + #[derive(serde::Serialize, serde::Deserialize)] + struct ParseTestResults<'a> { + input: &'a str, + result: Vec, + } + + fn test_parse(word: &str) -> Result> { + let parsed = super::parse(word, &ParserOptions::default())?; + Ok(ParseTestResults { + input: word, + result: parsed, + }) + } + + #[test] + fn parse_ansi_c_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r"$'hi\nthere\t'")?); + Ok(()) + } + + #[test] + fn parse_ansi_c_quoted_escape_seq() -> Result<()> { + assert_ron_snapshot!(test_parse(r"$'\\'")?); + Ok(()) + } + + #[test] + fn parse_tilde_with_out_of_range_dir_stack_index() -> Result<()> { + // Regression: the dir-stack index was unwrapped, so a run of digits too + // large for a `usize` panicked the parser. It should fall through to + // being treated as a user name, which is what bash does with it. + // `~N` and `~-N` fall through to a user name; `~+N` cannot, because '+' + // is not a valid user-name character, so it stays literal text. Both + // match what bash does with an out-of-range index. + let parsed = super::parse("~99999999999999999999", &ParserOptions::default())?; + assert_eq!(parsed.len(), 1); + assert_matches!( + parsed[0].piece, + WordPiece::TildeExpansion(TildeExpr::UserHome(_)) + ); + + let parsed = super::parse("~-99999999999999999999", &ParserOptions::default())?; + assert_eq!(parsed.len(), 1); + assert_matches!( + parsed[0].piece, + WordPiece::TildeExpansion(TildeExpr::UserHome(_)) + ); + + let parsed = super::parse("~+99999999999999999999", &ParserOptions::default())?; + assert_eq!(parsed.len(), 1); + assert_matches!(parsed[0].piece, WordPiece::Text(_)); + + Ok(()) + } + + #[test] + fn parse_tilde_with_in_range_dir_stack_index() -> Result<()> { + let parsed = super::parse("~+2", &ParserOptions::default())?; + assert_eq!(parsed.len(), 1); + assert_matches!( + parsed[0].piece, + WordPiece::TildeExpansion(TildeExpr::NthDirFromTopOfDirStack { n: 2, .. }) + ); + + Ok(()) + } + + #[test] + fn parse_tilde_after_colon() -> Result<()> { + let opts = ParserOptions { + tilde_expansion_after_colon: true, + ..ParserOptions::default() + }; + + let parsed = super::parse("a:~", &opts)?; + + // Should have: Text("a:"), TildeExpansion("") + assert_eq!(parsed.len(), 2); + assert_matches!(parsed[0].piece, WordPiece::Text(_)); + assert_matches!(parsed[1].piece, WordPiece::TildeExpansion(_)); + + Ok(()) + } + + #[test] + fn parse_double_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r#""a ${b} c""#)?); + Ok(()) + } + + #[test] + fn parse_gettext_double_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r#"$"a ${b} c""#)?); + Ok(()) + } + + #[test] + fn parse_command_substitution() -> Result<()> { + super::expansion_parser::command_substitution("$(echo hi)", &ParserOptions::default())?; + + assert_ron_snapshot!(test_parse("$(echo hi)")?); + + Ok(()) + } + + #[test] + fn parse_command_substitution_with_embedded_quotes() -> Result<()> { + super::expansion_parser::command_substitution( + r#"$(echo "hi")"#, + &ParserOptions::default(), + )?; + + assert_ron_snapshot!(test_parse(r#"$(echo "hi")"#)?); + Ok(()) + } + + #[test] + fn parse_command_substitution_with_multibyte_chars() -> Result<()> { + assert_ron_snapshot!(test_parse("é$(echo “ü”)ñ")?); + Ok(()) + } + + #[test] + fn parse_command_substitution_with_embedded_extglob() -> Result<()> { + assert_ron_snapshot!(test_parse("$(echo !(x))")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_unbalanced_single_quote() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\nit's here\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_unbalanced_backtick() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\na ` b\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_double_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n\"hello\"\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_single_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n'hello'\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_backticks() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n`hello`\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_backquoted_command() -> Result<()> { + assert_ron_snapshot!(test_parse("`echo hi`")?); + Ok(()) + } + + #[test] + fn parse_backquoted_command_in_double_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse(r#""`echo hi`""#)?); + Ok(()) + } + + #[test] + fn parse_backquoted_command_escapes() -> Result<()> { + for (input, expected) in [ + (r"`printf \\2`", r"printf \2"), + (r"`printf \\\\2`", r"printf \\2"), + (r#"`echo \$x \` \" \a`"#, r#"echo $x ` \" \a"#), + // A backslash left ending the command is escaped to keep it literal. + (r"`printf \\`", r"printf \\"), + (r"`printf \\\\`", r"printf \\"), + (r"`printf \\\\\\`", r"printf \\\\"), + ] { + let parsed = super::parse(input, &ParserOptions::default())?; + assert_matches!( + &parsed[..], + [WordPieceWithSource { piece: WordPiece::BackquotedCommandSubstitution(command), .. }] + if command == expected, + "parsing {input:?}" + ); + } + Ok(()) + } + + #[test] + fn parse_extglob_with_embedded_parameter() -> Result<()> { + assert_ron_snapshot!(test_parse("+([$var])")?); + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion() -> Result<()> { + assert_ron_snapshot!(test_parse("$((0))")?); + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion_with_parens() -> Result<()> { + assert_ron_snapshot!(test_parse("$((((1+2)*3)))")?); + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion_with_unmatched_brackets() -> Result<()> { + // Invalid arithmetic must stay arithmetic, rather than fall back to executing + // the expression as a command substitution. + for expr in ["echo unexpected [", "1[", "a[1", "[ ["] { + let word = std::format!("$(({expr}))"); + let parsed = super::parse(&word, &ParserOptions::default())?; + assert_matches!( + parsed.as_slice(), + [WordPieceWithSource { piece: WordPiece::ArithmeticExpression(e), .. }] if e.value == expr, + "for {word:?}" + ); + } + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion_with_deep_brackets() -> Result<()> { + // Brackets in the expression must not grow the word parser's call stack. + // Arithmetic evaluation can reject this expression after its boundaries are found. + let expr = std::format!("{}1{}", "[".repeat(15_000), "]".repeat(15_000)); + for (open, close) in [("$((", "))"), ("$[", "]")] { + let word = std::format!("{open}{expr}{close}"); + let parsed = super::parse(&word, &ParserOptions::default())?; + assert_matches!( + parsed.as_slice(), + [WordPieceWithSource { piece: WordPiece::ArithmeticExpression(e), .. }] if e.value == expr + ); + } + Ok(()) + } + + #[test] + fn parse_legacy_arithmetic_expansion_with_array_subscripts() -> Result<()> { + for (word, expr) in [ + ("$[a[1]]", "a[1]"), + ("$[ a[1] + 1 ]", " a[1] + 1 "), + ("$[ a[ a[0] ] ]", " a[ a[0] ] "), + ("$[ a[$[1]] + $(printf 2) ]", " a[$[1]] + $(printf 2) "), + ("$[ 'é]' ]", " 'é]' "), + ("$[ \\] ]", " \\] "), + ] { + let parsed = super::parse(word, &ParserOptions::default())?; + assert_matches!( + parsed.as_slice(), + [WordPieceWithSource { piece: WordPiece::ArithmeticExpression(e), .. }] if e.value == expr, + "for {word:?}" + ); + } + Ok(()) + } + + #[test] + fn parse_malformed_expansions_as_errors() { + // As in bash, a `$` before `(` or `[` always starts an expansion; if none parses, the + // text is malformed, not literal text. + let options = ParserOptions::default(); + for word in ["a$(echo", "\"$(echo\"", "$((1 +", "$[1 +", "\"a $[1 + b\""] { + assert!(super::parse(word, &options).is_err(), "for {word:?}"); + } + for body in [ + "a $(echo hi\n", + "a $(echo 'b) c\n", + "a $((1 + b\n", + "a $[1 + b\n", + ] { + assert!( + super::parse_heredoc(body, &options).is_err(), + "for {body:?}" + ); + } + } + + #[test] + fn parse_malformed_expansions_nested_in_parameter_expansions_quickly() { + // Each `${...}` can match any of ~20 parameter expression forms; unless its parse is + // memoized, a failure within (e.g., an unterminated `$(`) is re-discovered by each form, + // taking time exponential in the nesting depth. + let options = ParserOptions::default(); + let nested = + |inner: &str| std::format!("{}{inner}{}\n", "${x:-".repeat(30), "}".repeat(30)); + assert!(super::parse_heredoc(&nested("$(echo"), &options).is_err()); + assert!(super::parse_heredoc(&nested("$((1 +"), &options).is_err()); + assert!(super::parse(&std::format!("\"{}\"", nested("$[1").trim_end()), &options).is_err()); + } + + #[test] + fn parse_lone_dollar_signs_as_text() -> Result<()> { + let options = ParserOptions::default(); + for word in [ + "$", "a$", "$ b", "$%", "\"$\"", "\"a$ b\"", "\"$'x'\"", "${x}$", + ] { + super::parse(word, &options)?; + } + super::parse_heredoc("cost: $5 $ $% \"$'x'\" a$\n", &options)?; + Ok(()) + } + + #[test] + fn parse_heredoc_with_command_substitutions_nested_too_deeply() -> Result<()> { + // Here-doc bodies aren't tokenized along with the script, so the word parser is the + // first to find nesting past the tokenizer's limit; that's an error, not literal text. + let nested = |depth: u32| { + let depth = depth as usize; + std::format!("{}:{}\n", "$(".repeat(depth), ")".repeat(depth)) + }; + let limit = crate::tokenizer::MAX_EXPANSION_NESTING; + let options = ParserOptions::default(); + super::parse_heredoc(&nested(limit), &options)?; + assert!(super::parse_heredoc(&nested(limit + 1), &options).is_err()); + Ok(()) + } + + #[test] + fn test_arithmetic_word_parsing() { + let options = ParserOptions::default(); + + assert!(super::expansion_parser::is_arithmetic_word("a", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("b", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word(" a + b ", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(a)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("((a))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(((a)))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(1+2)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(1+2)*3", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("((1+2)*3)", &options).is_ok()); + } + + #[test] + fn test_arithmetic_word_piece_parsing() { + let options = ParserOptions::default(); + + assert!(super::expansion_parser::is_arithmetic_word_piece("a", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("b", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece(" a + b ", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((a))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(((a)))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(1+2)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((1+2))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((1+2)*3)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a", &options).is_err()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a))", &options).is_err()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((a)", &options).is_err()); + } + + #[test] + fn test_brace_expansion_parsing() -> Result<()> { + let options = ParserOptions::default(); + + let inputs = ["x{a,b}y", "{a,b{1,2}}"]; + + for input in inputs { + assert_ron_snapshot!(super::parse_brace_expansions(input, &options)?.ok_or_else( + || anyhow::anyhow!("Expected brace expansion to be parsed successfully") + )?); + } + + Ok(()) + } + + #[test] + fn brace_sequence_with_out_of_range_increment_is_text() -> Result<()> { + let options = ParserOptions::default(); + + for input in [ + "{1..5..99999999999999999999}", + "{1..5..-99999999999999999999}", + "{a..e..99999999999999999999}", + // No sequence steps by `i64::MIN`, whose magnitude overflows. + "{1..5..-9223372036854775808}", + "{5..1..-9223372036854775808}", + "{3..3..-9223372036854775808}", + "{a..e..-9223372036854775808}", + "{e..a..-9223372036854775808}", + ] { + let pieces = super::parse_brace_expansions(input, &options)?.unwrap_or_default(); + assert!( + pieces + .iter() + .all(|piece| matches!(piece, BraceExpressionOrText::Text(_))), + "{input} should not parse as a brace sequence: {pieces:?}" + ); + } + + Ok(()) + } + + #[test] + fn test_scalar_assignment_parsing() -> Result<()> { + let options = ParserOptions::default(); + + super::parse_scalar_assignment("x=3", &options)?; + super::parse_scalar_assignment("x=", &options)?; + super::parse_scalar_assignment("x[3]=a", &options)?; + super::parse_scalar_assignment("x[${y[3]}]=a", &options)?; + super::parse_scalar_assignment("x[y[3]]=a", &options)?; + Ok(()) + } + + #[test] + fn parse_compound_assignment_value() { + let options = ParserOptions::default(); + let parse = |value| super::parse_compound_assignment_value(value, &options); + + assert_matches!(parse("()").as_deref(), Some([])); + + assert_matches!(parse("(1 2)").as_deref(), Some([(None, first), (None, second)]) + if first.value == "1" && second.value == "2"); + + assert_matches!( + parse(r#"([key]=value "other one")"#).as_deref(), + Some([(Some(key), value), (None, other)]) + if key.value == "key" + && value.value == "value" + && other.value == r#""other one""#); + + // Text that is not exclusively a compound value must not be accepted; otherwise trailing + // shell syntax hidden in an expanded value would be silently dropped. + assert!(parse(r#"(x); printf "INJECTED\n"; #"#).is_none()); + assert!(parse("(unterminated").is_none()); + assert!(parse("plain text").is_none()); + assert!(parse("").is_none()); + } + + #[test] + fn parse_sequence_bound() { + assert_eq!(super::parse_sequence_bound("3"), Some(3)); + assert_eq!(super::parse_sequence_bound("007"), Some(7)); + assert_eq!(super::parse_sequence_bound("+7"), Some(7)); + assert_eq!(super::parse_sequence_bound("-007"), Some(-7)); + assert_eq!(super::parse_sequence_bound("0"), Some(0)); + assert_eq!(super::parse_sequence_bound("-0"), Some(0)); + assert_eq!(super::parse_sequence_bound("99999999999999999999"), None); + + // The digits of `i64::MIN` are one past `i64::MAX`, so the sign has to be parsed with them. + assert_eq!( + super::parse_sequence_bound("-9223372036854775808"), + Some(i64::MIN) + ); + assert_eq!( + super::parse_sequence_bound("9223372036854775807"), + Some(i64::MAX) + ); + assert_eq!(super::parse_sequence_bound("-9223372036854775809"), None); + } + + #[test] + fn zero_padded_width() { + let width = super::zero_padded_width; + + // Neither bound was written with a leading zero. + assert_eq!(width("1", "5"), None); + assert_eq!(width("0", "10"), None); + assert_eq!(width("-0", "3"), None); + + // The width is the longer bound as written, whichever one asked for padding. + assert_eq!(width("01", "5"), Some(2)); + assert_eq!(width("1", "005"), Some(3)); + assert_eq!(width("0009", "11"), Some(4)); + + // A sign takes one of the columns. + assert_eq!(width("-01", "01"), Some(3)); + assert_eq!(width("00", "-3"), Some(2)); + + // A zero after a plus does not ask for padding, though the text still + // counts toward the width once the other bound has asked. + assert_eq!(width("+01", "3"), None); + assert_eq!(width("+01", "05"), Some(3)); + } +} diff --git a/userland/brush/source/brush-shell/Cargo.toml b/userland/brush/source/brush-shell/Cargo.toml new file mode 100644 index 0000000..7502ae3 --- /dev/null +++ b/userland/brush/source/brush-shell/Cargo.toml @@ -0,0 +1,168 @@ +[package] +name = "brush-shell" +description = "Rust-implemented shell focused on POSIX and bash compatibility" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +# Application tier; see docs/reference/msrv-policy.md. +rust-version = "1.95.0" + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/{ name }-v{ version }/brush-{ target }{ archive-suffix }" + +[lib] +bench = false + +[[bin]] +name = "brush" +path = "src/main.rs" +bench = false + +[[test]] +name = "brush-compat-tests" +path = "tests/compat_tests.rs" +harness = false + +[[test]] +name = "brush-integration-tests" +path = "tests/integration_tests.rs" +harness = false + +[[test]] +name = "brush-version-tests" +path = "tests/version_tests.rs" + +[[test]] +name = "brush-interactive-tests" +path = "tests/interactive_tests.rs" + +[[test]] +name = "brush-completion-tests" +path = "tests/completion_tests.rs" + +# N.B. tests/pty_startup_tests.rs, tests/reedline_interactive_tests.rs, and +# tests/vt_interactive_tests.rs are deliberately left auto-discovered (no +# `brush-*-tests` name): the CI `brush_*_tests-*` artifact glob would otherwise +# ship them to the os-tests +# jobs, which run prebuilt test binaries outside a cargo target dir and so +# can't run pty-based tests (see the interactive/version test skips there). + +# Generates the config schema. Lives here so that xtask doesn't have to +# depend on brush-shell; `cargo xtask gen schema` shells out to it. +[[example]] +name = "gen" +path = "examples/gen.rs" +required-features = ["schema"] + +[[bench]] +name = "shell" +path = "benches/shell.rs" +harness = false + +[features] +# Input backends. Each can be enabled for any target, and is offered where it +# works (see `build.rs`), so the defaults suit every target. +default = ["basic", "reedline", "minimal", "vt"] +basic = ["brush-interactive/basic"] +minimal = ["brush-interactive/minimal"] +reedline = ["brush-interactive/reedline"] +vt = ["brush-interactive/vt"] +experimental = [ + "experimental-builtins", + "experimental-bundled-coreutils", + "experimental-load", + "experimental-parser", +] +experimental-builtins = ["dep:brush-experimental-builtins"] +experimental-bundled-coreutils = [ + "dep:brush-coreutils-builtins", + "brush-coreutils-builtins/coreutils.all", +] +experimental-load = ["dep:serde_json", "brush-core/serde"] +experimental-parser = [ + "brush-core/experimental-parser", + "brush-parser/winnow-parser", +] +schema = ["dep:schemars"] + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +brush-winnow-args-adapter = { version = "^0.1.0", path = "../brush-winnow-args-adapter" } +brush-builtins = { version = "^0.2.0", path = "../brush-builtins" } +brush-interactive = { version = "^0.4.0", path = "../brush-interactive" } +brush-coreutils-builtins = { version = "^0.1.0", path = "../brush-coreutils-builtins", optional = true } +brush-experimental-builtins = { version = "^0.1.0", path = "../brush-experimental-builtins", optional = true } +color-print = "0.3.7" +etcetera = "0.11.0" +schemars = { version = "1.2.1", optional = true } +serde = { version = "1.0", features = ["derive"] } +toml = "1.1.0" +const_format = "0.2.36" +serde_json = { version = "1.0.149", optional = true } +thiserror = "2.0.18" +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +human-panic = "2.0.6" +winnow-args = { version = "0.6.0", default-features = false, features = ["derive", "help-text"] } + +[target.'cfg(not(any(unix, windows)))'.dependencies] +tokio = { version = "1.52.3", features = ["rt", "sync"] } + +[target.'cfg(any(unix, windows))'.dependencies] +crossterm = "0.29.0" +tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "sync"] } + +[target.wasm32-unknown-unknown.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } +uuid = { version = "1", features = ["js"] } + +[dev-dependencies] +anyhow = "1.0.102" +# Used by the tests. +clap = { version = "4.6.0", features = ["derive", "env"] } +tempfile = "3.27.0" +# Also a regular optional dependency, enabled by `experimental-load`. The `gen` +# example needs it without that feature, so it's listed here too. +serde_json = "1.0.149" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +brush-test-harness = { path = "../brush-test-harness", features = ["insta"] } +assert_cmd = "2.2.0" +assert_fs = "1.1.3" +colored = "3.1.1" +criterion = { version = "0.8.2", features = ["async_tokio", "html_reports"] } +descape = "3.0.0" +diff = "0.1.13" +glob = "0.3.3" +indent = "0.1.1" +indexmap = "2.13.0" +junit-report = "0.9.0" +os-release = "0.1.0" +predicates = "3.1.4" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +yaml_serde = "0.10.7" +strip-ansi-escapes = "0.2.1" +test-with = { version = "0.17.0", default-features = false, features = [ + "executable", +] } +tokio = { version = "1.52.3", features = ["time"] } +version-compare = "0.2.1" +walkdir = "2.5.0" +which = "8.0.2" + +[target.'cfg(unix)'.dev-dependencies] +nix = { version = "0.31.2" } + +# expectrl's pty support (via ptyprocess) is limited to these platforms; +# the interactive tests that rely on it are gated accordingly. +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))'.dev-dependencies] +expectrl = "0.9.0" diff --git a/userland/brush/source/brush-shell/LICENSE b/userland/brush/source/brush-shell/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-shell/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-shell/about.hbs b/userland/brush/source/brush-shell/about.hbs new file mode 100644 index 0000000..f9da064 --- /dev/null +++ b/userland/brush/source/brush-shell/about.hbs @@ -0,0 +1,70 @@ + + + + + + + +

+
+

Third Party Licenses

+

This page lists the licenses of the projects used in brush-shell.

+
+ +

Overview of licenses:

+
    + {{#each overview}} +
  • {{name}} ({{count}})
  • + {{/each}} +
+ +

All license text:

+ +
+ + + diff --git a/userland/brush/source/brush-shell/about.toml b/userland/brush/source/brush-shell/about.toml new file mode 100644 index 0000000..2afeb4a --- /dev/null +++ b/userland/brush/source/brush-shell/about.toml @@ -0,0 +1,17 @@ +ignore-build-dependencies = true +ignore-dev-dependencies = true + +accepted = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "CDDL-1.0", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "WTFPL", + "Zlib", +] diff --git a/userland/brush/source/brush-shell/benches/shell.rs b/userland/brush/source/brush-shell/benches/shell.rs new file mode 100644 index 0000000..4352b6e --- /dev/null +++ b/userland/brush/source/brush-shell/benches/shell.rs @@ -0,0 +1,174 @@ +//! Benchmarks for the brush-shell crate. + +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +#[cfg(unix)] +mod unix { + use brush_builtins::ShellBuilderExt; + use brush_parser::SourceSpan; + use criterion::Criterion; + use std::hint::black_box; + + async fn instantiate_shell() -> brush_core::Shell { + brush_core::Shell::builder() + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await + .unwrap() + } + + async fn instantiate_shell_with_init_scripts() -> brush_core::Shell { + brush_core::Shell::builder() + .interactive(true) + .read_commands_from_stdin(true) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await + .unwrap() + } + + async fn run_one_command(shell: &mut brush_core::Shell, command: &str) { + let _ = shell + .run_string( + command.to_owned(), + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await + .unwrap(); + } + + async fn expand_string(shell: &mut brush_core::Shell, s: &str) { + let params = shell.default_exec_params(); + let _ = shell.basic_expand_string(¶ms, s).await.unwrap(); + } + + fn eval_arithmetic_expr(shell: &mut brush_core::Shell, expr: &str) { + let parsed_expr = brush_parser::arithmetic::parse(expr).unwrap(); + let _ = shell.eval_arithmetic(&parsed_expr).unwrap(); + } + + /// This function defines core shell benchmarks. + pub(crate) fn criterion_benchmark(c: &mut Criterion) { + // Construct a runtime for us to run async code on. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + // Benchmark shell instantiation. + c.bench_function("instantiate_shell", |b| { + b.to_async(&rt).iter(|| black_box(instantiate_shell())); + }); + c.bench_function("instantiate_shell_with_init_scripts", |b| { + b.to_async(&rt) + .iter(|| black_box(instantiate_shell_with_init_scripts())); + }); + + // Benchmark: cloning a shell object. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("clone_shell_object", |b| { + b.iter(|| black_box(shell.clone())); + }); + + // Benchmark: parsing and evaluating an arithmetic expression.. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("eval_arithmetic", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| eval_arithmetic_expr(s, "3 + 10 * 2"), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: running the echo built-in command. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("run_echo_builtin_command", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| rt.block_on(run_one_command(s, "echo 'Hello, world!' >/dev/null")), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: running an external command. + // let shell = rt.block_on(instantiate_shell()); + // c.bench_function("run_one_external_command", |b| { + // b.iter_batched_ref( + // || shell.clone(), + // |s| { + // rt.block_on(run_one_command( + // s, + // "/usr/bin/echo 'Hello, world!' >/dev/null", + // )); + // }, + // criterion::BatchSize::SmallInput, + // ); + // }); + + // Benchmark: word expansion. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("expand_one_string", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| rt.block_on(expand_string(s, "My version is ${BASH_VERSINFO[@]}")), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: function invocation. + let mut shell = rt.block_on(instantiate_shell()); + shell.define_func( + String::from("testfunc"), + brush_parser::ast::FunctionDefinition { + fname: String::from("testfunc").into(), + body: brush_parser::ast::FunctionBody( + brush_parser::ast::CompoundCommand::BraceGroup( + brush_parser::ast::BraceGroupCommand { + list: brush_parser::ast::CompoundList(vec![]), + loc: SourceSpan::default(), + }, + ), + None, + ), + }, + &brush_core::SourceInfo::default(), + ); + c.bench_function("function_call", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| { + rt.block_on(run_one_command(s, "testfunc")); + }, + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: for loop. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("for_loop", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| { + rt.block_on(run_one_command(s, "for ((i = 0; i < 10; i++)); do :; done")); + }, + criterion::BatchSize::SmallInput, + ); + }); + } +} + +#[cfg(unix)] +criterion::criterion_group! { + name = benches; + config = criterion::Criterion::default() + .measurement_time(std::time::Duration::from_secs(10)); + targets = unix::criterion_benchmark +} + +#[cfg(unix)] +criterion::criterion_main!(benches); + +#[cfg(not(unix))] +fn main() {} diff --git a/userland/brush/source/brush-shell/build.rs b/userland/brush/source/brush-shell/build.rs new file mode 100644 index 0000000..d7a36b4 --- /dev/null +++ b/userland/brush/source/brush-shell/build.rs @@ -0,0 +1,33 @@ +//! Defines a cfg for each input backend this build of the shell offers on the +//! target it's built for: `reedline_backend`, `vt_backend`, `basic_backend`, and +//! `minimal_backend`. A backend's feature can be enabled for any target; it's +//! offered only where it works. + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + + let families = std::env::var("CARGO_CFG_TARGET_FAMILY").unwrap_or_default(); + let unix = families.split(',').any(|family| family == "unix"); + let windows = families.split(',').any(|family| family == "windows"); + let feature = |name: &str| std::env::var_os(format!("CARGO_FEATURE_{name}")).is_some(); + + let backends = [ + // reedline draws through crossterm, which builds only for Unix and Windows + // (as brush-interactive's own build script also decides). + ("reedline_backend", feature("REEDLINE") && (unix || windows)), + // The shell's VT and basic backends edit on the process's own terminal + // (`StdTerminal`), which can switch it to raw mode only on Unix: the + // Windows console sends keys as bytes only in a mode brush doesn't set, + // and WASI has no API for raw mode. (brush-interactive's `VtInputBackend` + // itself builds for every target, for hosts that supply a `Terminal`.) + ("vt_backend", feature("VT") && unix), + ("basic_backend", feature("BASIC") && unix), + ("minimal_backend", feature("MINIMAL")), + ]; + for (cfg, offered) in backends { + println!("cargo::rustc-check-cfg=cfg({cfg})"); + if offered { + println!("cargo::rustc-cfg={cfg}"); + } + } +} diff --git a/userland/brush/source/brush-shell/examples/gen.rs b/userland/brush/source/brush-shell/examples/gen.rs new file mode 100644 index 0000000..dd86545 --- /dev/null +++ b/userland/brush/source/brush-shell/examples/gen.rs @@ -0,0 +1,18 @@ +//! Generates the config file's JSON schema, which needs `brush-shell` linked: +//! `gen OUTPUT_PATH`. +//! +//! This lives here, rather than in `xtask`, so that the build tooling doesn't +//! need to depend on the shell it builds. It's driven by +//! `cargo xtask gen schema config`. + +use anyhow::{Context, Result}; + +fn main() -> Result<()> { + let output_path = std::env::args_os() + .nth(1) + .context("usage: gen OUTPUT_PATH")?; + let schema = schemars::schema_for!(brush_shell::config::Config); + let json = serde_json::to_string_pretty(&schema)?; + std::fs::write(&output_path, format!("{json}\n"))?; + Ok(()) +} diff --git a/userland/brush/source/brush-shell/src/args.rs b/userland/brush/source/brush-shell/src/args.rs new file mode 100644 index 0000000..ed65a57 --- /dev/null +++ b/userland/brush/source/brush-shell/src/args.rs @@ -0,0 +1,466 @@ +//! Types for brush command-line parsing. + +use std::io::IsTerminal; +use std::path::PathBuf; + +use crate::{events, productinfo}; + +pub(crate) const VERSION: &str = const_format::concatcp!( + productinfo::PRODUCT_VERSION, + " (", + productinfo::PRODUCT_GIT_VERSION, + ")" +); + +/// Identifies the input backend to use for the shell. +#[derive(Clone, Copy, Debug, PartialEq, Eq, winnow_args::ValueEnum)] +#[arg(rename_all = "lowercase")] +pub enum InputBackendType { + /// Richest input backend, based on reedline. Not available on WASI or other + /// wasm targets. + Reedline, + /// The `vt` backend's line editing, drawn with backspaces alone and listing + /// ambiguous completions straight away: output that test harnesses can follow. + /// Available only on Unix. + Basic, + /// Most minimal input backend. + Minimal, + /// Line editing with completion and history, drawn with VT100-style escape + /// sequences; needs no terminal library. Available only on Unix. + Vt, +} + +/// Parsed command-line arguments for the brush shell. +#[derive(Clone, winnow_args::Args)] +#[arg( + name = "brush", + about = "Bo[u]rn[e] RUsty SHell 🦀 (https://brush.sh)", + long_about = r"brush is a bash-compatible, Rust-implemented, POSIX-style shell. + +brush is distributed under the terms of the MIT license. If you encounter any issues or discrepancies in behavior from bash, please report them at https://github.com/reubeno/brush. + +For more information, visit https://brush.sh.", + usage = "brush [OPTIONS]... [SCRIPT_PATH [SCRIPT_ARGS]...] +brush [OPTIONS]... -c COMMAND_STRING [NAME [ARGS]...]", + plus_options, + disable_help_short, + brief_help, + disable_version_flag, + disable_help_subcommand +)] +pub struct CommandLineArgs { + /// Display shell version. + #[arg(long = "version")] + pub version: bool, + + /// Path to TOML-based `brush` config file (overrides default location). + #[arg( + long = "config", + value_name = "FILE", + help_heading = "Configuration options" + )] + pub config_file: Option, + + /// Disable loading of TOML-based `brush` config file. + #[arg(long = "no-config", help_heading = "Configuration options")] + pub no_config: bool, + + /// Enable `noclobber` shell option. + #[arg(short = 'C', help_heading = "Standard shell options")] + pub disallow_overwriting_regular_files_via_output_redirection: bool, + + /// Execute the command given as the first operand and then exit. + // Only an input to parsing: `parse_shell_args` moves the command string into + // `command`, which is what everything else consults. + #[arg(short = 'c', help_heading = "Standard shell options")] + pub(crate) command_string_mode: bool, + + /// The command string to run, taken from the first operand when `-c` is + /// given. Not an argument itself (hence `skip`): bash parses options first + /// and only then takes the command from the first operand, so options may + /// sit between the two, as in `bash -c -l 'echo hi'`. + #[arg(skip)] + pub command: Option, + + /// Enable error-on-exit behavior. + #[arg(short = 'e', help_heading = "Standard shell options")] + pub exit_on_nonzero_command_exit: bool, + + /// Disable pathname expansion (also known as filename globbing). + #[arg(short = 'f', help_heading = "Standard shell options")] + pub disable_pathname_expansion: bool, + + /// Run in interactive mode. + #[arg(short = 'i', help_heading = "Standard shell options")] + pub interactive: bool, + + /// Inherit the specified file descriptors injected by the parent process. + #[arg( + long = "inherit-fd", + value_name = "FD", + help_heading = "Standard shell options" + )] + pub inherited_fds: Vec, + + /// Make shell act as if it had been invoked as a login shell. + #[arg(short = 'l', long = "login", help_heading = "Standard shell options")] + pub login: bool, + + /// Do not execute commands. + #[arg(short = 'n', help_heading = "Standard shell options")] + pub do_not_execute_commands: bool, + + /// Don't use readline for input. + #[arg(long = "noediting", help_heading = "Standard shell options")] + pub no_editing: bool, + + /// Don't process any profile/login files (`/etc/profile`, `~/.bash_profile`, `~/.bash_login`, + /// `~/.profile`). + #[arg(long = "noprofile", help_heading = "Standard shell options")] + pub no_profile: bool, + + /// Don't process "rc" files if the shell is interactive (e.g., `~/.bashrc`, `~/.brushrc`). + #[arg(long = "norc", help_heading = "Standard shell options")] + pub no_rc: bool, + + /// Don't inherit environment variables from the calling process. + #[arg(long = "noenv", help_heading = "Standard shell options")] + pub do_not_inherit_env: bool, + + /// Enable option (`set -o` option). + #[arg( + short = 'o', + value_name = "OPTION", + help_heading = "Standard shell options" + )] + pub enabled_options: Vec, + + /// Disable option (`set -o` option). + #[arg( + plus = 'o', + value_name = "OPTION", + hide, + help_heading = "Standard shell options" + )] + pub disabled_options: Vec, + + /// Enable `shopt` option. + #[arg( + short = 'O', + value_name = "SHOPT_OPTION", + help_heading = "Standard shell options" + )] + pub enabled_shopt_options: Vec, + + /// Disable `shopt` option. + #[arg( + plus = 'O', + value_name = "SHOPT_OPTION", + hide, + help_heading = "Standard shell options" + )] + pub disabled_shopt_options: Vec, + + /// Disable non-POSIX extensions. + #[arg(long = "posix", help_heading = "Standard shell options")] + pub posix: bool, + + /// Path to the rc file to load in interactive shells (instead of `bash.bashrc` and + /// `~/.bashrc`). + #[arg( + long = "rcfile", + alias = "init-file", + value_name = "FILE", + help_heading = "Standard shell options" + )] + pub rc_file: Option, + + /// Read commands from standard input. + #[arg(short = 's', help_heading = "Standard shell options")] + pub read_commands_from_stdin: bool, + + /// Run in `sh` compatibility mode, as if run as `/bin/sh`. + #[arg(long = "sh")] + pub sh_mode: bool, + + /// Run only one command and then exit. + #[arg(short = 't', help_heading = "Standard shell options")] + pub exit_after_one_command: bool, + + /// Treat expansion of an unset variable as an error. + #[arg(short = 'u', help_heading = "Standard shell options")] + pub treat_unset_variables_as_error: bool, + + /// Print input when it's processed. + #[arg(short = 'v', long = "verbose", help_heading = "Standard shell options")] + pub verbose: bool, + + /// Print commands as they execute. + #[arg(short = 'x', help_heading = "Standard shell options")] + pub print_commands_and_arguments: bool, + + /// Enable xtrace and configure for the given output file. + #[arg( + long = "xtrace-file", + value_name = "FILE", + help_heading = "User interface options" + )] + pub xtrace_file_path: Option, + + /// Disable bracketed paste. + #[arg( + long = "disable-bracketed-paste", + help_heading = "User interface options" + )] + pub disable_bracketed_paste: bool, + + /// Disable colorized output. + #[arg(long = "disable-color", help_heading = "User interface options")] + pub disable_color: bool, + + /// Enable syntax highlighting in input (always on in `experimental` builds). + #[arg(long = "enable-highlighting", help_heading = "User interface options")] + pub enable_highlighting: bool, + + /// Enable experimental parser (not ready for use). + #[cfg(feature = "experimental-parser")] + #[arg( + long = "experimental-parser", + help_heading = "*Experimental* options (unstable)" + )] + pub experimental_parser: bool, + + /// Enable terminal integration (**experimental**). + #[arg( + long = "enable-terminal-integration", + help_heading = "*Experimental* options (unstable)" + )] + pub terminal_shell_integration: bool, + + /// Enable zsh-style preexec/precmd hooks (**experimental**). + #[arg( + long = "enable-zsh-hooks", + help_heading = "*Experimental* options (unstable)" + )] + pub zsh_style_hooks: bool, + + /// Input backend. + #[arg( + long = "input-backend", + value_name = "BACKEND", + help_heading = "User interface options" + )] + pub input_backend: Option, + + /// Load state from the given file; the saved state should be in JSON format + /// and overrides any non-UI command-line options provided. + #[cfg(feature = "experimental-load")] + #[arg( + long = "load", + value_name = "FILE", + help_heading = "*Experimental* options (unstable)" + )] + pub load_file: Option, + + /// Enable debug logging for classes of tracing events. + #[arg( + long = "debug", + alias = "log-enable", + value_name = "EVENT", + help_heading = "User interface options" + )] + pub enabled_debug_events: Vec, + + /// Disable logging for classes of tracing events (takes same event types as `--debug`). + #[arg( + long = "disable-event", + alias = "log-disable", + value_name = "EVENT", + hide_choices, + help_heading = "User interface options" + )] + pub disabled_events: Vec, + + /// Path and arguments for script to execute (optional). + #[arg(positional, stop_flags, value_name = "SCRIPT_PATH [SCRIPT_ARGS]...")] + pub script_args: Vec, +} + +impl CommandLineArgs { + /// Returns a `CommandLineArgs` with all of its declared default values. + /// + /// This is useful for detecting which CLI arguments were explicitly provided + /// vs. which retained their default values (e.g., for config file merging). + #[must_use] + #[allow( + clippy::missing_panics_doc, + reason = "parsing defaults should not panic" + )] + pub fn default_values() -> Self { + // Parse with just the program name to get all defaults. + // This won't fail because all arguments have defaults or are optional. + #[allow(clippy::expect_used)] + Self::parse_shell_args([String::from("brush")]).expect("parsing defaults should never fail") + } + + /// Returns whether the shell will read its commands from standard input, as opposed to + /// running a command given with `-c` or a script named on the command line. + /// + /// This is the single source of truth for that question; it decides which shell mode + /// `entry::run_in_shell` enters, whether `$-` reports `s`, whether the shell can be + /// interactive, and which input backend gets selected. + pub const fn will_read_commands_from_stdin(&self) -> bool { + if self.command.is_some() { + // -c supplies the command. + false + } else if self.read_commands_from_stdin { + // -s makes any non-option arguments positional parameters, not a script to run. + true + } else { + // Otherwise the first non-option argument, if any, names a script to run. + self.script_args.is_empty() + } + } + + /// Returns whether or not the arguments indicate that the shell should run in interactive mode. + pub fn is_interactive(&self) -> bool { + // If -i is provided, then that overrides any further consideration; it forces + // interactive mode. + if self.interactive { + return true; + } + + // Running a -c command or a named script is not interactive. + if !self.will_read_commands_from_stdin() { + return false; + } + + // If *either* stdin or stderr is not a terminal, then we're not in interactive mode. + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return false; + } + + // In all other cases, we assume interactive mode. + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + let args = CommandLineArgs::default_values(); + // Verify some basic defaults + assert!(!args.interactive); + assert!(!args.login); + assert!(args.command.is_none()); + assert_eq!(args.script_args, Vec::::new()); + } + + #[test] + #[allow(clippy::expect_used)] + fn test_will_read_commands_from_stdin() { + // (arguments, whether commands will be read from stdin) + let cases = [ + (vec![], true), + (vec!["-i"], true), + (vec!["-c", "echo hi"], false), + (vec!["-c", "echo hi", "name"], false), + // `-i` forces an interactive shell, but `-c` still supplies the commands. + (vec!["-i", "-c", "echo hi"], false), + // `-c` is a plain flag; the command string is the first non-option argument. + (vec!["-cl", "echo hi"], false), + (vec!["-c", "-l", "echo hi"], false), + (vec!["script.sh"], false), + (vec!["-i", "script.sh"], false), + // `-s` claims the script slot, so trailing words are positional parameters. + (vec!["-s"], true), + (vec!["-s", "myarg"], true), + (vec!["-i", "-s", "myarg"], true), + (vec!["-si", "myarg"], true), + // `--` in option position is consumed as the end-of-options marker. + (vec!["--", "script.sh"], false), + (vec!["-s", "--", "myarg"], true), + ]; + + for (args, expected) in cases { + // NOTE: This deliberately goes through `parse_shell_args` and not the derived + // `parse_words`; only the former applies brush's `--` and `-c` handling, so only + // the former sees what the shell sees. + let parsed = CommandLineArgs::parse_shell_args( + std::iter::once("brush") + .chain(args.iter().copied()) + .map(String::from), + ) + .expect("arguments should parse"); + assert_eq!( + parsed.will_read_commands_from_stdin(), + expected, + "for arguments: {args:?}" + ); + } + } + + /// `cargo xtask gen completion` writes the script from the command's name + /// alone, without linking brush. + #[test] + fn completion_scripts_need_only_the_name() { + use winnow_args::Args as _; + use winnow_args::complete::{Shell, script}; + for &shell in Shell::ALL { + assert_eq!( + CommandLineArgs::completion_script(shell), + script("brush", shell), + "{shell:?}" + ); + } + } + + /// Each script asks brush itself for its answers. + #[test] + fn completion_scripts_call_back_into_brush() { + use winnow_args::Args as _; + use winnow_args::complete::Shell; + for &shell in Shell::ALL { + assert!( + CommandLineArgs::completion_script(shell).contains("brush __complete_word__"), + "{shell:?}" + ); + } + } + + #[test] + fn help_lists_every_flag() { + use winnow_args::Args as _; + let data = CommandLineArgs::help_data(); + let help = winnow_args::help::render(data, &[], false); + let flags: Vec<_> = data + .items + .iter() + .filter(|item| !item.hide && !item.positional) + .collect(); + assert!(flags.len() > 30, "only {} flags", flags.len()); + // Each flag starts a row of its own, as `-l, --login` or `--norc`. + let has_row = |names: &str| { + help.lines().any(|line| { + line.trim_start() + .strip_prefix(names) + .is_some_and(|rest| rest.is_empty() || rest.starts_with(' ')) + }) + }; + for flag in flags { + let names: Vec<_> = [ + flag.short.map(|short| format!("-{short}")), + flag.long.map(|long| format!("--{long}")), + ] + .into_iter() + .flatten() + .collect(); + let names = names.join(", "); + assert!(has_row(&names), "no `{names}` row in:\n{help}"); + } + assert!(has_row("--help"), "no `--help` row in:\n{help}"); + } +} diff --git a/userland/brush/source/brush-shell/src/brushctl.rs b/userland/brush/source/brush-shell/src/brushctl.rs new file mode 100644 index 0000000..df5d540 --- /dev/null +++ b/userland/brush/source/brush-shell/src/brushctl.rs @@ -0,0 +1,263 @@ +use brush_core::{ExecutionResult, sys}; +use std::io::Write; + +use crate::events; + +/// Extension trait for adding brush-specific built-in commands to a shell builder. +pub(crate) trait ShellBuilderBrushBuiltinExt { + /// Add brush-specific builtins to a shell being built. + #[must_use] + fn brush_builtins(self) -> Self; +} + +impl + ShellBuilderBrushBuiltinExt for brush_core::ShellBuilder +{ + fn brush_builtins(self) -> Self { + // For compatibility with previous releases, we register the command under both + // `brushctl` and `brushinfo` names. It will behave identically across the two. + self.builtin( + "brushctl", + brush_core::builtins::Registration::of::(), + ) + .builtin( + "brushinfo", + brush_core::builtins::Registration::of::(), + ) + } +} + +/// Configure the running brush shell. +#[derive(winnow_args::Args)] +#[arg(name = "brushctl", arg_required_else_help)] +pub(crate) struct BrushCtlCommand { + #[arg(subcommand)] + command_group: CommandGroup, +} + +#[derive(winnow_args::Subcommand)] +enum CommandGroup { + /// Commands for generating completions. + #[arg(arg_required_else_help)] + Complete { + #[arg(subcommand)] + command: CompleteCommand, + }, + /// Commands for inspecting call state. + #[arg(arg_required_else_help)] + Call { + #[arg(subcommand)] + command: CallCommand, + }, + /// Commands for configuring tracing events. + #[arg(arg_required_else_help)] + Events { + #[arg(subcommand)] + command: EventsCommand, + }, + /// Commands for inspecting process state. + #[arg(arg_required_else_help)] + Process { + #[arg(subcommand)] + command: ProcessCommand, + }, +} + +/// Commands for inspecting call state. +#[derive(winnow_args::Subcommand)] +enum CallCommand { + /// Display the current call stack. + #[arg(name = "stack")] + ShowCallStack { + /// Whether to show more details. + #[arg(short = 'd', long = "detailed")] + detailed: bool, + }, +} + +/// Commands for generating completions. +#[derive(winnow_args::Subcommand)] +enum CompleteCommand { + /// Generate completions for an input line. + #[arg(name = "line")] + Line { + /// The 0-indexed cursor position for generation. + #[arg(long = "cursor", short = 'c')] + cursor_index: Option, + + /// The input line to generate completions for. + #[arg(positional)] + line: String, + }, +} + +/// Commands for configuring tracing events. +#[derive(winnow_args::Subcommand)] +enum EventsCommand { + /// Display status of enabled events. + Status, + + /// Enable event. + Enable { + /// Event to enable. + #[arg(positional)] + event: events::TraceEvent, + }, + + /// Disable event. + Disable { + /// Event to disable. + #[arg(positional)] + event: events::TraceEvent, + }, +} + +/// Commands for inspecting process state. +#[expect(clippy::enum_variant_names)] +#[derive(winnow_args::Subcommand)] +enum ProcessCommand { + /// Display process ID. + #[arg(name = "pid")] + ShowProcessId, + /// Display process group ID. + #[arg(name = "pgid")] + ShowProcessGroupId, + /// Display foreground process ID. + #[arg(name = "fgpid")] + ShowForegroundProcessId, + /// Display parent process ID. + #[arg(name = "ppid")] + ShowParentProcessId, +} + +brush_winnow_args_adapter::impl_parse_args!(BrushCtlCommand); + +impl brush_core::builtins::Builtin for BrushCtlCommand { + type Error = brush_core::Error; + + async fn execute( + self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + match &self.command_group { + CommandGroup::Call { command } => command.execute(&context), + CommandGroup::Complete { command } => command.execute(&mut context).await, + CommandGroup::Events { command } => command.execute(&context), + CommandGroup::Process { command } => command.execute(&context), + } + } +} + +impl CallCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::ShowCallStack { detailed } => { + let stack = context.shell.call_stack(); + let format_options = brush_core::callstack::FormatOptions { + show_args: *detailed, + show_entry_points: *detailed, + }; + + write!(context.stdout(), "{}", stack.format(&format_options))?; + + Ok(ExecutionResult::success()) + } + } + } +} + +impl CompleteCommand { + async fn execute( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::Line { cursor_index, line } => { + let completions = context + .shell + .complete(line, cursor_index.unwrap_or(line.len())) + .await?; + for candidate in completions.candidates { + writeln!(context.stdout(), "{candidate}")?; + } + Ok(ExecutionResult::success()) + } + } + } +} + +impl EventsCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let event_config = crate::entry::get_event_config(); + + let mut event_config = event_config.try_lock().map_err(|_| { + brush_core::Error::from(brush_core::ErrorKind::Unimplemented( + "Failed to acquire lock on event configuration", + )) + })?; + + if let Some(event_config) = event_config.as_mut() { + match self { + Self::Status => { + let enabled_events = event_config.get_enabled_events(); + for event in enabled_events { + writeln!(context.stdout(), "{event}")?; + } + } + Self::Enable { event } => event_config.enable(*event)?, + Self::Disable { event } => event_config.disable(*event)?, + } + + Ok(brush_core::ExecutionResult::success()) + } else { + Err(brush_core::ErrorKind::Unimplemented("event configuration not initialized").into()) + } + } +} + +impl ProcessCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::ShowProcessId => { + writeln!(context.stdout(), "{}", std::process::id())?; + Ok(ExecutionResult::success()) + } + Self::ShowProcessGroupId => { + if let Some(pgid) = sys::terminal::get_process_group_id() { + writeln!(context.stdout(), "{pgid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get process group ID")?; + Ok(ExecutionResult::general_error()) + } + } + Self::ShowForegroundProcessId => { + if let Some(pid) = sys::terminal::get_foreground_pid() { + writeln!(context.stdout(), "{pid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get foreground process ID")?; + Ok(ExecutionResult::general_error()) + } + } + Self::ShowParentProcessId => { + if let Some(pid) = sys::terminal::get_parent_process_id() { + writeln!(context.stdout(), "{pid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get parent process ID")?; + Ok(ExecutionResult::general_error()) + } + } + } + } +} diff --git a/userland/brush/source/brush-shell/src/bundled.rs b/userland/brush/source/brush-shell/src/bundled.rs new file mode 100644 index 0000000..de6324f --- /dev/null +++ b/userland/brush/source/brush-shell/src/bundled.rs @@ -0,0 +1,289 @@ +//! Bundled commands: utilities that ship inside the brush binary. +//! +//! Utilities are shipped busybox-style (one binary, many names) but execute +//! as a subprocess of brush so that shell redirections, pipes, and +//! process-group state are honored by code that reads/writes the host +//! process's standard fds (e.g., uutils crates). +//! +//! ## Protocol +//! +//! The brush binary recognizes a hidden first-position argument +//! [`DISPATCH_FLAG`] followed by ` [ARGS...]`. When present, brush +//! dispatches early in `main()` to the registered function for `NAME`, before +//! any shell state is built, and exits with the function's return code. The +//! dispatched function has the same signature as `uutils`' `uumain`: +//! `fn(Vec) -> i32`, with the bundled name as `argv[0]`. +//! +//! ## Shell integration +//! +//! For every entry in the registry, [`register_shims`] installs a brush +//! builtin (using `builtins::Registry::register_if_absent`, so brush's own +//! builtins always win on conflict). The builtin's execution path uses brush-core's existing +//! external-command machinery to spawn `current_exe() +//! `, inheriting the shell's redirection state for free. +//! +//! The mechanism is generic — the registry is just `name → fn pointer`. The +//! `experimental-bundled-coreutils` feature populates it with uutils, but +//! anything matching the signature can be registered. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; +use std::sync::OnceLock; + +use brush_core::ExecutionExitCode; +use brush_core::builtins::{ + Builtin, ContentOptions, ContentType, Invocation, ParseArgs, Registration, UsageError, +}; +use brush_core::commands::{self, CommandArg, ExecutionContext}; +use brush_core::extensions::ShellExtensions; + +/// The leading flag that signals a bundled-command dispatch. +/// +/// Deliberately obscure so that it's unlikely to collide with future +/// first-class shell flags or with scripts that happen to contain the +/// literal token. +pub const DISPATCH_FLAG: &str = "--invoke-bundled"; + +/// Signature of a bundled command's entry point — matches `uu_*::uumain`. +pub type BundledFn = fn(args: Vec) -> i32; + +/// Process-wide registry. Set once at startup, read on each shim invocation +/// (and during bundled-dispatch fast path). +static REGISTRY: OnceLock> = OnceLock::new(); + +/// Cached path to the running brush executable. Populated lazily on first +/// shim invocation; left as `Err`-equivalent if `current_exe()` fails. +static SELF_EXE: OnceLock> = OnceLock::new(); + +/// Installs the bundled-command registry. Idempotent: only the first call +/// takes effect. +#[allow( + clippy::implicit_hasher, + reason = "registry uses the default hasher; callers build with HashMap::new()" +)] +pub fn install(commands: HashMap) { + let _ = REGISTRY.set(commands); +} + +/// Installs the registry from all compiled-in providers. +/// +/// Providers are controlled by Cargo features. Binaries should call this +/// once, before [`maybe_dispatch`], so both the dispatch fast path and the +/// shell's shim builtins see a populated registry. +pub fn install_default_providers() { + #[allow(unused_mut)] + let mut commands: HashMap = HashMap::new(); + + #[cfg(feature = "experimental-bundled-coreutils")] + commands.extend(brush_coreutils_builtins::bundled_commands()); + + install(commands); +} + +/// Returns the registered bundled commands, if [`install`] was called. +#[must_use] +pub fn registry() -> Option<&'static HashMap> { + REGISTRY.get() +} + +/// Runs the bundled-command fast path if the process was invoked for it. +/// +/// If the process was invoked as `brush [ARGS...]` +/// (with `` as the very first argument after `argv[0]`), runs +/// the registered function and returns its exit code as `Some(code)`. The +/// caller is responsible for exiting the process with that code — +/// centralizing the exit call in the binary's `main()` keeps destructors / +/// panic hooks / tracing guards in the loop. +/// +/// Returns `None` when the process was not invoked as a bundled dispatch, so +/// normal shell startup can proceed. +/// +/// The dispatch flag is only recognized in the leading position so that +/// ordinary scripts and command lines containing the literal token elsewhere +/// are not affected. +#[must_use] +pub fn maybe_dispatch() -> Option { + let mut raw = std::env::args_os(); + let _argv0 = raw.next(); + let first = raw.next()?; + if first != DISPATCH_FLAG { + return None; + } + + // Everything after `DISPATCH_FLAG` belongs to the bundled command. The + // first such argument is the command name; subsequent arguments form its + // argv (with the name itself supplied as argv[0] to match the convention + // `uutils` and most CLI tools expect). + let rest: Vec = raw.collect(); + let Some((name, args)) = rest.split_first() else { + eprintln!("brush: {DISPATCH_FLAG} requires a command name"); + return Some(exit_code(ExecutionExitCode::InvalidUsage)); + }; + + // The registry is keyed by UTF-8 `String`, so a non-UTF-8 name can never + // match. Reject up front rather than allocating a lossy-substituted + // lookup key that could accidentally collide with a real registration. + let Some(name_str) = name.to_str() else { + eprintln!("brush: unknown bundled command: {}", name.to_string_lossy()); + return Some(exit_code(ExecutionExitCode::NotFound)); + }; + + let Some(func) = REGISTRY.get().and_then(|r| r.get(name_str)) else { + eprintln!("brush: unknown bundled command: {name_str}"); + return Some(exit_code(ExecutionExitCode::NotFound)); + }; + + let mut argv: Vec = Vec::with_capacity(1 + args.len()); + argv.push(name.clone()); + argv.extend(args.iter().cloned()); + + Some(func(argv)) +} + +fn exit_code(code: ExecutionExitCode) -> i32 { + u8::from(code).into() +} + +/// Returns the path to the running brush executable (cached). +fn self_exe() -> Option<&'static PathBuf> { + SELF_EXE + .get_or_init(|| std::env::current_exe().ok()) + .as_ref() +} + +/// The builtin shared by all bundled commands. It parses nothing: its +/// arguments are passed to the bundled utility as they are. +struct BundledCommand { + args: Vec, +} + +impl ParseArgs for BundledCommand { + /// Help/usage content for the shim builtin. brush calls this for + /// `help `, `type `, etc. + fn content( + name: &str, + content_type: ContentType, + _options: &ContentOptions, + ) -> Result { + match content_type { + ContentType::ShortDescription => Ok(format!("{name} - bundled command")), + ContentType::DetailedHelp => Ok(format!( + "{name} - bundled command (executes via `brush {DISPATCH_FLAG} {name}`)\n" + )), + // A bundled command never contributes its own short-usage or man page + // through this path; detailed help comes from the bundled utility + // itself (`brush --help` or equivalent). + ContentType::ShortUsage | ContentType::ManPage => Ok(String::new()), + } + } + + fn parse(invocation: Invocation<'_>) -> Result { + Ok(Self { + args: invocation.into_args(), + }) + } +} + +impl Builtin for BundledCommand { + type Error = brush_core::Error; + + /// Runs a bundled command: looks up the invoked name from + /// `context.command_name` and re-executes the running brush binary as + /// `brush `. + /// + /// Reuses the same entry point the `command` builtin uses (see + /// `brush-builtins/src/command.rs`): constructs a [`commands::SimpleCommand`] + /// whose `command_name` is the absolute brush exe path. Because that contains + /// a path separator, `SimpleCommand::execute` routes directly to the + /// external-execution path, bypassing the builtin/function lookup that would + /// otherwise re-enter this very shim. + /// + /// `use_functions = false` is defensive: even though the path-separator + /// branch already skips function dispatch, we don't want a hypothetical + /// refactor of `SimpleCommand` to silently break us. + // + // TODO(bundled): Process-group propagation. + // The shim leaves `SimpleCommand::process_group_id` as `None`, so when a + // bundled command appears in a pipeline it doesn't join the pipeline's + // pgid — job control and pipeline-wide signal delivery misbehave. + // `ExecutionContext` doesn't currently carry the dispatcher's pgid, so + // fixing this requires plumbing the pgid through the builtin dispatch + // boundary (likely as a field on `ExecutionParameters` or a new + // `ExecutionContext` accessor). + // + // TODO(bundled): Pipeline serialization. + // The builtin contract returns an `ExecutionResult` (a completed command), + // not an `ExecutionSpawnResult` (a spawn handle), so this function has to + // `.await` the child to completion before returning. That's fine for a + // standalone bundled command or for the tail of a pipeline, but for a + // bundled stage in the middle of `a | b | c` it means stage N only + // "starts" (from brush's perspective) after its child has fully exited — + // downstream stages get no parallelism with it. Fixing this means + // bypassing the builtin API for bundled dispatch: either detect the shim + // inside `SimpleCommand::execute`'s dispatch table and return an + // `ExecutionSpawnResult::StartedProcess` directly (same shape as external + // dispatch), or generalize the builtin API so a builtin can return a + // spawn handle instead of a finished result. + async fn execute( + self, + context: ExecutionContext<'_, SE>, + ) -> Result { + let exe_path = if let Some(p) = self_exe() { + p.to_string_lossy().into_owned() + } else { + let _ = writeln!( + context.stderr(), + "brush: cannot determine path to running executable" + ); + return Ok(ExecutionExitCode::CannotExecute.into()); + }; + + // Build the argv for the spawned brush. `SimpleCommand::args[0]` is + // dropped by the external-execution path (argv[0] of the spawned + // process comes from `cmd.argv0` below), so a placeholder suffices. + // The builtin's arguments follow `DISPATCH_FLAG` and an explicit + // ``, so the child's dispatcher sees the name in a fixed slot. + let bundled_name = context.command_name.clone(); + let mut child_args: Vec = Vec::with_capacity(self.args.len() + 2); + child_args.push(CommandArg::String(String::new())); // args[0], dropped + child_args.push(CommandArg::String(DISPATCH_FLAG.into())); + child_args.push(CommandArg::String(bundled_name.clone())); + child_args.extend(self.args); + + let mut cmd = commands::SimpleCommand::new( + commands::ShellForCommand::ParentShell(context.shell), + context.params, + exe_path, + child_args, + ); + cmd.use_functions = false; + // Override the spawned process's argv[0] so tools that report errors + // via their own argv[0] (uutils' `uucore::util_name()` reads + // `std::env::args_os()[0]` into a LazyLock at first use) render as + // `:` rather than `brush:`. Without this the child sees the + // brush exe path as argv[0] and misattributes errors. + cmd.argv0 = Some(bundled_name); + + let spawn_result = cmd.execute().await?; + let wait_result = spawn_result.wait().await?; + Ok(wait_result.into()) + } +} + +/// Registers a shim builtin for every name in the installed bundled-command +/// registry. +/// +/// Uses `builtins::Registry::register_if_absent` so brush's own builtins (echo, printf, +/// true, false, etc.) win on conflict. +pub fn register_shims(shell: &mut brush_core::Shell) { + let Some(registry) = REGISTRY.get() else { + return; + }; + for name in registry.keys() { + shell + .builtins_mut() + .register_if_absent(name.clone(), Registration::of::()); + } +} diff --git a/userland/brush/source/brush-shell/src/config.rs b/userland/brush/source/brush-shell/src/config.rs new file mode 100644 index 0000000..778f38c --- /dev/null +++ b/userland/brush/source/brush-shell/src/config.rs @@ -0,0 +1,441 @@ +//! Configuration file support for the brush shell. +//! +//! This module provides TOML-based configuration file loading with the following features: +//! - Forward-compatible: unknown fields are ignored +//! - Graceful degradation: parse errors are logged but don't prevent shell startup +//! - Layered configuration: defaults < config file < command-line arguments + +use brush_interactive::UIOptions; +use etcetera::BaseStrategy; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use crate::args::CommandLineArgs; + +/// Root configuration structure for the brush shell. +/// +/// All fields are optional to support forward compatibility and partial configuration. +/// Unknown fields in the TOML file are silently ignored. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct Config { + /// User interface configuration options. + pub ui: UiConfig, + + /// Experimental features configuration. + pub experimental: ExperimentalConfig, +} + +/// User interface configuration options. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct UiConfig { + /// Enable syntax highlighting in the input line. + #[serde(rename = "syntax-highlighting")] + pub syntax_highlighting: Option, +} + +/// Experimental features configuration. +/// +/// These options control unstable features that may change or be removed in future versions. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct ExperimentalConfig { + /// Enable zsh-style preexec/precmd hooks. + #[serde(rename = "zsh-hooks")] + pub zsh_hooks: Option, + + /// Enable terminal shell integration. + #[serde(rename = "terminal-shell-integration")] + pub terminal_shell_integration: Option, +} + +impl Config { + /// Converts the configuration to [`UIOptions`], merging with CLI arguments. + /// + /// Settings are applied with the following priority (highest to lowest): + /// 1. CLI arguments (if explicitly set, i.e., different from default) + /// 2. Config file values + /// 3. Default values + /// + /// CLI defaults are inferred by parsing an empty command line. + /// + /// # Arguments + /// + /// * `args` - The parsed command-line arguments + #[must_use] + pub(crate) fn to_ui_options(&self, args: &CommandLineArgs) -> UIOptions { + // Get the defaults by parsing an empty argument list. + // This lets us detect which CLI values were explicitly set vs. defaulted. + let defaults = CommandLineArgs::default_values(); + + let enable_highlighting = merge_bool_setting( + args.enable_highlighting, + defaults.enable_highlighting, + self.ui.syntax_highlighting, + ); + let terminal_shell_integration = merge_bool_setting( + args.terminal_shell_integration, + defaults.terminal_shell_integration, + self.experimental.terminal_shell_integration, + ); + let zsh_style_hooks = merge_bool_setting( + args.zsh_style_hooks, + defaults.zsh_style_hooks, + self.experimental.zsh_hooks, + ); + + UIOptions::builder() + .disable_bracketed_paste(args.disable_bracketed_paste) + .disable_color(args.disable_color) + .disable_highlighting(!enable_highlighting) + .terminal_shell_integration(terminal_shell_integration) + .zsh_style_hooks(zsh_style_hooks) + .build() + } +} + +/// Merges a boolean setting from CLI args, config file, and defaults. +/// +/// Priority: CLI (if explicitly set) > config file > default. +/// +/// Since boolean CLI flags can't distinguish between "explicitly set to false" and +/// "not provided" (both result in `false`), we use a heuristic: +/// - If the CLI value differs from the default, the user explicitly provided it +/// - Otherwise, use the config value if present, or fall back to the default +const fn merge_bool_setting( + cli_value: bool, + cli_default: bool, + config_value: Option, +) -> bool { + if cli_value != cli_default { + // CLI was explicitly set to a non-default value + cli_value + } else if let Some(config) = config_value { + // Use config file value + config + } else { + // Fall back to default + cli_default + } +} + +/// Result of attempting to load a configuration file. +#[derive(Debug, Default)] +pub struct ConfigLoadResult { + /// The loaded configuration, or default if loading failed. + pub config: Config, + + /// The path that was used (or attempted) for loading. + pub path: Option, + + /// Any error that occurred during loading. + pub error: Option, + + /// Whether the path was explicitly provided by the user (via `--config`). + pub explicit_path: bool, +} + +impl ConfigLoadResult { + /// Consumes the result and returns the configuration. + /// + /// If an error occurred: + /// - For explicit paths (user-provided via `--config`): returns `Err` with a formatted error + /// - For default paths: logs a warning and returns the default configuration + /// + /// # Errors + /// + /// Returns an error if an explicit config path was provided and loading failed. + pub fn into_config_or_log(self) -> Result { + let Some(err) = self.error else { + return Ok(self.config); + }; + + let path_display = self.path.as_ref().map_or(Cow::Borrowed(""), |p| { + Cow::Owned(p.display().to_string()) + }); + + if self.explicit_path { + // User explicitly provided --config; treat errors as fatal. + return Err(format!("failed to load config from {path_display}: {err}")); + } + + // Default config path; log warning but continue with defaults. + tracing::warn!("failed to load config from {path_display}: {err}"); + Ok(self.config) + } +} + +/// Errors that can occur when loading configuration. +#[derive(Debug, thiserror::Error)] +pub enum ConfigLoadError { + /// Failed to read the configuration file. + #[error("failed to read config file: {0}")] + Io(#[from] std::io::Error), + + /// Failed to parse the TOML content. + #[error("failed to parse config file: {0}")] + Parse(#[from] toml::de::Error), +} + +const CONFIG_SUBDIR_NAME: &str = "brush"; +const CONFIG_FILE_NAME: &str = "config.toml"; + +/// Returns the default configuration file path for the current platform. +/// +/// Uses the XDG Base Directory specification on Linux/macOS and appropriate +/// platform conventions on other systems via the `etcetera` crate. +/// +/// Returns `None` if the platform's config directory cannot be determined. +pub fn default_config_path() -> Option { + let strategy = etcetera::choose_base_strategy().ok()?; + Some( + strategy + .config_dir() + .join(CONFIG_SUBDIR_NAME) + .join(CONFIG_FILE_NAME), + ) +} + +/// Loads configuration from the specified path. +/// +/// Returns a `ConfigLoadResult` containing: +/// - The parsed configuration (or default on error) +/// - The path that was used +/// - Any error that occurred +/// +/// Note: This function sets `explicit_path` to `false`. Use `load_config` for +/// proper handling of explicit vs. default paths. +pub fn load_from_path(path: &Path) -> ConfigLoadResult { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + return ConfigLoadResult { + path: Some(path.to_path_buf()), + error: Some(ConfigLoadError::Io(e)), + ..Default::default() + }; + } + }; + + match toml::from_str(&content) { + Ok(config) => ConfigLoadResult { + config, + path: Some(path.to_path_buf()), + ..Default::default() + }, + Err(e) => ConfigLoadResult { + path: Some(path.to_path_buf()), + error: Some(ConfigLoadError::Parse(e)), + ..Default::default() + }, + } +} + +/// Loads configuration based on the provided options. +/// +/// # Arguments +/// +/// * `disabled` - If true, skip loading and return defaults +/// * `explicit_path` - If provided, use this path instead of the default +/// +/// # Returns +/// +/// A `ConfigLoadResult` containing the configuration and any errors encountered. +/// If `explicit_path` is provided and loading fails, the result will have +/// `explicit_path: true` to indicate that the error should be treated as fatal. +pub fn load_config(disabled: bool, explicit_path: Option<&Path>) -> ConfigLoadResult { + if disabled { + return ConfigLoadResult::default(); + } + + let is_explicit = explicit_path.is_some(); + + let path = match explicit_path { + Some(p) => p.to_path_buf(), + None => match default_config_path() { + Some(p) => p, + None => { + // Can't determine config path; use defaults silently + return ConfigLoadResult::default(); + } + }, + }; + + // If using default path and file doesn't exist, silently use defaults + if !is_explicit && !path.exists() { + return ConfigLoadResult { + path: Some(path), + ..Default::default() + }; + } + + let mut result = load_from_path(&path); + result.explicit_path = is_explicit; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_config() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.ui.syntax_highlighting.is_none()); + assert!(config.experimental.zsh_hooks.is_none()); + assert!(config.experimental.terminal_shell_integration.is_none()); + } + + #[test] + fn full_config() { + let toml = r" + [ui] + syntax-highlighting = true + + [experimental] + zsh-hooks = true + terminal-shell-integration = false + "; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(true)); + assert_eq!(config.experimental.zsh_hooks, Some(true)); + assert_eq!(config.experimental.terminal_shell_integration, Some(false)); + } + + #[test] + fn partial_config() { + let toml = r" + [ui] + syntax-highlighting = false + "; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(false)); + assert!(config.experimental.zsh_hooks.is_none()); + } + + #[test] + fn unknown_fields_ignored() { + let toml = r#" + [ui] + syntax-highlighting = true + unknown-field = "should be ignored" + another-unknown = 42 + + [experimental] + zsh-hooks = false + future-feature = true + + [unknown-section] + foo = "bar" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(true)); + assert_eq!(config.experimental.zsh_hooks, Some(false)); + } + + #[test] + fn load_config_disabled() { + let result = load_config(true, None); + assert!(result.path.is_none()); + assert!(result.error.is_none()); + } + + #[test] + fn load_config_nonexistent_default() { + // When using default path and file doesn't exist, should return defaults without error + let result = load_config(false, None); + // We may or may not get a path depending on platform, but shouldn't error + assert!(result.error.is_none()); + } + + #[test] + fn load_config_nonexistent_explicit() { + let path = Path::new("/nonexistent/path/to/config.toml"); + let result = load_config(false, Some(path)); + assert!(result.error.is_some()); + assert!(matches!(result.error, Some(ConfigLoadError::Io(_)))); + } + + #[test] + fn to_ui_options_defaults_only() { + let config = Config::default(); + let args = CommandLineArgs::default_values(); + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_bracketed_paste); + assert!(!ui.disable_color); + // Note: whether highlighting is enabled by default depends on the compile-time + // DEFAULT_ENABLE_HIGHLIGHTING constant (true with reedline, false without) + assert!(!ui.terminal_shell_integration); + assert!(!ui.zsh_style_hooks); + } + + #[test] + fn to_ui_options_config_overrides_defaults() { + let toml = r" + [ui] + syntax-highlighting = true + + [experimental] + zsh-hooks = true + terminal-shell-integration = true + "; + let config: Config = toml::from_str(toml).unwrap(); + let args = CommandLineArgs::default_values(); + + // CLI values match defaults, so config should take effect + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_highlighting); // config enabled highlighting + assert!(ui.terminal_shell_integration); + assert!(ui.zsh_style_hooks); + } + + #[test] + fn to_ui_options_cli_overrides_config() { + let toml = r" + [ui] + syntax-highlighting = false + + [experimental] + zsh-hooks = false + "; + let config: Config = toml::from_str(toml).unwrap(); + + // Simulate CLI explicitly setting values different from defaults + // by parsing with the flags enabled + let args = CommandLineArgs::parse_shell_args( + ["brush", "--enable-highlighting", "--enable-zsh-hooks"].map(String::from), + ) + .unwrap(); + + // CLI explicitly enables highlighting and zsh-hooks (differs from default) + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_highlighting); // CLI enabled highlighting + assert!(ui.zsh_style_hooks); // CLI enabled + } + + #[test] + fn to_ui_options_cli_only_settings() { + let config = Config::default(); + let args = CommandLineArgs::parse_shell_args( + ["brush", "--disable-bracketed-paste", "--disable-color"].map(String::from), + ) + .unwrap(); + + let ui = config.to_ui_options(&args); + + assert!(ui.disable_bracketed_paste); + assert!(ui.disable_color); + } +} diff --git a/userland/brush/source/brush-shell/src/entry.rs b/userland/brush/source/brush-shell/src/entry.rs new file mode 100644 index 0000000..34ee7ee --- /dev/null +++ b/userland/brush/source/brush-shell/src/entry.rs @@ -0,0 +1,1100 @@ +//! Implements the command-line interface for the `brush` shell. + +use crate::args::CommandLineArgs; +use crate::args::InputBackendType; +use crate::brushctl::ShellBuilderBrushBuiltinExt as _; +use crate::bundled; +use crate::config; +use crate::error_formatter; +use crate::events; +use crate::productinfo; +use brush_builtins::ShellBuilderExt as _; +#[cfg(feature = "experimental-builtins")] +use brush_experimental_builtins::ShellBuilderExt as _; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::LazyLock; +use std::{path::Path, sync::Arc}; +use tokio::sync::Mutex; +use winnow_args::Args as _; + +static TRACE_EVENT_CONFIG: LazyLock>>> = + LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None))); + +type BrushShellExtensions = brush_core::extensions::ShellExtensionsImpl; +type BrushShell = brush_core::Shell; + +/// A command-line failure, already rendered. Help and version requests use +/// exit code 0; every other failure uses 2. +#[derive(Debug)] +pub(crate) struct ParseError { + pub(crate) message: String, + pub(crate) exit_code: u8, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ParseError {} + +/// Parses `args`, splitting off `--` and everything after it first. +/// +/// Returns the parsed value plus the `--` separator and the words that followed +/// it, or `None` if no separator was present. The first word is the program name +/// and is not an argument. +fn try_parse_known( + args: impl IntoIterator, +) -> Result<(CommandLineArgs, Option>), ParseError> { + let args: Vec = args.into_iter().collect(); + let words = args.split_first().map_or(&[][..], |(_, rest)| rest); + + let mut before = Vec::new(); + let mut hyphen = None; + let mut after = Vec::new(); + for word in words { + if hyphen.is_none() && word == "--" { + hyphen = Some(word.clone()); + continue; + } + if hyphen.is_some() { + after.push(word.clone()); + } else { + before.push(word.clone()); + } + } + + let parsed = parse_words(&before)?; + let raw = hyphen.map(|marker| { + let mut tail = vec![marker]; + tail.extend(after); + tail + }); + Ok((parsed, raw)) +} + +fn parse_words(words: &[String]) -> Result { + CommandLineArgs::parse_words(&winnow_args::words(words)).map_err(|err| render_parse_error(&err)) +} + +fn render_parse_error(err: &winnow_args::Error) -> ParseError { + // Help goes to stdout with status 0; every other failure to stderr with 2. + // Colored when the stream each goes to is a terminal that shows color. + use std::io::IsTerminal as _; + use winnow_args::{color::Theme, help::Style}; + const THEME: Theme = Theme::uniform(brush_winnow_args_adapter::HELP_PALETTE); + match err.render_help_styled( + "brush", + Style::themed(&THEME, std::io::stdout().is_terminal()), + ) { + Some(message) => ParseError { + message, + exit_code: 0, + }, + None => ParseError { + message: err.render(Style::themed(&THEME, std::io::stderr().is_terminal())), + exit_code: 2, + }, + } +} + +impl CommandLineArgs { + /// Parses the shell command line, including brush's `--` handling. + pub(crate) fn parse_shell_args( + itr: impl IntoIterator, + ) -> Result { + let (mut this, script_args) = try_parse_known(itr)?; + + if this.version { + return Err(ParseError { + message: format!("{} {}", productinfo::PRODUCT_NAME, crate::args::VERSION), + exit_code: 0, + }); + } + + // Highlighting is always on in experimental builds. + this.enable_highlighting |= DEFAULT_ENABLE_HIGHLIGHTING; + + // Collect any args from after `--` (handled by try_parse_known) into + // script_args, which become positional parameters ($0, $1, ...). + if let Some(args) = script_args { + let mut args = args.into_iter().peekable(); + + // Bash stops option parsing at the first non-option word: a script path + // or the `-c` command string. A `--` seen before either is the option + // terminator and is dropped (`bash -s -- a`, `bash -- script.sh`); one + // seen after is an ordinary positional (`bash script.sh -- a`). + if this.script_args.is_empty() { + args.next_if(|a| a == "--"); + } + this.script_args.extend(args); + } + + // With `-c`, bash takes the command string from the first operand; the + // operands after it keep their usual meaning, so the next one sets `$0` + // and the rest become positional parameters. + if this.command_string_mode { + if this.script_args.is_empty() { + return Err(ParseError { + message: "brush: -c: option requires an argument".to_owned(), + exit_code: 2, + }); + } + this.command = Some(this.script_args.remove(0)); + } + + Ok(this) + } +} + +/// Main entry point for the `brush` shell. +pub fn run() { + // + // Install the bundled-command registry so it's available both for + // bundled dispatch (handled next) and for builtin shim registration + // during shell construction. With no bundled-providing features enabled + // the registry is empty and both code paths become no-ops. + // + bundled::install_default_providers(); + + // + // If we were invoked as `brush [args...]`, run the + // bundled command and exit before doing any shell setup. This is the + // hot path for in-binary coreutils invocations. + // + if let Some(code) = bundled::maybe_dispatch() { + std::process::exit(code); + } + + // + // Install panic handlers to clean up on panic. + // + install_panic_handlers(); + + // + // Answer a request from a generated completion script (`brush __complete_word__ ...`) + // before parsing: it isn't part of the shell's command line. + // + let completion_args: Vec<_> = std::env::args_os().skip(1).collect(); + if let Some(answer) = CommandLineArgs::completion_request(&completion_args) { + let _ = write!(std::io::stdout(), "{answer}"); + std::process::exit(0); + } + + // + // Parse args. + // + let args: Vec<_> = std::env::args().collect(); + + let parsed_args = match CommandLineArgs::parse_shell_args(args.iter().cloned()) { + Ok(parsed_args) => parsed_args, + Err(error) => { + // Help and version requests succeed, so they go to stdout like any + // other requested output; real failures go to stderr. Help already + // ends with a newline. + let message = error.message.trim_end_matches('\n'); + let _ = if error.exit_code == 0 { + writeln!(std::io::stdout(), "{message}") + } else { + writeln!(std::io::stderr(), "{message}") + }; + std::process::exit(i32::from(error.exit_code)); + } + }; + + // + // Run. + // + #[cfg(any(unix, windows))] + let mut builder = tokio::runtime::Builder::new_multi_thread(); + #[cfg(not(any(unix, windows)))] + let mut builder = tokio::runtime::Builder::new_current_thread(); + + let Ok(runtime) = builder.enable_all().build() else { + tracing::error!("error: failed to create Tokio runtime"); + std::process::exit(1); + }; + + let result = runtime.block_on(run_async(&args, parsed_args)); + + let exit_code = match result { + Ok(code) => code, + Err(err) => { + tracing::error!("error: {err:#}"); + 1 + } + }; + + std::process::exit(i32::from(exit_code)); +} + +/// Installs panic handlers to report our panic and cleanly exit on panic. +fn install_panic_handlers() { + // + // Set up panic handler. On release builds, it will capture panic details to a + // temporary .toml file and report a human-readable message to the screen. + // + human_panic::setup_panic!( + human_panic::Metadata::new(productinfo::PRODUCT_NAME, productinfo::PRODUCT_VERSION) + .homepage(env!("CARGO_PKG_HOMEPAGE")) + .support("please post a GitHub issue at https://github.com/reubeno/brush/issues/new") + ); + + // + // If stdout is connected to a terminal, then register a new panic handler that + // resets the terminal and then invokes the previously registered handler. In + // dev/debug builds, the previously registered handler will be the default + // handler; in release builds, it will be the one registered by `human_panic`. + // + if std::io::stdout().is_terminal() { + let original_panic_handler = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + // Best-effort attempt to reset the terminal to defaults. + let _ = try_reset_terminal_to_defaults(); + + // Invoke the original handler + original_panic_handler(panic_info); + })); + } +} + +#[cfg(feature = "experimental")] +pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = true; +#[cfg(not(feature = "experimental"))] +pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = false; + +/// Run the brush shell. Returns the exit code. +/// +/// # Arguments +/// +/// * `cli_args` - The command-line arguments to the shell, in string form. +/// * `args` - The already-parsed command-line arguments. +#[doc(hidden)] +async fn run_async( + cli_args: &[String], + args: CommandLineArgs, +) -> Result { + // Initializing tracing. + let mut event_config = TRACE_EVENT_CONFIG.lock().await; + *event_config = Some(events::TraceEventConfig::init( + &args.enabled_debug_events, + &args.disabled_events, + )); + drop(event_config); + + // Load configuration file. + let file_config = config::load_config(args.no_config, args.config_file.as_deref()) + .into_config_or_log() + .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?; + + // Instantiate an appropriately configured shell and wrap it in an `Arc`. Note that we do + // *not* run any code in the shell yet. We'll delay loading profiles and such until after + // we've set up everything else (in `run_in_shell`). + let shell: BrushShell = instantiate_shell(&args, cli_args).await?; + let shell = Arc::new(Mutex::new(shell)); + + // Run with the selected input backend. Each branch instantiates the concrete + // backend type and calls `run_in_shell`, preserving static dispatch. + let default_backend = get_default_input_backend_type(&args); + let selected_backend = args.input_backend.unwrap_or(default_backend); + + // Build UI options by merging config file with CLI args. + #[allow(unused_variables, reason = "not used when no backend features enabled")] + let ui_options = file_config.to_ui_options(&args); + + let result = match selected_backend { + #[cfg(reedline_backend)] + InputBackendType::Reedline => { + let mut input_backend = + brush_interactive::ReedlineInputBackend::new(&ui_options, &shell)?; + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(reedline_backend))] + InputBackendType::Reedline => Err(brush_interactive::ShellError::InputBackendNotSupported), + + #[cfg(basic_backend)] + InputBackendType::Basic => { + let mut input_backend = brush_interactive::VtInputBackend::default().with_basic_style(); + let mut locked_shell = shell.lock().await; + input_backend.install_key_bindings(&mut locked_shell); + drop(locked_shell); + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(basic_backend))] + InputBackendType::Basic => Err(brush_interactive::ShellError::InputBackendNotSupported), + + #[cfg(minimal_backend)] + InputBackendType::Minimal => { + let mut input_backend = brush_interactive::MinimalInputBackend; + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(minimal_backend))] + InputBackendType::Minimal => Err(brush_interactive::ShellError::InputBackendNotSupported), + + #[cfg(vt_backend)] + InputBackendType::Vt => { + let mut input_backend = + brush_interactive::VtInputBackend::default().with_ui_options(&ui_options); + let mut locked_shell = shell.lock().await; + input_backend.install_key_bindings(&mut locked_shell); + drop(locked_shell); + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(vt_backend))] + InputBackendType::Vt => Err(brush_interactive::ShellError::InputBackendNotSupported), + }; + + // Display any error that percolated up. + let exit_code = match result { + Ok(code) => code, + Err(brush_interactive::ShellError::ShellError(e)) => { + let shell = shell.lock().await; + let mut stderr = shell.stderr(); + let _ = shell.display_error(&mut stderr, &e); + drop(shell); + 1 + } + Err(err) => { + tracing::error!("error: {err:#}"); + 1 + } + }; + + Ok(exit_code) +} + +/// Runs the shell according to the provided command-line arguments. +/// Also responsible for loading profiles and rc files as appropriate. +/// +/// # Arguments +/// +/// * `shell_ref` - A reference to the shell to run. +/// * `args` - The parsed command-line arguments. +/// * `input_backend` - The input backend to use. +/// * `ui_options` - The user interface options to use. +async fn run_in_shell( + shell_ref: &brush_interactive::ShellRef, + args: CommandLineArgs, + input_backend: &mut impl brush_interactive::InputBackend, + ui_options: &brush_interactive::UIOptions, +) -> Result { + let read_commands_from_stdin = args.will_read_commands_from_stdin(); + let interactive_options: brush_interactive::InteractiveOptions = ui_options.into(); + + // Before config files run, so they (and what they source) observe it -- and only for a + // shell that goes on to read commands interactively below, since only such a shell + // dispatches hooks. + if read_commands_from_stdin { + brush_interactive::init_zsh_style_hooks( + &mut *shell_ref.lock().await, + &interactive_options, + )?; + } + + // Load profile and rc files as appropriate. + initialize_shell(shell_ref, &args).await?; + + // If a command was specified via -c, then run that command and then exit. Its result + // is also the shell's last exit status, which is what's returned below for every mode. + if let Some(command) = args.command { + let _ = shell_ref.lock().await.run_dash_c_command(command).await?; + + // Otherwise read commands interactively: -s was given (positional args become parameters; + // a script named among them is *not* run), or nothing to run was specified. + } else if read_commands_from_stdin { + brush_interactive::InteractiveShell::new(shell_ref, input_backend, &interactive_options)? + .run_interactively() + .await?; + + // Otherwise a script path was given; run it. As with -c, its status is returned below. + } else { + let _ = shell_ref + .lock() + .await + .run_script( + Path::new(&args.script_args[0]), + args.script_args.iter().skip(1), + ) + .await?; + } + + // Make sure to return the last result observed in the shell. + let result = shell_ref.lock().await.last_exit_status(); + + Ok(result) +} + +/// Initializes a shell by loading profile and rc files as appropriate. +/// +/// # Arguments +/// +/// * `shell_ref` - A reference to the shell to initialize. +/// * `args` - The parsed command-line arguments. +async fn initialize_shell( + shell_ref: &brush_interactive::ShellRef, + args: &CommandLineArgs, +) -> Result<(), brush_interactive::ShellError> { + // Compute desired profile-loading behavior. + let profile = if args.no_profile { + brush_core::ProfileLoadBehavior::Skip + } else { + brush_core::ProfileLoadBehavior::LoadDefault + }; + + // Compute desired rc-loading behavior. + let rc = if args.no_rc { + brush_core::RcLoadBehavior::Skip + } else if let Some(rc_file) = &args.rc_file { + brush_core::RcLoadBehavior::LoadCustom(rc_file.clone()) + } else { + brush_core::RcLoadBehavior::LoadDefault + }; + + shell_ref.lock().await.load_config(&profile, &rc).await?; + + Ok(()) +} + +/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell. +/// +/// # Arguments +/// +/// * `args` - The parsed command-line arguments. +/// * `cli_args` - The raw command-line arguments. +async fn instantiate_shell( + args: &CommandLineArgs, + cli_args: &[String], +) -> Result { + #[cfg(feature = "experimental-load")] + let mut shell = if let Some(load_file) = &args.load_file { + instantiate_shell_from_file(load_file.as_path())? + } else { + instantiate_shell_from_args(args, cli_args).await? + }; + + #[cfg(not(feature = "experimental-load"))] + let mut shell = instantiate_shell_from_args(args, cli_args).await?; + + // Register shims for any bundled commands in the installed registry. + // Done here (not inside the inner instantiators) so both paths are + // covered from a single site. + bundled::register_shims(&mut shell); + + Ok(shell) +} + +#[cfg(feature = "experimental-load")] +fn instantiate_shell_from_file( + file_path: &Path, +) -> Result { + let mut shell: BrushShell = serde_json::from_reader(std::fs::File::open(file_path)?) + .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?; + + // NOTE: We need to manually register builtins because we can't serialize/deserialize them. + // TODO(serde): we should consider whether we could/should at least track *which* are enabled. + let builtin_set = if shell.options().sh_mode { + brush_builtins::BuiltinSet::ShMode + } else { + brush_builtins::BuiltinSet::BashMode + }; + + let builtins = brush_builtins::default_builtins(builtin_set); + + for (builtin_name, builtin) in builtins { + shell.builtins_mut().register(builtin_name, builtin); + } + + // Add experimental builtins (if enabled). + #[cfg(feature = "experimental-builtins")] + for (builtin_name, builtin) in brush_experimental_builtins::experimental_builtins() { + shell.builtins_mut().register(builtin_name, builtin); + } + + Ok(shell) +} + +/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell. +/// +/// # Arguments +/// +/// * `args` - The parsed command-line arguments. +/// * `cli_args` - The raw command-line arguments. +async fn instantiate_shell_from_args( + args: &CommandLineArgs, + cli_args: &[String], +) -> Result { + // Compute login flag. + let login = args.login || cli_args.first().is_some_and(|argv0| argv0.starts_with('-')); + + // Compute shell name. + let shell_name = if args.command.is_some() && !args.script_args.is_empty() { + Some(args.script_args[0].clone()) + } else if !cli_args.is_empty() { + Some(cli_args[0].clone()) + } else if args.sh_mode { + // Simulate having been run as "sh". + Some(String::from("sh")) + } else { + None + }; + + // Compute positional shell arguments. + let shell_args = if args.command.is_some() { + Some(args.script_args.iter().skip(1).cloned().collect()) + } else if args.read_commands_from_stdin { + Some(args.script_args.clone()) + } else { + None + }; + + // Commands are read from stdin if -s was provided, or if no command was specified (either via + // -c or as a positional argument). + let read_commands_from_stdin = args.will_read_commands_from_stdin(); + + let builtin_set = if args.sh_mode { + brush_builtins::BuiltinSet::ShMode + } else { + brush_builtins::BuiltinSet::BashMode + }; + + // Identify the file descriptors to inherit. + let fds = args + .inherited_fds + .iter() + .filter_map(|&fd| brush_core::sys::fd::try_get_file_for_open_fd(fd).map(|file| (fd, file))) + .collect(); + + // Select parser implementation to use. + #[cfg(feature = "experimental-parser")] + let parser_impl = if args.experimental_parser { + brush_core::parser::ParserImpl::Winnow + } else { + brush_core::parser::ParserImpl::Peg + }; + + #[cfg(not(feature = "experimental-parser"))] + let parser_impl = brush_core::parser::ParserImpl::Peg; + + // Set up the shell builder with the requested options. + // NOTE: We skip loading profile and rc files here; that will be handled later after we've + // fully instantiated everything we want set before running any code. + let shell = brush_core::Shell::builder_with_extensions::() + .disable_options(args.disabled_options.clone()) + .disable_shopt_options(args.disabled_shopt_options.clone()) + .disallow_overwriting_regular_files_via_output_redirection( + args.disallow_overwriting_regular_files_via_output_redirection, + ) + .enable_options(args.enabled_options.clone()) + .enable_shopt_options(args.enabled_shopt_options.clone()) + .do_not_execute_commands(args.do_not_execute_commands) + .exit_after_one_command(args.exit_after_one_command) + .login(login) + .interactive(args.is_interactive()) + .command_string_mode(args.command.is_some()) + .no_editing(args.no_editing) + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .do_not_inherit_env(args.do_not_inherit_env) + .fds(fds) + .maybe_shell_args(shell_args) + .posix(args.posix || args.sh_mode) + .print_commands_and_arguments(args.print_commands_and_arguments) + .read_commands_from_stdin(read_commands_from_stdin) + .maybe_shell_name(shell_name) + .shell_product_display_str(productinfo::get_product_display_str()) + .sh_mode(args.sh_mode) + .treat_unset_variables_as_error(args.treat_unset_variables_as_error) + .exit_on_nonzero_command_exit(args.exit_on_nonzero_command_exit) + .disable_pathname_expansion(args.disable_pathname_expansion) + .verbose(args.verbose) + .parser(parser_impl) + .error_formatter(new_error_behavior(args)) + .shell_version(env!("CARGO_PKG_VERSION").to_string()); + + // Add builtins. + let shell = shell.default_builtins(builtin_set).brush_builtins(); + + // Add experimental builtins (if enabled). + #[cfg(feature = "experimental-builtins")] + let shell = shell.experimental_builtins(); + + // Build the shell. + let mut shell = shell.build().await?; + + // Make adjustments. + if let Some(xtrace_file_path) = &args.xtrace_file_path { + enable_xtrace_to_file(&mut shell, xtrace_file_path)?; + } + + Ok(shell) +} + +fn enable_xtrace_to_file( + shell: &mut brush_core::Shell, + file_path: &Path, +) -> Result<(), brush_interactive::ShellError> { + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(file_path) + .map_err(|e| { + brush_interactive::ShellError::FailedToCreateXtraceFile(file_path.to_path_buf(), e) + })?; + + let file = brush_core::openfiles::OpenFile::from(file); + let file_fd = shell.open_files_mut().add(file)?; + + shell.options_mut().print_commands_and_arguments = true; + shell.set_env_global( + "BASH_XTRACEFD", + brush_core::ShellVariable::new(file_fd.to_string()), + )?; + + Ok(()) +} + +const fn new_error_behavior(args: &CommandLineArgs) -> error_formatter::Formatter { + error_formatter::Formatter { + use_color: !args.disable_color, + } +} + +// The default input backend must be one this build offers. +#[cfg(not(any(reedline_backend, vt_backend, basic_backend, minimal_backend)))] +compile_error!("no input backend is enabled that works on this target; enable `minimal`"); + +/// Whether this build offers the given input backend; see `build.rs`. +const fn is_input_backend_available(backend: InputBackendType) -> bool { + match backend { + InputBackendType::Reedline => cfg!(reedline_backend), + InputBackendType::Basic => cfg!(basic_backend), + InputBackendType::Minimal => cfg!(minimal_backend), + InputBackendType::Vt => cfg!(vt_backend), + } +} + +fn get_default_input_backend_type(args: &CommandLineArgs) -> InputBackendType { + default_input_backend_type( + std::io::stdin().is_terminal() && args.will_read_commands_from_stdin(), + ) +} + +/// Returns the input backend to use if none is requested: the first this build +/// offers, of the richest for commands typed at a terminal, or else the simplest. +/// +/// # Arguments +/// +/// * `at_terminal` - Whether commands are typed at a terminal. +fn default_input_backend_type(at_terminal: bool) -> InputBackendType { + use InputBackendType as B; + + // If stdin isn't a terminal, then `reedline` doesn't do the right thing + // (reference: https://github.com/nushell/reedline/issues/509), so it comes last. + let preferred = if at_terminal { + [B::Reedline, B::Vt, B::Basic, B::Minimal] + } else { + [B::Minimal, B::Vt, B::Basic, B::Reedline] + }; + + // Some backend is always offered (see the `compile_error!` above). + preferred + .into_iter() + .find(|backend| is_input_backend_available(*backend)) + .unwrap_or(B::Minimal) +} + +pub(crate) fn get_event_config() -> Arc>> { + TRACE_EVENT_CONFIG.clone() +} + +fn try_reset_terminal_to_defaults() -> Result<(), std::io::Error> { + #[cfg(any(unix, windows))] + { + // Reset the console. + let exec_result = crossterm::execute!( + std::io::stdout(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::terminal::EnableLineWrap, + crossterm::style::ResetColor, + crossterm::event::DisableMouseCapture, + crossterm::event::DisableBracketedPaste, + crossterm::cursor::Show, + crossterm::cursor::MoveToNextLine(1), + ); + + let raw_result = crossterm::terminal::disable_raw_mode(); + + exec_result?; + raw_result?; + } + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::{assert_eq, assert_matches}; + + fn args(strs: &[&str]) -> Vec { + strs.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn plus_options_are_options_only_before_operands() -> Result<()> { + let parsed = CommandLineArgs::parse_shell_args(args(&[ + "brush", "+o", "errexit", "-o", "nounset", "+O", "extglob", "script", "+o", "+Oz", + ]))?; + assert_eq!(parsed.disabled_options, ["errexit"]); + assert_eq!(parsed.enabled_options, ["nounset"]); + assert_eq!(parsed.disabled_shopt_options, ["extglob"]); + assert_eq!(parsed.script_args, ["script", "+o", "+Oz"]); + + let parsed = CommandLineArgs::parse_shell_args(args(&[ + "brush", "-xc", "echo", "arg0", "+o", "--", "+o", + ]))?; + assert_eq!(parsed.command.as_deref(), Some("echo")); + assert_eq!(parsed.script_args, ["arg0", "+o", "--", "+o"]); + Ok(()) + } + + #[test] + fn parse_empty_args() -> Result<()> { + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush"]))?; + assert_matches!(parsed_args.script_args.as_slice(), []); + Ok(()) + } + + #[test] + fn parse_script_and_args() -> Result<()> { + let parsed_args = CommandLineArgs::parse_shell_args(args(&[ + "brush", + "some-script", + "-x", + "1", + "--option", + ]))?; + assert_eq!( + parsed_args.script_args, + ["some-script", "-x", "1", "--option"] + ); + Ok(()) + } + + #[test] + fn parse_script_and_args_with_double_dash_in_script_args() -> Result<()> { + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush", "some-script", "--"]))?; + assert_eq!(parsed_args.script_args, ["some-script", "--"]); + Ok(()) + } + + #[test] + fn parse_unknown_args() { + let result = CommandLineArgs::parse_shell_args(args(&["brush", "--unknown-option"])); + assert!(result.is_err()); + } + + #[test] + fn parse_c_with_double_dash_separator() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "--", "echo hello", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert_eq!(parsed_args.script_args, ["arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_double_dash_no_command() { + assert!(CommandLineArgs::parse_shell_args(args(&["brush", "-c", "--"])).is_err()); + } + + #[test] + fn parse_c_with_double_dash_command_is_double_dash() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "--", "--", "echo", "hi"]))?; + assert_eq!(parsed_args.command, Some("--".to_string())); + assert_eq!(parsed_args.script_args, ["echo", "hi"]); + Ok(()) + } + + #[test] + fn parse_ec_with_double_dash_separator() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-ec", "--", "echo hello", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_value_before_double_dash_unchanged() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "echo hi", "--", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hi".to_string())); + assert_eq!(parsed_args.script_args, ["--", "arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_option_before_command() -> Result<()> { + // bash takes -c's command string from the first operand, so options may + // sit between the two: `bash -c -l 'echo hello' arg0`. + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "-l", "echo hello", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.login); + assert_eq!(parsed_args.script_args, ["arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_value_taking_option_before_command() -> Result<()> { + // -o consumes its own value, so the first operand is still the command. + let parsed_args = CommandLineArgs::parse_shell_args(args(&[ + "brush", + "-c", + "-o", + "errexit", + "echo hello", + ]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert_eq!(parsed_args.enabled_options, ["errexit"]); + assert_eq!(parsed_args.script_args, Vec::::new()); + Ok(()) + } + + #[test] + fn parse_ec_with_option_before_command() -> Result<()> { + // Only the `c` leaves the combined group; -e still applies. + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-ec", "-l", "echo hello"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert!(parsed_args.login); + Ok(()) + } + + #[test] + fn parse_c_with_several_options_before_command() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "-l", "-x", "echo hello"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.login); + assert!(parsed_args.print_commands_and_arguments); + assert_eq!(parsed_args.script_args, Vec::::new()); + Ok(()) + } + + #[test] + fn parse_c_after_value_taking_option() -> Result<()> { + // "errexit" is -o's value rather than an operand, so option parsing + // continues and the command is still the first operand. + let parsed_args = CommandLineArgs::parse_shell_args(args(&[ + "brush", + "-o", + "errexit", + "-c", + "-l", + "echo hello", + ]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert_eq!(parsed_args.enabled_options, ["errexit"]); + assert!(parsed_args.login); + Ok(()) + } + + #[test] + fn parse_c_after_script_stays_a_script_argument() -> Result<()> { + // Option parsing stops at the first operand, so these reach the script: + // `bash announce.sh -c -l` passes "-c" and "-l" as $1 and $2. + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "announce.sh", "-c", "-l"]))?; + assert!(parsed_args.command.is_none()); + assert!(!parsed_args.login); + assert_eq!(parsed_args.script_args, ["announce.sh", "-c", "-l"]); + Ok(()) + } + + #[test] + fn parse_c_with_double_dash_and_option_like_command() -> Result<()> { + // After `--`, the next token is the command even when it looks like an + // option: `bash -c -- -l 'echo zero'` runs `-l` with $0 of "echo zero". + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "--", "-l", "echo zero"]))?; + assert_eq!(parsed_args.command, Some("-l".to_string())); + assert!(!parsed_args.login); + assert_eq!(parsed_args.script_args, ["echo zero"]); + Ok(()) + } + + #[test] + fn parse_c_with_option_and_no_command() { + // bash: "-c: option requires an argument", exit 2. + assert!(CommandLineArgs::parse_shell_args(args(&["brush", "-c", "-l"])).is_err()); + } + + #[test] + fn parse_c_with_option_then_double_dash() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "-l", "--", "echo hello"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.login); + assert_eq!(parsed_args.script_args, Vec::::new()); + Ok(()) + } + + #[test] + fn parse_c_with_option_and_doubled_double_dash() -> Result<()> { + // Only the first `--` terminates options; the second is the command + // operand, matching `bash -c -l -- -- echo hi`. + let parsed_args = CommandLineArgs::parse_shell_args(args(&[ + "brush", "-c", "-l", "--", "--", "echo", "hi", + ]))?; + assert_eq!(parsed_args.command, Some("--".to_string())); + assert_eq!(parsed_args.script_args, ["echo", "hi"]); + Ok(()) + } + + #[test] + fn parse_c_combined_with_other_flags() -> Result<()> { + // `-c` groups with other short flags in any order, as in `brush -cl "echo hi"`. + for argv in [ + &["brush", "-cl", "echo hi", "name"][..], + &["brush", "-lc", "echo hi", "name"][..], + &["brush", "-c", "-l", "echo hi", "name"][..], + ] { + let parsed_args = CommandLineArgs::parse_shell_args(args(argv))?; + assert!(parsed_args.login, "for {argv:?}"); + assert_eq!( + parsed_args.command, + Some("echo hi".to_string()), + "for {argv:?}" + ); + assert_eq!(parsed_args.script_args, ["name"], "for {argv:?}"); + } + Ok(()) + } + + #[test] + fn parse_c_without_command_string() { + assert!(CommandLineArgs::parse_shell_args(args(&["brush", "-c"])).is_err()); + } + + #[test] + fn parse_c_with_empty_command_string() -> Result<()> { + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush", "-c", ""]))?; + assert_eq!(parsed_args.command, Some(String::new())); + Ok(()) + } + + #[test] + fn parse_o_with_double_dash_is_not_transformed() { + // Unlike -c, bash's -o consumes -- as its literal value (invalid option + // name), not as an option terminator. + let result = CommandLineArgs::parse_shell_args(args(&["brush", "-o", "--"])); + // try_parse_known splits at --, so -o ends up + // without a value and parsing correctly fails. The key assertion is + // that we MUST NOT reinterpret -- as an option terminator for -o and + // then take any later argument as its value. + assert!(result.is_err()); + } + + #[test] + fn parse_oc_is_o_with_an_attached_value() -> Result<()> { + // To brush's parser, -oc is -o with attached value "c", not -o flag + -c + // flag, so the -- is an ordinary option terminator here. NOTE: bash instead + // takes the *next* word as -o's option name; see the known-failure compat + // case "-oc with -- takes its option name from the next word". + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush", "-oc", "--", "echo"]))?; + // -o consumed "c" as its value; -- ended the options; no -c command. + assert!(parsed_args.command.is_none()); + assert_eq!(parsed_args.script_args, ["echo"]); + Ok(()) + } + + #[test] + fn parse_bool_flag_before_double_dash_not_transformed() -> Result<()> { + // -e is a boolean flag, not -c. The -- ends the options, so + // everything after it is positional (including -c). + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-e", "--", "-c", "echo"]))?; + assert!(parsed_args.command.is_none()); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["-c", "echo"]); + Ok(()) + } + + #[test] + fn parse_s_with_double_dash_separator() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-s", "--", "--", "a"]))?; + assert!(parsed_args.read_commands_from_stdin); + assert_eq!(parsed_args.script_args, ["--", "a"]); + Ok(()) + } + + #[test] + fn parse_double_dash_before_script() -> Result<()> { + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "--", "script.sh", "--", "x"]))?; + assert_eq!(parsed_args.script_args, ["script.sh", "--", "x"]); + Ok(()) + } + + #[test] + fn parse_c_with_double_dash_and_later_double_dash() -> Result<()> { + // After removing the first --, -c gets "echo". The second -- is + // handled by try_parse_known and appears in script_args. + let parsed_args = + CommandLineArgs::parse_shell_args(args(&["brush", "-c", "--", "echo", "--", "more"]))?; + assert_eq!(parsed_args.command, Some("echo".to_string())); + assert_eq!(parsed_args.script_args, ["--", "more"]); + Ok(()) + } + + #[test] + fn parse_c_flag_group_edge_cases() -> Result<()> { + // `-C` is a different flag, and `-oc` is `-o` with the value "c", so + // neither puts the shell in command mode. + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush", "-C", "script.sh"]))?; + assert!(!parsed_args.command_string_mode); + assert!(parsed_args.command.is_none()); + + let parsed_args = CommandLineArgs::parse_shell_args(args(&["brush", "-oc", "script.sh"]))?; + assert!(!parsed_args.command_string_mode); + assert_eq!(parsed_args.enabled_options, ["c"]); + Ok(()) + } + + #[test] + fn defaults_to_an_available_input_backend() { + for at_terminal in [true, false] { + let backend = default_input_backend_type(at_terminal); + assert!(is_input_backend_available(backend), "{backend:?}"); + } + } + + #[cfg(reedline_backend)] + #[test] + fn defaults_to_reedline_at_a_terminal() { + assert_eq!(default_input_backend_type(true), InputBackendType::Reedline); + } + + #[cfg(all(not(reedline_backend), vt_backend))] + #[test] + fn defaults_to_vt_at_a_terminal_without_reedline() { + assert_eq!(default_input_backend_type(true), InputBackendType::Vt); + } + + #[cfg(minimal_backend)] + #[test] + fn defaults_to_minimal_away_from_a_terminal() { + assert_eq!(default_input_backend_type(false), InputBackendType::Minimal); + } +} diff --git a/userland/brush/source/brush-shell/src/error_formatter.rs b/userland/brush/source/brush-shell/src/error_formatter.rs new file mode 100644 index 0000000..7142fe6 --- /dev/null +++ b/userland/brush/source/brush-shell/src/error_formatter.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Default, Clone)] +pub(crate) struct Formatter { + pub use_color: bool, +} + +impl brush_core::extensions::ErrorFormatter for Formatter { + fn format_error( + &self, + err: &brush_core::error::Error, + _shell: &brush_core::Shell, + ) -> String { + let prefix = if self.use_color { + color_print::cstr!("error: ") + } else { + "error: " + }; + + std::format!("{prefix}{err:#}\n") + } +} diff --git a/userland/brush/source/brush-shell/src/events.rs b/userland/brush/source/brush-shell/src/events.rs new file mode 100644 index 0000000..4546bab --- /dev/null +++ b/userland/brush/source/brush-shell/src/events.rs @@ -0,0 +1,174 @@ +//! Facilities for configuring event tracing in the brush shell. + +use std::{collections::HashSet, fmt::Display}; + +use brush_core::Error; +use tracing_subscriber::{ + Layer, Registry, filter::Targets, layer::SubscriberExt, reload::Handle, util::SubscriberInitExt, +}; + +/// Type of event to trace. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, winnow_args::ValueEnum)] +pub enum TraceEvent { + /// Traces parsing and evaluation of arithmetic expressions. + Arithmetic, + /// Traces command execution. + Commands, + /// Traces command completion generation. + Complete, + /// Traces word expansion. + Expand, + /// Traces functions. + Functions, + /// Traces input controls. + Input, + /// Traces job management. + Jobs, + /// Traces the process of parsing tokens into an abstract syntax tree. + Parse, + /// Traces pattern matching. + Pattern, + /// Traces the process of tokenizing input text. + Tokenize, + /// Traces usage of unimplemented functionality. + #[arg(alias = "unimp")] + Unimplemented, +} + +impl Display for TraceEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Arithmetic => write!(f, "arithmetic"), + Self::Commands => write!(f, "commands"), + Self::Complete => write!(f, "complete"), + Self::Expand => write!(f, "expand"), + Self::Functions => write!(f, "functions"), + Self::Input => write!(f, "input"), + Self::Jobs => write!(f, "jobs"), + Self::Parse => write!(f, "parse"), + Self::Pattern => write!(f, "pattern"), + Self::Tokenize => write!(f, "tokenize"), + Self::Unimplemented => write!(f, "unimplemented"), + } + } +} + +#[derive(Default)] +pub(crate) struct TraceEventConfig { + enabled_debug_events: HashSet, + disabled_events: HashSet, + handle: Option>, +} + +impl TraceEventConfig { + pub fn init(enabled_debug_events: &[TraceEvent], disabled_events: &[TraceEvent]) -> Self { + let enabled_debug_events: HashSet = + enabled_debug_events.iter().copied().collect(); + let disabled_events: HashSet = disabled_events.iter().copied().collect(); + + let mut config = Self { + enabled_debug_events, + disabled_events, + ..Default::default() + }; + + let filter = config.compose_filter(); + + // Make the filter reloadable so that we can change the log level at runtime. + let (reload_filter, handle) = tracing_subscriber::reload::Layer::new(filter); + + let layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .without_time() + .with_target(false) + .with_filter(reload_filter); + + if tracing_subscriber::registry() + .with(layer) + .try_init() + .is_ok() + { + config.handle = Some(handle); + } else { + // Something went wrong; proceed on anyway but complain audibly. + eprintln!("warning: failed to initialize tracing."); + } + + config + } + + fn compose_filter(&self) -> tracing_subscriber::filter::Targets { + let mut filter = tracing_subscriber::filter::Targets::new() + .with_default(tracing_subscriber::filter::LevelFilter::INFO); + + for event in &self.enabled_debug_events { + let targets = Self::event_to_tracing_targets(event); + filter = filter.with_targets( + targets + .into_iter() + .map(|target| (target, tracing::Level::DEBUG)), + ); + } + + for event in &self.disabled_events { + let targets = Self::event_to_tracing_targets(event); + filter = filter.with_targets( + targets + .into_iter() + .map(|target| (target, tracing::level_filters::LevelFilter::OFF)), + ); + } + + filter + } + + const fn event_to_tracing_targets(event: &TraceEvent) -> [&str; 1] { + match event { + TraceEvent::Arithmetic => ["arithmetic"], + TraceEvent::Commands => ["commands"], + TraceEvent::Complete => ["completion"], + TraceEvent::Expand => ["expansion"], + TraceEvent::Functions => ["functions"], + TraceEvent::Input => ["input"], + TraceEvent::Jobs => ["jobs"], + TraceEvent::Parse => ["parse"], + TraceEvent::Pattern => ["pattern"], + TraceEvent::Tokenize => ["tokenize"], + TraceEvent::Unimplemented => ["unimplemented"], + } + } + + pub const fn get_enabled_events(&self) -> &HashSet { + &self.enabled_debug_events + } + + pub fn enable(&mut self, event: TraceEvent) -> Result<(), Error> { + // Don't bother to reload config if nothing has changed. + if !self.enabled_debug_events.insert(event) { + return Ok(()); + } + + self.reload_filter() + } + + pub fn disable(&mut self, event: TraceEvent) -> Result<(), Error> { + // Don't bother to reload config if nothing has changed. + if !self.enabled_debug_events.remove(&event) { + return Ok(()); + } + + self.reload_filter() + } + + fn reload_filter(&self) -> Result<(), Error> { + if let Some(handle) = &self.handle { + if handle.reload(self.compose_filter()).is_ok() { + Ok(()) + } else { + Err(brush_core::ErrorKind::Unimplemented("failed to enable tracing events").into()) + } + } else { + Err(brush_core::ErrorKind::Unimplemented("tracing not initialized").into()) + } + } +} diff --git a/userland/brush/source/brush-shell/src/lib.rs b/userland/brush/source/brush-shell/src/lib.rs new file mode 100644 index 0000000..4e24391 --- /dev/null +++ b/userland/brush/source/brush-shell/src/lib.rs @@ -0,0 +1,12 @@ +//! Create for brush, an executable bash-compatible shell. + +#![allow(dead_code)] + +mod args; +mod brushctl; +pub mod bundled; +pub mod config; +pub mod entry; +mod error_formatter; +mod events; +mod productinfo; diff --git a/userland/brush/source/brush-shell/src/main.rs b/userland/brush/source/brush-shell/src/main.rs new file mode 100644 index 0000000..97abd67 --- /dev/null +++ b/userland/brush/source/brush-shell/src/main.rs @@ -0,0 +1,5 @@ +//! Main entry for the `brush` shell. + +fn main() { + brush_shell::entry::run(); +} diff --git a/userland/brush/source/brush-shell/src/productinfo.rs b/userland/brush/source/brush-shell/src/productinfo.rs new file mode 100644 index 0000000..592669b --- /dev/null +++ b/userland/brush/source/brush-shell/src/productinfo.rs @@ -0,0 +1,36 @@ +//! Information about this shell project. + +/// The formal name of this product. +pub const PRODUCT_NAME: &str = "brush"; + +const PRODUCT_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE"); +const PRODUCT_REPO: &str = env!("CARGO_PKG_REPOSITORY"); + +/// The URI to display as the product's homepage. +#[allow(clippy::const_is_empty)] +pub const PRODUCT_DISPLAY_URI: &str = if !PRODUCT_HOMEPAGE.is_empty() { + PRODUCT_HOMEPAGE +} else { + PRODUCT_REPO +}; + +/// Marks builds that aren't from a release (e.g. "+canary" for builds of `main`). Set through the +/// `BRUSH_VERSION_SUFFIX` environment variable at build time; empty for releases. +const PRODUCT_VERSION_SUFFIX: &str = match option_env!("BRUSH_VERSION_SUFFIX") { + Some(suffix) => suffix, + None => "", +}; + +/// The version of the product, in string form, as shown to users. Scripts see the unsuffixed +/// version through `BRUSH_VERSION`. +pub const PRODUCT_VERSION: &str = + const_format::concatcp!(env!("CARGO_PKG_VERSION"), PRODUCT_VERSION_SUFFIX); + +/// Info regarding the specific version of sources used to build this product. +pub const PRODUCT_GIT_VERSION: &str = "9435534-sausage"; + +pub(crate) fn get_product_display_str() -> String { + std::format!( + "{PRODUCT_NAME} version {PRODUCT_VERSION} ({PRODUCT_GIT_VERSION}) - {PRODUCT_DISPLAY_URI}" + ) +} diff --git a/userland/brush/source/brush-shell/tests/cases/brush/brushctl.yaml b/userland/brush/source/brush-shell/tests/cases/brush/brushctl.yaml new file mode 100644 index 0000000..aca23a6 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/brushctl.yaml @@ -0,0 +1,59 @@ +name: "brushctl" +cases: + - name: "Events are enabled and disabled by name or alias" + stdin: | + brushctl events enable unimp + brushctl events status + brushctl events disable unimplemented + brushctl events status + echo "done" + expected_stdout: "unimplemented\ndone\n" + + - name: "An unknown event is a usage error" + ignore_stderr: true + stdin: | + brushctl events enable bogus + echo "status: $?" + expected_stdout: "status: 2\n" + + - name: "A subcommand's help lists its own subcommands" + stdin: | + [[ $(brushctl events --help) == *$'\n enable '* ]] && echo "events lists enable" + [[ $(brushctl --help) == *$'\n enable '* ]] || echo "brushctl doesn't" + expected_stdout: "events lists enable\nbrushctl doesn't\n" + + - name: "-h and the help subcommand give help too" + stdin: | + [[ $(brushctl -h) == *$'\n events '* ]] && echo "-h" + [[ $(brushctl help events) == *$'\n enable '* ]] && echo "help events" + [[ $(brushctl events help enable) == *'Usage: brushctl events enable '* ]] && echo "events help enable" + expected_stdout: "-h\nhelp events\nevents help enable\n" + + - name: "A group without a subcommand is a usage error" + ignore_stderr: true + stdin: | + out=$(brushctl events) + echo "status: $? stdout: ${#out}" + out=$(brushctl) + echo "status: $? stdout: ${#out}" + expected_stdout: "status: 2 stdout: 0\nstatus: 2 stdout: 0\n" + + - name: "Completions for a line" + stdin: | + my_unique_func() { :; } + brushctl complete line 'my_unique_f' + brushctl complete line --cursor 6 'my_uniqZZZ' + expected_stdout: "my_unique_func\nmy_unique_func\n" + + - name: "Process IDs" + incompatible_platforms: ["wasi"] # (No processes there) + stdin: | + [[ $(brushctl process pid) == "$$" ]] && echo "pid matches" + [[ $(brushctl process ppid) == "$PPID" ]] && echo "ppid matches" + expected_stdout: "pid matches\nppid matches\n" + + - name: "Call stack names the calling function" + stdin: | + f() { brushctl call stack --detailed; } + [[ $(f) == *'func(f)'* ]] && echo "names f" + expected_stdout: "names f\n" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/cli.yaml b/userland/brush/source/brush-shell/tests/cases/brush/cli.yaml new file mode 100644 index 0000000..45af41c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/cli.yaml @@ -0,0 +1,331 @@ +name: "CLI tests" +cases: + - name: "Basic echo" + stdin: "echo hello world" + expected_exit_code: 0 + expected_stdout: "hello world\n" + + - name: "Exit code propagates" + stdin: "exit 42" + expected_exit_code: 42 + + - name: "Exit code propagates from an interactive session" + incompatible_platforms: ["windows"] # (No pty to run it in) + pty: true + stdin: | + #expect-prompt + exit 42 + #send:Enter + expected_exit_code: 42 + + - name: "Echo via args" + args: + - "-c" + - "echo from args" + expected_exit_code: 0 + expected_stdout: "from args\n" + + - name: "Version flag succeeds" + args: + - "--version" + expected_exit_code: 0 + # Note: can't easily check stdout content as it varies + + - name: "Version goes to stdout and names the shell" + stdin: | + version=$("$BASH" --version 2>/dev/null) + echo "${version%% *}" + expected_exit_code: 0 + expected_stdout: "brush\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion script callback answers from the CLI spec" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush --input-backend r' + expected_exit_code: 0 + expected_stdout: "reedline\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback completes a flag value after other flags" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush --norc --input-backend r' + expected_exit_code: 0 + expected_stdout: "reedline\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback completes an attached flag value" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush --input-backend=r' + expected_exit_code: 0 + expected_stdout: "--input-backend=reedline\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback completes a long flag name" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush --nor' + expected_exit_code: 0 + expected_stdout: "--norc\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + # Flags stop at the script or `-c` command, so a later flag-like word is an + # argument: the callback answers `\x01files`, its request for file names. + - name: "Completion callback offers files, not flag values, after a script" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush script.sh --input-backend r' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback offers files, not attached flag values, after a script" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush script.sh --input-backend=r' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback offers files, not flag names, after a script" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush script.sh --nor' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback offers files, not flag values, after a -c command" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush -c "echo hi" --input-backend r' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback offers files, not flag names, after a -c command" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush -c "echo hi" --nor' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Completion callback offers files, not flag names, after --" + stdin: | + "$BASH" __complete_word__ --shell bash --line 'brush -- --nor' + expected_exit_code: 0 + expected_stdout: "\x01files\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Help flag succeeds" + args: + - "--help" + expected_exit_code: 0 + + - name: "Help ends without a blank line" + # Pure shell (no tail/grep) so it also runs on Windows. The trailing "." + # keeps command substitution from stripping the newlines under test. + stdin: | + out=$("$BASH" --help; echo .) + out=${out%.} + [[ $out == *$'\n' && $out != *$'\n\n' ]] && echo ok + expected_stdout: "ok\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "Invalid option fails" + args: + - "--unknown-argument-here" + expected_exit_code: 2 + # Exit code 2 for argument parsing errors. + + - name: "Explicit config file not found fails" + removed_default_args: + - "--no-config" + args: + - "--config" + - "/nonexistent/path/to/config.toml" + - "-c" + - "echo should_not_run" + expected_exit_code: 1 + + - name: "Explicit config file valid" + removed_default_args: + - "--no-config" + test_files: + - path: "config.toml" + contents: | + [ui] + syntax-highlighting = false + + [experimental] + zsh-hooks = false + args: + - "--config" + - "config.toml" + - "-c" + - "echo config_loaded" + expected_exit_code: 0 + expected_stdout: "config_loaded\n" + + - name: "Config file with unknown fields succeeds" + removed_default_args: + - "--no-config" + test_files: + - path: "config.toml" + contents: | + [ui] + syntax-highlighting = true + future-setting = "ignored" + + [unknown-section] + foo = "bar" + args: + - "--config" + - "config.toml" + - "-c" + - "echo forward_compat" + expected_exit_code: 0 + expected_stdout: "forward_compat\n" + + - name: "--input-backend selects the minimal backend" + args: ["--input-backend", "minimal", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--input-backend takes an attached value" + args: ["--input-backend=minimal", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--input-backend selects the basic backend" + args: ["--input-backend", "basic", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + incompatible_platforms: ["windows", "wasi"] # basic is Unix-only + + - name: "--input-backend selects the vt backend" + args: ["--input-backend", "vt", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + incompatible_platforms: ["windows", "wasi"] # vt is Unix-only + + - name: "--input-backend selects the reedline backend" + args: ["--input-backend", "reedline", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + incompatible_platforms: ["wasi"] # reedline is not built for wasm + + - name: "--input-backend rejects an unknown backend" + args: ["--input-backend", "nope", "-c", "echo hi"] + expected_exit_code: 2 + expected_stdout: "" + + - name: "--input-backend needs a value" + args: ["--input-backend"] + expected_exit_code: 2 + expected_stdout: "" + + - name: "--debug enables an event class" + args: ["--debug", "commands", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--debug takes an attached event class" + args: ["--debug=commands", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--log-enable is another name for --debug" + args: ["--log-enable", "commands", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--debug rejects an unknown event class" + args: ["--debug", "nosuch", "-c", "echo hi"] + expected_exit_code: 2 + expected_stdout: "" + + - name: "--disable-event disables an event class" + args: ["--disable-event", "commands", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--log-disable is another name for --disable-event" + args: ["--log-disable", "commands", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--disable-event rejects an unknown event class" + args: ["--disable-event", "nosuch", "-c", "echo hi"] + expected_exit_code: 2 + expected_stdout: "" + + - name: "The caller's environment is inherited" + env: + FOO: "set" + args: ["-c", 'echo "${FOO-unset}"'] + expected_exit_code: 0 + expected_stdout: "set\n" + + - name: "--noenv starts without the caller's environment" + env: + FOO: "set" + args: ["--noenv", "-c", 'echo "${FOO-unset}"'] + expected_exit_code: 0 + expected_stdout: "unset\n" + + - name: "--sh turns on the posix option" + # sh mode has no echo builtin, and Windows and WASI have no echo program. + args: ["--sh", "-c", 'case :$SHELLOPTS: in *:posix:*) exit 0;; esac; exit 1'] + expected_exit_code: 0 + + - name: "--inherit-fd hands the shell a descriptor" + stdin: | + "$BASH" --inherit-fd 3 -c 'echo via3 >&3' 3>&1 + expected_exit_code: 0 + expected_stdout: "via3\n" + # Runs brush as a child process, which WASI 0.2 cannot do; fd 3 is a Unix notion. + incompatible_platforms: ["wasi", "windows"] + + - name: "--inherit-fd rejects a descriptor that is not a number" + args: ["--inherit-fd", "nope", "-c", "echo hi"] + expected_exit_code: 2 + expected_stdout: "" + + - name: "--enable-highlighting runs a -c command" + args: ["--enable-highlighting", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--enable-terminal-integration runs a -c command" + args: ["--enable-terminal-integration", "-c", "echo hi"] + expected_exit_code: 0 + expected_stdout: "hi\n" + + - name: "--version wins over a later -c" + stdin: | + out=$("$BASH" --version -c 'echo ran') + echo "status=$?" + echo "${out%% *}" + case $out in *ran*) echo "ran too" ;; esac + expected_exit_code: 0 + expected_stdout: "status=0\nbrush\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] + + - name: "--version wins over a later short option missing its value" + known_failure: true # brush fails with "`-o` needs a value" (exit 2) instead of printing its version + stdin: | + out=$("$BASH" --version -o 2>/dev/null) + echo "status=$?" + echo "${out%% *}" + expected_exit_code: 0 + expected_stdout: "status=0\nbrush\n" + # Runs brush as a child process, which WASI 0.2 cannot do. + incompatible_platforms: ["wasi"] diff --git a/userland/brush/source/brush-shell/tests/cases/brush/command_substitution.yaml b/userland/brush/source/brush-shell/tests/cases/brush/command_substitution.yaml new file mode 100644 index 0000000..810130d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/command_substitution.yaml @@ -0,0 +1,200 @@ +name: "Command substitution" +# These cases use only builtins, so they also run under WASI +# (`cargo xtask test integration --wasi`). +cases: + - name: "Modern and backtick forms" + stdin: | + echo "$(echo modern) `echo legacy`" + expected_exit_code: 0 + expected_stdout: | + modern legacy + expected_stderr: "" + + - name: "Trailing newlines are stripped, inner ones kept" + stdin: | + x=$(echo -e "a\n\nb\n\n\n") + echo "[$x]" + y=$(echo -n abc) + echo "[$y]" + z=$(true) + echo "[$z] ${#z}" + expected_exit_code: 0 + expected_stdout: | + [a + + b] + [abc] + [] 0 + expected_stderr: "" + + - name: "Nested substitutions" + stdin: | + echo "$(echo "outer $(echo "inner $(echo deepest)")")" + expected_exit_code: 0 + expected_stdout: | + outer inner deepest + expected_stderr: "" + + - name: "Exit status of a substitution" + stdin: | + x=$(false) + echo "status: $?" + x=$(exit 7) + echo "status: $?" + x=$(true) + echo "status: $?" + expected_exit_code: 0 + expected_stdout: | + status: 1 + status: 7 + status: 0 + expected_stderr: "" + + - name: "Assignments inside don't leak out" + stdin: | + v=1 + out=$(v=2; echo "$v") + echo "v=$v out=$out" + expected_exit_code: 0 + expected_stdout: | + v=1 out=2 + expected_stderr: "" + + - name: "Functions and loops inside" + stdin: | + f() { for i in 1 2 3; do echo "$i"; done; } + all=$(f) + echo $all + echo "$all" + expected_exit_code: 0 + expected_stdout: | + 1 2 3 + 1 + 2 + 3 + expected_stderr: "" + + - name: "Captures a pipeline's output" + stdin: | + x=$(echo "a b" | { read -r p q; echo "$q$p"; }) + echo "$x" + expected_exit_code: 0 + expected_stdout: | + ba + expected_stderr: "" + + - name: "Large output" + stdin: | + pad= + for ((i = 0; i < 10; i++)); do pad+=0123456789; done + x=$(for ((i = 0; i < 1000; i++)); do echo "line $i $pad"; done) + echo "${#x}" + echo "${x:0:8}" + echo "${x: -20}" + expected_exit_code: 0 + expected_stdout: | + 109889 + line 0 0 + 01234567890123456789 + expected_stderr: "" + + - name: "stderr is captured only when redirected" + stdin: | + x=$( { echo visible; echo captured >&2; } 2>&1 ) + echo "$x" + expected_exit_code: 0 + expected_stdout: | + visible + captured + expected_stderr: "" + + - name: "Word splitting of results" + stdin: | + set -- $(echo "a b c") + echo "$#" + set -- "$(echo "a b c")" + echo "$#" + expected_exit_code: 0 + expected_stdout: | + 3 + 1 + expected_stderr: "" + + - name: "Substitutions in arithmetic" + stdin: | + echo $(( $(echo 6) * $(echo 7) )) + expected_exit_code: 0 + expected_stdout: | + 42 + expected_stderr: "" + + - name: "Quotes and dollars inside" + stdin: | + echo "pre-$(echo 'mid "quoted" $notvar')-post" + expected_exit_code: 0 + expected_stdout: | + pre-mid "quoted" $notvar-post + expected_stderr: "" + + - name: "Reading a file" + test_files: + - path: "data.txt" + contents: | + first line + second line + stdin: | + x=$("; done < data.txt) + echo "$y" + expected_exit_code: 0 + expected_stdout: | + first line + second line + + + expected_stderr: "" + + - name: "Here-string inside a substitution" + stdin: | + x=$(read -r a b <<< "1 2"; echo "$b$a") + echo "$x" + expected_exit_code: 0 + expected_stdout: | + 21 + expected_stderr: "" + + - name: "Many substitutions in a loop" + stdin: | + s= + for i in {1..200}; do s+=$(echo -n "$((i % 10))"); done + echo "${#s} ${s:0:12}" + expected_exit_code: 0 + expected_stdout: | + 200 123456789012 + expected_stderr: "" + + - name: "Recursion through substitutions" + stdin: | + fib() { + local n=$1 + if (( n < 2 )); then echo "$n"; return; fi + echo $(( $(fib $((n - 1))) + $(fib $((n - 2))) )) + } + fib 12 + expected_exit_code: 0 + expected_stdout: | + 144 + expected_stderr: "" + + - name: "Builtin output" + stdin: | + t=$(type -t echo) + echo "$t" + k=$(type -t while) + echo "$k" + expected_exit_code: 0 + expected_stdout: | + builtin + keyword + expected_stderr: "" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/completion.yaml b/userland/brush/source/brush-shell/tests/cases/brush/completion.yaml new file mode 100644 index 0000000..39f6266 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/completion.yaml @@ -0,0 +1,18 @@ +name: "Completion" +cases: + - name: "compgen -W translates $'...' and $\"...\" as a command line would" + # Deliberately unlike bash, which translates them only when it parses a command line, so in + # -W's list (outside a command substitution) reads them as a literal `$` and an ordinary + # quote. + stdin: | + compgen -W "\$'a\\'b' \$'c\\td' \$\"e f\" g\$\"h\"" + compgen -W "\$'i j" + expected_stdout: "a'b\nc\td\ne f\ngh\ni j\n" + + - name: "compgen -W translates a $'...' after $$" + # Command substitution forks a subshell, which WASI doesn't support. (Compared in the + # shell, not with sed, which Windows lacks.) + incompatible_platforms: ["wasi"] + stdin: | + [[ $(compgen -W "\$\$\$'k'") == "$$k" ]] && echo PIDk + expected_stdout: "PIDk\n" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/external_commands.yaml b/userland/brush/source/brush-shell/tests/cases/brush/external_commands.yaml new file mode 100644 index 0000000..2290970 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/external_commands.yaml @@ -0,0 +1,172 @@ +name: "External commands" +# These cases run brush itself ("$0") as the external command, so that they need +# no other program and run on every platform that can start processes. +incompatible_platforms: ["wasi"] # (No processes to start) +cases: + - name: "Output goes to the shell's streams" + stdin: | + "$0" -c 'echo out; echo err >&2' + expected_exit_code: 0 + expected_stdout: | + out + expected_stderr: | + err + + - name: "Standard output redirected to the shell's standard error" + stdin: | + "$0" -c 'echo out' >&2 + expected_exit_code: 0 + expected_stdout: "" + expected_stderr: | + out + + - name: "Standard error redirected to the shell's standard output" + stdin: | + "$0" -c 'echo err >&2' 2>&1 + expected_exit_code: 0 + expected_stdout: | + err + expected_stderr: "" + + - name: "Standard output and error swapped" + stdin: | + "$0" -c 'echo out; echo err >&2' 3>&1 1>&2 2>&3 3>&- + expected_exit_code: 0 + expected_stdout: | + err + expected_stderr: | + out + + - name: "Output to files" + stdin: | + "$0" -c 'echo out; echo err >&2' > out.txt 2> err.txt + "$0" -c 'echo more' >> out.txt + mapfile -t lines < out.txt + read -r e < err.txt + echo "out: ${lines[*]}; err: ${e}" + expected_exit_code: 0 + expected_stdout: | + out: out more; err: err + expected_stderr: "" + + - name: "Closed standard output isn't the shell's" + stdin: | + "$0" -c 'echo hidden' >&- + echo after + expected_exit_code: 0 + expected_stdout: | + after + + - name: "Closed standard input can't be read" + stdin: | + "$0" -c 'read -r l; echo "read: $?"' <&- + expected_exit_code: 0 + expected_stdout: | + read: 1 + + - name: "Input from a here-document, a file, and a pipe" + stdin: | + echo from-file > in.txt + "$0" -c 'read -r l; echo "got: $l"' <&3' 3>&1 + expected_exit_code: 0 + expected_stdout: | + via-fd-3 + expected_stderr: "" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/help.yaml b/userland/brush/source/brush-shell/tests/cases/brush/help.yaml new file mode 100644 index 0000000..60d8097 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/help.yaml @@ -0,0 +1,33 @@ +name: "Help colors" +cases: + - name: "Help printed to a pipe isn't painted" + removed_default_args: ["--disable-color"] + stdin: | + help -s cd + expected_stdout: "cd: cd [-LPe@] [target_dir]\n" + + - name: "Help is painted where color is forced" + known_failure: true # TODO(help): `help -s` prints the synopsis unpainted + removed_default_args: ["--disable-color"] + stdin: | + CLICOLOR_FORCE=1 + help -s cd | cat -v + cd --help | head -n 1 | cat -v + expected_stdout: | + cd: cd [^[[1;35m-LPe@^[[0m] [^[[36mtarget_dir^[[0m] + cd: cd [^[[1;35m-LPe@^[[0m] [^[[36mtarget_dir^[[0m] + + - name: "NO_COLOR wins over forced color" + removed_default_args: ["--disable-color"] + stdin: | + CLICOLOR_FORCE=1 + NO_COLOR=1 + help -s cd + expected_stdout: "cd: cd [-LPe@] [target_dir]\n" + + # The harness runs brush with --disable-color. + - name: "--disable-color turns help colors off" + stdin: | + CLICOLOR_FORCE=1 + help -s cd + expected_stdout: "cd: cd [-LPe@] [target_dir]\n" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/here_documents.yaml b/userland/brush/source/brush-shell/tests/cases/brush/here_documents.yaml new file mode 100644 index 0000000..ffb10cf --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/here_documents.yaml @@ -0,0 +1,145 @@ +name: "Here-documents and here-strings" +# These cases use only builtins, so they also run under WASI +# (`cargo xtask test integration --wasi`). +cases: + - name: "Here-document into a read loop" + stdin: | + while read -r a b; do echo "$b-$a"; done <&2; } 2>&1 | while read -r l; do echo "got: $l"; done + { echo via-pipe-and >&2; } |& { read -r l; echo "got: $l"; } + expected_exit_code: 0 + expected_stdout: | + got: out + got: err + got: via-pipe-and + expected_stderr: "" + + - name: "mapfile reads a pipeline's output" + stdin: | + echo -e "x\ny\nz" | { mapfile -t lines; echo "${#lines[@]}: ${lines[*]}"; } + expected_exit_code: 0 + expected_stdout: | + 3: x y z + expected_stderr: "" + + - name: "A stage that ignores its input" + stdin: | + echo unused | true + echo "status: $?" + echo unused | echo "ignored input" + expected_exit_code: 0 + expected_stdout: | + status: 0 + ignored input + expected_stderr: "" + + - name: "Pipelines nested in subshells" + stdin: | + ( echo inner | { read -r v; echo "v=$v"; } ) | { read -r w; echo "w=$w"; } + expected_exit_code: 0 + expected_stdout: | + w=v=inner + expected_stderr: "" + + - name: "Pipelines in loop bodies and conditions" + stdin: | + for w in one two three; do echo "$w" | { read -r x; echo "${#x}"; }; done + if echo yes | { read -r a; [[ $a == yes ]]; }; then echo matched; fi + n=0 + while echo tick | { read -r t; [[ $t == tick ]]; } && (( n < 3 )); do n=$((n + 1)); done + echo "n=$n" + expected_exit_code: 0 + expected_stdout: | + 3 + 3 + 5 + matched + n=3 + expected_stderr: "" + + - name: "A long line through a pipe" + stdin: | + s= + for i in {1..1000}; do s+=0123456789; done + echo "$s" | { read -r t; echo "${#t}"; } + expected_exit_code: 0 + expected_stdout: | + 10000 + expected_stderr: "" + + - name: "eval and source in pipeline stages" + test_files: + - path: "lib.sh" + contents: | + greet() { echo "hello from lib"; } + stdin: | + eval 'echo from-eval' | { read -r l; echo "[$l]"; } + source ./lib.sh + greet | { read -r l; echo "[$l]"; } + expected_exit_code: 0 + expected_stdout: | + [from-eval] + [hello from lib] + expected_stderr: "" + + - name: "Coprocess round trip" + known_failure_platforms: ["wasi"] # TODO(wasm): the coprocess hasn't run when the shell reads its reply, and a read on wasm can't wait for it + stdin: | + coproc { read -r line; echo "co:$line"; } + echo ping >&"${COPROC[1]}" + read -r reply <&"${COPROC[0]}" + echo "reply=$reply" + expected_exit_code: 0 + expected_stdout: | + reply=co:ping + expected_stderr: "" + + - name: "An endless producer stops once its consumer is done" + # TODO(pipeline): hangs everywhere: a compound stage runs to completion before the next stage + # starts, so an endless producer never stops (natively, it blocks once the OS pipe fills). + skip: true + stdin: | + while true; do echo y; done | { read -r x; echo "got $x"; } + echo done + expected_exit_code: 0 + expected_stdout: | + got y + done diff --git a/userland/brush/source/brush-shell/tests/cases/brush/redirection.yaml b/userland/brush/source/brush-shell/tests/cases/brush/redirection.yaml new file mode 100644 index 0000000..331a104 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/redirection.yaml @@ -0,0 +1,170 @@ +name: "Redirection" +# These cases use only builtins, so they also run under WASI +# (`cargo xtask test integration --wasi`). +cases: + - name: "Write, append, and read back a file" + stdin: | + echo one > out.txt + echo two >> out.txt + while read -r l; do echo "read: $l"; done < out.txt + expected_exit_code: 0 + expected_stdout: | + read: one + read: two + expected_stderr: "" + + - name: "> truncates" + stdin: | + echo first > f.txt + echo second > f.txt + read -r v < f.txt + echo "$v" + expected_exit_code: 0 + expected_stdout: | + second + expected_stderr: "" + + - name: "stderr to a file" + stdin: | + { echo to-err >&2; } 2> err.txt + read -r e < err.txt + echo "captured: $e" + expected_exit_code: 0 + expected_stdout: | + captured: to-err + expected_stderr: "" + + - name: "&> and &>> send both streams" + stdin: | + { echo o; echo e >&2; } &> both.txt + { echo o2; echo e2 >&2; } &>> both.txt + mapfile -t lines < both.txt + echo "${#lines[@]}: ${lines[*]}" + expected_exit_code: 0 + expected_stdout: | + 4: o e o2 e2 + expected_stderr: "" + + - name: "Duplicated file descriptors" + stdin: | + { echo moved >&3; } 3>&1 + { echo to-file >&4; } 4> fd4.txt + read -r v < fd4.txt + echo "$v" + expected_exit_code: 0 + expected_stdout: | + moved + to-file + expected_stderr: "" + + - name: "Reading test files" + test_files: + - path: "input.txt" + contents: | + alpha + beta + gamma + stdin: | + mapfile -t rows < input.txt + echo "${#rows[@]}: ${rows[-1]}" + while read -r r; do echo "${r^^}"; done < input.txt > upper.txt + mapfile -t up < upper.txt + echo "${up[*]}" + expected_exit_code: 0 + expected_stdout: | + 3: gamma + ALPHA BETA GAMMA + expected_stderr: "" + + - name: "noclobber" + stdin: | + set -C + echo a > c.txt + { echo b > c.txt; } 2> err.txt + echo "status: $?" + read -r v < c.txt + echo "$v" + echo c >| c.txt + read -r v < c.txt + echo "$v" + expected_exit_code: 0 + expected_stdout: | + status: 1 + a + c + expected_stderr: "" + + - name: "Redirecting a whole loop" + stdin: | + for i in 1 2 3; do echo "$i"; done > loop.txt + mapfile -t a < loop.txt + echo "${a[*]}" + expected_exit_code: 0 + expected_stdout: | + 1 2 3 + expected_stderr: "" + + - name: "Redirecting a function's output" + stdin: | + f() { echo "from f"; echo "err from f" >&2; } + f > f-out.txt 2> f-err.txt + read -r o < f-out.txt + read -r e < f-err.txt + echo "$o / $e" + expected_exit_code: 0 + expected_stdout: | + from f / err from f + expected_stderr: "" + + - name: "Missing input file" + stdin: | + read -r v < missing.txt 2> err.txt + echo "status: $?" + expected_exit_code: 0 + expected_stdout: | + status: 1 + + - name: "Sourcing a file" + test_files: + - path: "lib.sh" + contents: | + lib_var=loaded + lib_fn() { echo "lib_fn: $1"; } + stdin: | + source ./lib.sh + echo "$lib_var" + lib_fn arg + . ./lib.sh + echo "sourced twice" + expected_exit_code: 0 + expected_stdout: | + loaded + lib_fn: arg + sourced twice + expected_stderr: "" + + - name: "Process substitution as input" + known_failure_platforms: ["wasi"] # TODO(wasm): the substitution's command hasn't run when the read happens, and a read on wasm can't wait for it + stdin: | + while read -r l; do echo "got $l"; done < <(echo hi; echo there) + read -r x < <(echo hello) + echo "x=$x" + expected_exit_code: 0 + expected_stdout: | + got hi + got there + x=hello + expected_stderr: "" + + - name: "Redirecting to /dev/null" + known_failure_platforms: ["wasi"] # TODO(wasm): the WASI sandbox has no /dev/null, and brush doesn't provide one + stdin: | + echo hidden > /dev/null + echo "write status: $?" + read -r v < /dev/null + echo "read status: $?" + expected_exit_code: 0 + expected_stdout: | + write status: 0 + read status: 1 + expected_stderr: "" diff --git a/userland/brush/source/brush-shell/tests/cases/brush/scripting.yaml b/userland/brush/source/brush-shell/tests/cases/brush/scripting.yaml new file mode 100644 index 0000000..fa7d899 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/scripting.yaml @@ -0,0 +1,412 @@ +name: "Scripting basics" +# These cases use only builtins, so they also run under WASI +# (`cargo xtask test integration --wasi`). +cases: + - name: "Arithmetic" + stdin: | + echo $(( (7 + 3) * 2 ** 3 % 7 )) $(( 17 / 5 )) $(( -17 % 5 )) $(( 1 << 10 )) $(( 0x1f )) $(( 8#17 )) + let x=5*5 + echo "$x" + (( y = 10, y += 5 )) + echo "$y" + (( 0 )) + echo "status: $?" + expected_exit_code: 0 + expected_stdout: | + 3 3 -2 1024 31 15 + 25 + 15 + status: 1 + expected_stderr: "" + + - name: "Indexed arrays" + stdin: | + a=(zero one two) + a+=(three) + echo "${#a[@]} ${a[2]} ${a[-1]} ${!a[*]}" + unset 'a[1]' + echo "${a[*]} / ${!a[*]}" + b=("${a[@]:1:2}") + echo "${b[*]}" + expected_exit_code: 0 + expected_stdout: | + 4 two three 0 1 2 3 + zero two three / 0 2 3 + two three + expected_stderr: "" + + - name: "Associative arrays" + stdin: | + declare -A m=([b]=2 [a]=1) + m[c]=3 + for k in a b c; do echo -n "$k=${m[$k]} "; done + echo + echo "${#m[@]}" + [[ -n ${m[a]+set} ]] && echo "has a" + unset 'm[a]' + [[ -z ${m[a]+set} ]] && echo "no a" + expected_exit_code: 0 + expected_stdout: "a=1 b=2 c=3 \n3\nhas a\nno a\n" + expected_stderr: "" + + - name: "Parameter expansion" + stdin: | + s="Hello, World" + echo "${s,,} ${s^^} ${#s}" + echo "${s:7} ${s:0:5} ${s: -3}" + echo "${s/World/There} ${s//l/L}" + echo "${s#*, } ${s%,*} ${s##*o} ${s%%o*}" + unset u + echo "${u:-default} ${u:=assigned} $u ${u:+alt}" + expected_exit_code: 0 + expected_stdout: | + hello, world HELLO, WORLD 12 + World Hello rld + Hello, There HeLLo, WorLd + World Hello rld Hell + default assigned assigned alt + expected_stderr: "" + + - name: "Functions, locals, and dynamic scope" + stdin: | + g() { echo "g sees $v"; } + f() { local v=inner; g; return 3; } + v=global + f + echo "status: $? after: $v" + expected_exit_code: 0 + expected_stdout: | + g sees inner + status: 3 after: global + expected_stderr: "" + + - name: "case statements" + stdin: | + for w in apple Banana cherry 42 ""; do + case $w in + a*) echo "$w: starts with a" ;; + [Bb]anana) echo "$w: banana" ;; + *rr*) echo "$w: double r" ;& + [0-9]*) echo "$w: falls through or digits" ;; + "") echo "empty" ;; + esac + done + expected_exit_code: 0 + expected_stdout: | + apple: starts with a + Banana: banana + cherry: double r + cherry: falls through or digits + 42: falls through or digits + empty + expected_stderr: "" + + - name: "Loops with break and continue" + stdin: | + for ((i = 0; i < 10; i++)); do + (( i % 2 )) && continue + (( i > 6 )) && break + echo -n "$i " + done + echo + n=3 + until (( n == 0 )); do echo -n "$n "; n=$((n - 1)); done + echo + for x in a b; do for y in 1 2; do [[ $y == 2 ]] && continue 2; echo "$x$y"; done; done + expected_exit_code: 0 + expected_stdout: "0 2 4 6 \n3 2 1 \na1\nb1\n" + expected_stderr: "" + + - name: "Extended tests" + stdin: | + [[ abc == a* ]] && echo glob + [[ x123 =~ ^x([0-9]+)$ ]] && echo "${BASH_REMATCH[1]}" + [[ -z "" && -n x ]] && echo "empty and nonempty" + [[ 10 -gt 9 && b > a ]] && echo compare + expected_exit_code: 0 + expected_stdout: | + glob + 123 + empty and nonempty + compare + expected_stderr: "" + + - name: "-ef tells files apart" + known_failure_platforms: ["wasi", "windows"] # TODO(test): -ef has no device and inode to compare on Windows, and on wasm every file has the same ones + test_files: + - path: "a.txt" + contents: | + a + - path: "b.txt" + contents: | + b + stdin: | + [[ a.txt -ef b.txt ]] && echo "a.txt is b.txt" || echo "a.txt isn't b.txt" + [[ a.txt -ef ./a.txt ]] && echo "a.txt is ./a.txt" || echo "a.txt isn't ./a.txt" + expected_exit_code: 0 + expected_stdout: | + a.txt isn't b.txt + a.txt is ./a.txt + expected_stderr: "" + + - name: "test and [" + test_files: + - path: "exists.txt" + contents: | + x + stdin: | + [ 3 -lt 5 ] && echo lt + test -n "x" && echo nonempty + [ -f exists.txt ] && echo file + [ -e missing.txt ] || echo missing + [ "a" = "b" ] + echo "status: $?" + expected_exit_code: 0 + expected_stdout: | + lt + nonempty + file + missing + status: 1 + expected_stderr: "" + + - name: "getopts" + stdin: | + parse() { + local OPTIND opt + while getopts "ab:" opt; do + case $opt in + a) echo "flag a" ;; + b) echo "b=$OPTARG" ;; + esac + done + shift $((OPTIND - 1)) + echo "rest: $*" + } + parse -a -b val one two + expected_exit_code: 0 + expected_stdout: | + flag a + b=val + rest: one two + expected_stderr: "" + + - name: "printf" + stdin: | + type -t printf + printf '%s=%d|%5.2f|%x\n' answer 42 3.14159 255 + printf -v greeting 'hi, %s' world + echo "$greeting" + set_in_caller() { printf -v "$1" %s "$2"; } + f() { local out; set_in_caller out 'from callee'; echo "$out"; } + f + declare -a arr=() + printf -v 'arr[1]' %s second + echo "${arr[1]}" + expected_exit_code: 0 + expected_stdout: | + builtin + answer=42| 3.14|ff + hi, world + from callee + second + expected_stderr: "" + + - name: "set -e stops at the first failure" + stdin: | + set -e + echo before + false + echo unreachable + expected_exit_code: 1 + expected_stdout: | + before + expected_stderr: "" + + - name: "EXIT trap" + stdin: | + trap 'echo bye' EXIT + echo hi + expected_exit_code: 0 + expected_stdout: | + hi + bye + expected_stderr: "" + + - name: "eval and indirect expansion" + stdin: | + name=target + target=value + echo "${!name}" + eval "dyn_$name=42" + echo "$dyn_target" + expected_exit_code: 0 + expected_stdout: | + value + 42 + expected_stderr: "" + + - name: "Brace expansion" + stdin: | + echo {a,b}{1,2} {1..5} {10..0..5} {a..e} + expected_exit_code: 0 + expected_stdout: | + a1 a2 b1 b2 1 2 3 4 5 10 5 0 a b c d e + expected_stderr: "" + + - name: "Globbing" + test_files: + - path: "a.txt" + contents: "" + - path: "b.txt" + contents: "" + - path: "c.log" + contents: "" + stdin: | + echo *.txt + for f in *.log; do echo "log: $f"; done + echo none: *.xyz + shopt -s nullglob + echo none: *.xyz + expected_exit_code: 0 + expected_stdout: | + a.txt b.txt + log: c.log + none: *.xyz + none: + expected_stderr: "" + + - name: "Quoting" + stdin: | + x=val + echo 'single $x' "double $x $((1 + 1))" $'ansi\ttab' "esc \"q\" \$x" + expected_exit_code: 0 + expected_stdout: "single $x double val 2 ansi\ttab esc \"q\" $x\n" + expected_stderr: "" + + - name: "Subshells isolate state" + stdin: | + x=1 + (x=2; echo "inner $x") + echo "outer $x" + expected_exit_code: 0 + expected_stdout: | + inner 2 + outer 1 + expected_stderr: "" + + - name: "Aliases" + stdin: | + shopt -s expand_aliases + alias greet='echo hello' + greet world + expected_exit_code: 0 + expected_stdout: | + hello world + expected_stderr: "" + + - name: "Positional parameters and shift" + stdin: | + set -- a b c d + shift 2 + echo "$# $1 $*" + f() { echo "$#: $1 | $2"; } + f "x y" z + expected_exit_code: 0 + expected_stdout: | + 2 c c d + 2: x y | z + expected_stderr: "" + + - name: "read options" + stdin: | + read -r -a arr <<< "p q r" + echo "${arr[1]}" + IFS=, read -r a b <<< "1,2" + echo "$a+$b" + read -r -n 3 c <<< "abcdef" + echo "$c" + read -r -d ';' d <<< "x;y" + echo "$d" + expected_exit_code: 0 + expected_stdout: | + q + 1+2 + abc + x + expected_stderr: "" + + - name: "mapfile options" + stdin: | + mapfile -t -s 1 -n 2 arr <<< $'h\na\nb\nc' + echo "${arr[*]}" + expected_exit_code: 0 + expected_stdout: | + a b + expected_stderr: "" + + - name: "type and command -v" + stdin: | + f() { :; } + type -t echo + type -t if + type -t f + command -v echo + expected_exit_code: 0 + expected_stdout: | + builtin + keyword + function + echo + expected_stderr: "" + + - name: "type and command -v of a missing command" + stdin: | + type -t no-such-command; echo "type: $?" + command -v no-such-command; echo "command -v: $?" + [ -x /no/such/file ]; echo "test -x: $?" + expected_exit_code: 0 + expected_stdout: | + type: 1 + command -v: 1 + test -x: 1 + expected_stderr: "" + + - name: "test -r and -w of a missing file" + stdin: | + [ -r /no/such/file ]; echo "test -r: $?" + [ -w /no/such/file ]; echo "test -w: $?" + expected_exit_code: 0 + expected_stdout: | + test -r: 1 + test -w: 1 + expected_stderr: "" + + - name: "$$ expands to the shell's process ID" + known_failure_platforms: ["wasi"] # TODO(wasm): wasm has no process IDs, and expanding $$ panics + stdin: | + echo "pid set: ${$:+yes}" + x=$$ + echo "numeric: $(( x > 0 ))" + expected_exit_code: 0 + expected_stdout: | + pid set: yes + numeric: 1 + + - name: "An unknown command exits with 127" + stdin: | + no_such_command_xyz 2> err.txt + echo "status: $?" + expected_exit_code: 0 + expected_stdout: | + status: 127 + + - name: "Self-referencing variable in arithmetic" + known_failure_platforms: ["wasi", "windows"] # TODO(arithmetic): the recursion overflows the stack (wasm's, or the main thread's on Windows) before brush's depth limit stops it + stdin: | + a=a + echo "$(( a ))" + echo "exit: $?" + expected_exit_code: 0 + expected_stdout: | + exit: 1 diff --git a/userland/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap b/userland/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap new file mode 100644 index 0000000..20aa8f0 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap @@ -0,0 +1,12 @@ +--- +source: brush-test-harness/src/runner.rs +expression: snapshot_content +--- +exit_code: 0 +stdout: | + hello +stderr: "" +files: + trace.txt: | + + echo hello + + true diff --git a/userland/brush/source/brush-shell/tests/cases/brush/tracing.yaml b/userland/brush/source/brush-shell/tests/cases/brush/tracing.yaml new file mode 100644 index 0000000..3a26092 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/tracing.yaml @@ -0,0 +1,14 @@ +name: "Tracing tests" +cases: + - name: "xtrace-file captures trace output" + args: ["--xtrace-file", "trace.txt"] + stdin: | + echo hello + true + expected_exit_code: 0 + snapshot: true + # std::fs::File::try_clone() returns "operation not supported" on + # wasm32-wasip3, so brush's OpenFile::clone() falls back to a failing + # writer and the trace file ends up empty. Affects all output redirection + # under wasi, not just xtrace. + incompatible_platforms: ["wasi"] diff --git a/userland/brush/source/brush-shell/tests/cases/brush/zsh_hooks.yaml b/userland/brush/source/brush-shell/tests/cases/brush/zsh_hooks.yaml new file mode 100644 index 0000000..1f8d34e --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/brush/zsh_hooks.yaml @@ -0,0 +1,626 @@ +name: "zsh-style hook tests" +# An interactive shell writes "exit" to stderr on the way out, so `expected_stderr` is +# asserted only where stderr is itself the thing under test -- a case claiming a hook is +# skipped "quietly", or one pinning a diagnostic. Elsewhere it is left off, and the +# harness ignores it. +cases: + # The hooks only fire from the interactive read loop. A shell reading commands from a + # pipe does *not* qualify, even though it reads them from stdin the same way -- so the + # bash-preexec inclusion guards must stay unclaimed and the arrays undeclared. Claiming + # them here would tell an integration that hooks are handled while nothing runs them, + # and firing hooks here lets a hook that reads stdin consume the script itself. + - name: "zsh hooks stay inert in a non-interactive shell" + args: + - "--enable-zsh-hooks" + - "-s" + stdin: | + preexec() { echo "PREEXEC-FIRED"; } + precmd() { echo "PRECMD-FIRED"; } + echo "guard=[${bash_preexec_imported:-unset}]" + echo "legacy=[${__bp_imported:-unset}]" + echo "precmds=[${precmd_functions[*]:-unset}]" + echo "preexecs=[${preexec_functions[*]:-unset}]" + expected_exit_code: 0 + expected_stdout: | + guard=[unset] + legacy=[unset] + precmds=[unset] + preexecs=[unset] + + # A hook that reads stdin must not be able to eat the script being piped in. + - name: "non-interactive zsh hooks cannot consume piped script lines" + args: + - "--enable-zsh-hooks" + - "-s" + stdin: | + precmd() { read -r junk; echo "ATE[$junk]"; } + precmd_functions=(precmd) + echo one + echo two + echo three + expected_exit_code: 0 + expected_stdout: | + one + two + three + + # `-i` makes the shell interactive in the `$-` sense, but a script still runs instead of + # the interactive loop; nothing may be claimed. + - name: "zsh hooks stay inert when -i runs a script" + args: + - "--enable-zsh-hooks" + - "-i" + - "script.sh" + test_files: + - path: "script.sh" + contents: | + echo "guard=[${bash_preexec_imported:-unset}] arrays=[${precmd_functions[*]:-unset}|${preexec_functions[*]:-unset}]" + expected_exit_code: 0 + expected_stdout: | + guard=[unset] arrays=[unset|unset] + + # `-c` runs a command instead of the interactive loop; nothing may be claimed. + - name: "zsh hooks stay inert when -c runs a command" + args: + - "--enable-zsh-hooks" + - "-c" + - 'echo "guard=[${bash_preexec_imported:-unset}] arrays=[${precmd_functions[*]:-unset}|${preexec_functions[*]:-unset}]"' + expected_exit_code: 0 + expected_stdout: | + guard=[unset] arrays=[unset|unset] + + # Without `-s` a shell fed by a pipe reads commands the same way; still not interactive. + - name: "zsh hooks stay inert reading a pipe without -s" + args: + - "--enable-zsh-hooks" + stdin: | + precmd() { echo "PRECMD-FIRED"; } + echo "guard=[${bash_preexec_imported:-unset}] arrays=[${precmd_functions[*]:-unset}]" + expected_exit_code: 0 + expected_stdout: | + guard=[unset] arrays=[unset] + + # `-i` makes a pipe-fed shell interactive, and that is the whole test: the contract is + # claimed before the first line runs, both hooks fire (precmd ahead of every prompt, + # including the one for EOF), each sees the last command's status, and neither hook's + # own non-zero return leaks into the next command's `$?` or `PIPESTATUS`. + - name: "zsh hooks fire when -i makes a pipe-fed shell interactive" + args: + - "--enable-zsh-hooks" + - "-i" + - "-s" + stdin: | + echo "guard=[${bash_preexec_imported:-unset}] arrays=[${precmd_functions[*]}|${preexec_functions[*]}]" + preexec() { echo "PREEXEC[$1]"; return 8; }; precmd() { echo "PRECMD[$?][${PIPESTATUS[*]}]"; return 9; } + (exit 4) | (exit 5) + echo "AFTER[$?][${PIPESTATUS[*]}]" + expected_exit_code: 0 + expected_stdout: | + guard=[defined] arrays=[precmd|preexec] + PRECMD[0][0] + PREEXEC[(exit 4) | (exit 5)] + PRECMD[5][4 5] + PREEXEC[echo "AFTER[$?][${PIPESTATUS[*]}]"] + AFTER[5][4 5] + PRECMD[0][0] + expected_stderr: | + exit + # A pipeline forks a subshell per stage, which WASI doesn't support. + incompatible_platforms: ["wasi"] + + # The guards and hook arrays are assigned the way `name=value` assigns, so a variable + # that already exists -- inherited from the environment, say -- keeps its attributes + # while taking the new value. It is not replaced wholesale. + - name: "zsh hook state is assigned without clobbering attributes" + args: + - "--enable-zsh-hooks" + - "-i" + - "-s" + env: + bash_preexec_imported: stale + precmd_functions: stale + stdin: | + declare -p bash_preexec_imported precmd_functions + expected_exit_code: 0 + expected_stdout: | + declare -x bash_preexec_imported="defined" + declare -ax precmd_functions=([0]="precmd") + + # `$?` and `PIPESTATUS` are restored before every hook, not just the first: a hook that + # runs a pipeline of its own doesn't change what the next one sees. There is no + # `BP_PIPESTATUS` copy; bash-preexec offers one only because it can't restore the original. + - name: "zsh hooks each see the command's own status and PIPESTATUS" + args: + - "--enable-zsh-hooks" + - "-i" + - "-s" + stdin: | + first() { echo "FIRST[$?][${PIPESTATUS[*]}][${BP_PIPESTATUS-unset}]"; true | false | true; } + second() { echo "SECOND[$?][${PIPESTATUS[*]}]"; } + precmd_functions=(first second); preexec_functions=(first second) + (exit 3) | (exit 4) + expected_exit_code: 4 + expected_stdout: | + FIRST[0][0][unset] + SECOND[0][0] + FIRST[0][0][unset] + SECOND[0][0] + FIRST[4][3 4][unset] + SECOND[4][3 4] + # A pipeline of subshells forks each stage, which WASI doesn't support. + incompatible_platforms: ["wasi"] + + # The command has already been entered into history when preexec runs. Integrations that + # inline bash-preexec commonly recover the command this way instead of trusting `$1`. + # + # The entry's parsed with `read` rather than `sed`, since this suite (unlike the compat + # suite) also runs on hosts -- Windows, notably -- with no `sed` on `PATH`. + - name: "zsh preexec sees the command in history" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { local idx rest; read -r idx rest <<< "$(history 1)"; echo "HISTORY[$rest]"; } + echo from-history + expected_exit_code: 0 + expected_stdout: | + HISTORY[echo from-history] + from-history + # Command substitution forks a subshell to run `history 1`, which WASI doesn't + # support. + incompatible_platforms: ["wasi"] + + # Hook errors are reported but do not abort the shell, overwrite the previous status, or + # prevent later hooks from running. + - name: "zsh precmd failures are non-fatal and preserve status" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { local -r ro=1; ro=2; }; later() { echo LATER-RAN; }; precmd_functions=(precmd later) + (exit 6) + echo "AFTER[$?]" + expected_exit_code: 0 + expected_stdout: | + LATER-RAN + LATER-RAN + AFTER[6] + LATER-RAN + expected_stderr: | + error: cannot mutate readonly variable + error: cannot mutate readonly variable + error: cannot mutate readonly variable + exit + + - name: "zsh preexec failures are non-fatal" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { local -r ro=1; ro=2; } + echo STILL-RAN + expected_exit_code: 0 + expected_stdout: | + STILL-RAN + expected_stderr: | + error: cannot mutate readonly variable + exit + + # A name that resolves to nothing is quiet, so a hook may be registered before it is + # defined -- matching bash-preexec, which guards each entry with `type -t`. + - name: "zsh hook registries skip names that resolve to nothing" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd_functions=(not_defined) + not_defined() { echo LATE-RAN; } + echo QUIET + expected_exit_code: 0 + expected_stdout: | + LATE-RAN + QUIET + LATE-RAN + # Asserted, because "quietly" is the whole claim: nothing but the interactive + # shell's own farewell may reach stderr. + expected_stderr: | + exit + + # Every non-function entry is ignored the same way, whatever it happens to name: nothing + # at all, a directory, a file without the execute bit. There is no category that gets a + # diagnostic, so stderr is asserted rather than waved past -- and with no message there + # is no OS-specific errno text to make the expectation unportable. + - name: "zsh hook names that are not functions are skipped quietly" + args: ["--enable-zsh-hooks", "-i", "-s"] + test_files: + - path: "not-executable" + contents: | + #!/bin/sh + echo SHOULD-NOT-RUN + - path: "a-directory/keep" + contents: "placeholder so the directory exists\n" + stdin: | + later() { echo LATER-RAN; }; precmd_functions=(./not-executable ./a-directory later) + echo REAL + expected_exit_code: 0 + expected_stdout: | + LATER-RAN + REAL + LATER-RAN + expected_stderr: | + exit + + # Only shell functions are dispatched -- as zsh does, and deliberately unlike bash-preexec, + # whose `type -t` guard also admits builtins and externals. Rationale in + # docs/reference/zsh-hooks.md, under `Differences`. + - name: "zsh hooks run shell functions only, ignoring builtins and programs" + args: ["--enable-zsh-hooks", "-i", "-s"] + test_files: + - path: "external-hook" + executable: true + contents: | + #!/bin/sh + echo "EXTERNAL-RAN[$#][$1]" + stdin: | + real() { echo "FUNCTION-RAN[$#][$1]"; }; precmd_functions=(./external-hook echo true real); preexec_functions=(./external-hook echo true real) + echo REAL + expected_exit_code: 0 + expected_stdout: | + FUNCTION-RAN[0][] + FUNCTION-RAN[1][echo REAL] + REAL + FUNCTION-RAN[0][] + expected_stderr: | + exit + + # A function shadowing a builtin name is still dispatched: the rule is "look the name up as + # a function", not "reject names that are builtins". zsh behaves the same way. + - name: "zsh hooks dispatch a function that shadows a builtin" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + true() { echo SHADOWING-FUNCTION-RAN; }; precmd_functions=(true) + echo REAL + expected_exit_code: 0 + expected_stdout: | + SHADOWING-FUNCTION-RAN + REAL + SHADOWING-FUNCTION-RAN + expected_stderr: | + exit + + # A hook name is invoked as a single already-expanded word, so a name holding spaces or + # glob characters is looked up verbatim -- and, finding nothing, skipped quietly. This is + # bash-preexec's behavior too: it invokes `"$precmd_function"`, quoted. + - name: "zsh hook names are not re-expanded" + args: ["--enable-zsh-hooks", "-i", "-s"] + test_files: + - path: "hook with space" + executable: true + contents: | + #!/bin/sh + echo SHOULD-NOT-RUN + stdin: | + precmd_functions=("echo hi" "*") + echo QUIET + expected_exit_code: 0 + expected_stdout: | + QUIET + expected_stderr: | + exit + + # Additional array entries receive the preexec command line, and both registries run in + # array order. Replacing the array removes the seeded bare hook like any other entry. + - name: "zsh hook arrays pass arguments and preserve order" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { echo BARE-PREEXEC; }; precmd() { echo BARE-PRECMD; }; pre_a() { echo "PRE-A[$1]"; }; pre_b() { echo "PRE-B[$1]"; }; post_a() { echo "POST-A[$?]"; }; post_b() { echo "POST-B[$?]"; }; preexec_functions=(pre_a pre_b); precmd_functions=(post_a post_b) + (exit 7) + expected_exit_code: 7 + expected_stdout: | + POST-A[0] + POST-B[0] + PRE-A[(exit 7)] + PRE-B[(exit 7)] + POST-A[7] + POST-B[7] + + # Each registry is read once per dispatch, so a hook that edits it is heeded from the next + # dispatch on -- never mid-loop. `added` is appended during the second dispatch but doesn't + # run until the third. + - name: "zsh hook registry edits take effect on the next dispatch" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + grower() { echo GROWER; precmd_functions+=(added); }; added() { echo ADDED; }; precmd_functions=(grower) + echo COMMAND + expected_exit_code: 0 + expected_stdout: | + GROWER + COMMAND + GROWER + ADDED + + # Claiming the inclusion guards makes a subsequently sourced bash-preexec implementation + # return before installing its own dispatch machinery. The preamble below is verbatim + # from bash-preexec v0.7.0 (MIT; https://github.com/rcaloras/bash-preexec), so this + # exercises the real bail-out path rather than a hand-written approximation of it -- + # including the two version checks that run *ahead* of the guard, which brush must also + # satisfy for the guard to matter at all. Everything after it stands in for the ~500 + # lines that install the `DEBUG` trap and rewrite `PROMPT_COMMAND`; none of it may run. + - name: "sourcing bash-preexec after initialization is a no-op" + args: ["--enable-zsh-hooks", "-i", "-s"] + test_files: + - path: "bash-preexec.sh" + contents: | + # Make sure this is bash that's running and return otherwise. + # Use POSIX syntax for this line: + if [ -z "${BASH_VERSION-}" ]; then + return 1 + fi + + # We only support Bash 3.1+. + # Note: BASH_VERSINFO is first available in Bash-2.0. + if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then + return 1 + fi + + # Avoid duplicate inclusion + if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then + return 0 + fi + bash_preexec_imported="defined" + + # WARNING: This variable is no longer used and should not be relied upon. + # Use ${bash_preexec_imported} instead. + # shellcheck disable=SC2034 + __bp_imported="${bash_preexec_imported}" + + echo BASH-PREEXEC-INSTALLED + trap 'echo BASH-PREEXEC-DEBUG-TRAP' DEBUG + stdin: | + source ./bash-preexec.sh + echo "SOURCED[$?] GUARDS[$bash_preexec_imported|$__bp_imported]" + expected_exit_code: 0 + expected_stdout: | + SOURCED[0] GUARDS[defined|defined] + expected_stderr: | + exit + + # `exit` from a hook terminates the shell immediately. Later hooks, PROMPT_COMMAND, and the + # pending user command do not run. + - name: "exit from zsh precmd stops remaining prompt actions" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + first() { exit 7; }; never() { echo NEVER-HOOK; }; precmd_functions=(first never); PROMPT_COMMAND='echo NEVER-PROMPT' + echo NEVER-COMMAND + expected_exit_code: 7 + expected_stdout: "" + expected_stderr: | + exit + + - name: "exit from zsh preexec stops the command" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { exit 5; } + echo NEVER-COMMAND + expected_exit_code: 5 + expected_stdout: "" + expected_stderr: | + exit + + - name: "exit from PROMPT_COMMAND exits the interactive shell" + args: ["-i", "-s"] + stdin: | + PROMPT_COMMAND='exit 3' + echo NEVER-COMMAND + expected_exit_code: 3 + expected_stdout: "" + expected_stderr: | + exit + + # Hook state belongs to this shell only. Exporting it would incorrectly tell a child bash + # that bash-preexec is installed there too. + - name: "zsh hook guards and registries are not exported" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + case "$(export -p)" in *bash_preexec_imported*|*__bp_imported*|*precmd_functions*|*preexec_functions*) echo EXPORTED;; *) echo NOT-EXPORTED;; esac + echo "guards=[$bash_preexec_imported|$__bp_imported]" + expected_exit_code: 0 + expected_stdout: | + NOT-EXPORTED + guards=[defined|defined] + # Command substitution forks a subshell to run `export -p`, which WASI doesn't + # support. + incompatible_platforms: ["wasi"] + + # A line that runs no command doesn't invoke preexec: bash-preexec dispatches from the + # DEBUG trap, which fires per command, so blank lines, whitespace, and comment-only lines + # produce nothing. A comment *after* a command is still a command line, so it does. + - name: "zsh preexec skips lines that run no command" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: "preexec() { echo \"PREEXEC[$1]\"; }\n\n \n# just a comment\n # indented comment\necho REAL # trailing comment\n" + expected_exit_code: 0 + expected_stdout: | + PREEXEC[echo REAL # trailing comment] + REAL + + # A line that doesn't parse runs no command either, so it invokes no preexec. + - name: "zsh preexec skips a syntax error" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { echo "PREEXEC[$1]"; } + echo ) + echo REAL + expected_exit_code: 0 + expected_stdout: | + PREEXEC[echo REAL] + REAL + expected_stderr: | + error: main: syntax error at line 1 col 6 + exit + + # A line that only defines a function runs no command: bash's `DEBUG` trap does not fire + # for `other() { ...; }`, so a `preexec` already registered must not dispatch for it either. + # A line that both defines a function *and* runs a command still counts as running one. + - name: "zsh preexec skips a line that only defines a function" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { echo "PREEXEC[$1]"; } + other() { echo "NEVER-CALLED"; } + other() { echo "NEVER-CALLED"; }; echo REAL + expected_exit_code: 0 + expected_stdout: | + PREEXEC[other() { echo "NEVER-CALLED"; }; echo REAL] + REAL + + # `$_` is restored alongside `$?` and `PIPESTATUS`: before every hook, so each one sees the + # last *user* command's last argument rather than an earlier hook's, and again afterwards, + # so the next command does too. The hooks report only when `$_` holds the sentinel, which + # keeps the earlier dispatches -- whose `$_` is the shell's own path -- out of the + # expectation. + - name: "zsh hooks preserve the last argument in $_" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { case $_ in USER-ARG) echo "PRECMD-SEES[$_]";; esac; true PRECMD-ARG; }; preexec() { case $_ in USER-ARG) echo "PREEXEC-SEES[$_]";; esac; true PREEXEC-ARG; } + true USER-ARG + echo "UNDERSCORE[$_]" + expected_exit_code: 0 + expected_stdout: | + PRECMD-SEES[USER-ARG] + PREEXEC-SEES[USER-ARG] + UNDERSCORE[USER-ARG] + + # With the feature disabled, neither the contract nor dispatch is active even in an + # interactive shell. + - name: "zsh hooks are inert when disabled" + args: ["-i", "-s"] + stdin: | + precmd() { echo PRECMD-RAN; }; preexec() { echo PREEXEC-RAN; } + echo "state=[${bash_preexec_imported:-unset}|${__bp_imported:-unset}|${precmd_functions[*]:-unset}|${preexec_functions[*]:-unset}]" + expected_exit_code: 0 + expected_stdout: | + state=[unset|unset|unset|unset] + + # Native precmd dispatch occurs before PROMPT_COMMAND, matching bash-preexec's dispatcher. + - name: "zsh precmd runs before PROMPT_COMMAND" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { echo PRECMD-FIRST; }; PROMPT_COMMAND='echo PROMPT-SECOND' + echo COMMAND + expected_exit_code: 0 + expected_stdout: | + PRECMD-FIRST + PROMPT-SECOND + COMMAND + PRECMD-FIRST + PROMPT-SECOND + + # The status snapshot is put back after the last precmd hook too, not just between hooks, + # so PROMPT_COMMAND -- which bash-preexec reaches through the same dispatcher -- sees what + # the user's command left behind rather than what a hook did. + - name: "zsh precmd leaves no trace in what PROMPT_COMMAND sees" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { true | false; return 3; }; PROMPT_COMMAND='echo "PC[$?][${PIPESTATUS[*]}][$_]"' + true PROMPT-ARG + (exit 9) | (exit 8) + expected_exit_code: 8 + expected_stdout: | + PC[0][0][] + PC[0][0][PROMPT-ARG] + PC[8][9 8][PROMPT-ARG] + # A pipeline of subshells forks each stage, which WASI doesn't support. + incompatible_platforms: ["wasi"] + + # A hook registry that ended up a scalar still dispatches. bash-preexec iterates + # `"${precmd_functions[@]}"`, and in bash a scalar *is* element 0, so it runs the hook; + # skipping it here would silently drop hooks from a registry that still expands and + # prints exactly as the user wrote it. + - name: "zsh hook registry is read as a word list, scalar included" + args: + - "--enable-zsh-hooks" + - "-i" + - "-s" + stdin: | + my_precmd() { echo "SCALAR-PRECMD"; }; my_preexec() { echo "SCALAR-PREEXEC[$1]"; }; unset precmd_functions preexec_functions; precmd_functions=my_precmd; preexec_functions=my_preexec + declare -p precmd_functions + expected_exit_code: 0 + expected_stdout: | + SCALAR-PRECMD + SCALAR-PREEXEC[declare -p precmd_functions] + declare -- precmd_functions="my_precmd" + SCALAR-PRECMD + + # preexec gets the command line as entered, newlines included. (bash-preexec reads + # `history 1` instead, which with `cmdhist` is the same command joined onto one line.) + - name: "zsh preexec receives a multi-line command verbatim" + args: + - "--enable-zsh-hooks" + - "-i" + - "-s" + stdin: | + preexec() { echo "PREEXEC<$1>"; } + for i in 1 2; do + echo "loop $i" + done + expected_exit_code: 0 + expected_stdout: | + PREEXEC + loop 1 + loop 2 + + # `errexit` applies to a hook the way bash applies it to the PROMPT_COMMAND that + # bash-preexec dispatches from: a hook that returns non-zero exits the shell, and the + # remaining hooks don't run. Without `set -e` (see the failure cases above) the same + # non-zero return is ignored. Both dispatch sites are checked because they sit on + # different paths -- one ahead of the prompt, one ahead of the command. + - name: "zsh precmd obeys errexit like PROMPT_COMMAND" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { echo PRECMD; false; }; later() { echo LATER; }; precmd_functions=(precmd later) + set -e + echo NEVER + expected_exit_code: 1 + expected_stdout: | + PRECMD + LATER + PRECMD + expected_stderr: | + exit + + - name: "zsh preexec obeys errexit and stops the command" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { echo PREEXEC; false; } + set -e + echo NEVER + expected_exit_code: 1 + expected_stdout: | + PREEXEC + PREEXEC + expected_stderr: | + exit + + # `return` unwinds a function directly, bypassing the statement-boundary check that a + # plain failing command (like the `false` cases above) hits inside the hook's own body. + # errexit must still apply to it, or a hook written with `return` would silently disagree + # with one written with `false`. + - name: "zsh precmd obeys errexit when the hook uses return" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + precmd() { echo PRECMD; return 1; }; later() { echo LATER; }; precmd_functions=(precmd later) + set -e + echo NEVER + expected_exit_code: 1 + expected_stdout: | + PRECMD + LATER + PRECMD + expected_stderr: | + exit + + - name: "zsh preexec obeys errexit when the hook uses return" + args: ["--enable-zsh-hooks", "-i", "-s"] + stdin: | + preexec() { echo PREEXEC; return 1; } + set -e + echo NEVER + expected_exit_code: 1 + expected_stdout: | + PREEXEC + PREEXEC + expected_stderr: | + exit diff --git a/userland/brush/source/brush-shell/tests/cases/compat/and_or.yaml b/userland/brush/source/brush-shell/tests/cases/compat/and_or.yaml new file mode 100644 index 0000000..3dd66ec --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/and_or.yaml @@ -0,0 +1,83 @@ +name: "and/or" +cases: + - name: "Basic &&" + stdin: | + false && echo 1 + true && echo 2 + + - name: "Basic ||" + stdin: | + false || echo 1 + true || echo 2 + + - name: "Longer chains" + stdin: | + false || false || false || echo "Got to the end" + echo "1" && echo "2" && echo "3" && echo "4" + + - name: "Mixed chains" + stdin: | + false && true || echo "1. Got to the end" + false && false || echo "2. Got to the end" + true && false || echo "3. Got to the end" + + - name: "And with return in function" + stdin: | + f() { + echo "Before" && return 42 && echo "After return" + echo "After and-or" + } + f + echo "Exit code: $?" + + - name: "And with exit" + stdin: | + echo "Before" && exit 42 && echo "After exit" + echo "After and-or" + + - name: "And with break in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" && break && echo "After break" + echo "After and-or" + done + echo "After loop" + + - name: "And with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" && continue && echo "After continue" + echo "After and-or" + done + echo "After loop" + + - name: "Or with return in function" + stdin: | + f() { + false || return 42 || echo "After return" + echo "After or-list" + } + f + echo "Exit code: $?" + + - name: "Or with exit" + stdin: | + false || exit 42 || echo "After exit" + echo "After or-list" + + - name: "Or with break in loop" + stdin: | + for i in 1 2 3; do + false || break || echo "After break" + echo "After or-list" + done + echo "After loop" + + - name: "Or with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + false || continue || echo "After continue" + echo "After or-list" + done + echo "After loop" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/arguments.yaml b/userland/brush/source/brush-shell/tests/cases/compat/arguments.yaml new file mode 100644 index 0000000..9f4e45a --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/arguments.yaml @@ -0,0 +1,659 @@ +name: "Argument handling tests" +common_test_files: + - path: "script.sh" + contents: | + echo \"$0\" \"$1\" \"$2\" \"$@\" + # Announces that it ran, so cases can prove a script was *not* executed. + - path: "announce.sh" + contents: | + echo "RAN-SCRIPT count=[$#] one=[$1] two=[$2]" + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + case $- in *s*) echo "stdin-flag";; *) echo "no-stdin-flag";; esac + # Consumes stdin, to prove leftover stdin reaches the script as data. + - path: "reader.sh" + contents: | + while IFS= read -r line; do echo "read=[$line]"; done + # Only sourced by an *interactive* shell, and only via `--rcfile`. Both effects it has + # -- the variable and the alias -- are things a non-interactive shell does not get. + - path: "testrc" + contents: | + # Re-assert the harness's prompt: cases that load this file drop `--norc`, which + # lets a system-wide /etc/bash.bashrc (Debian/Ubuntu) clobber the inherited PS1. + PS1='test$ ' + RC_MARKER=loaded + alias aliascheck='echo ALIAS-EXPANDED' + # Reports whether the rc file reached it, from inside a named script. + - path: "rccheck.sh" + contents: | + echo "RC[$RC_MARKER]" + +cases: + - name: "-c mode arguments without --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - 1 + - "-2" + - "3" + + - name: "-c mode arguments with --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "--" + - 1 + - 2 + - 3 + + - name: "-c mode and arguments with +O" + args: + - "+O" + - "nullglob" + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "--" + - 1 + - 2 + - 3 + + - name: "-o enables an option disabled with +o" + known_failure: true # TODO(args): every +o is applied after every -o, so the last one doesn't win + args: ["+o", "errexit", "-o", "errexit", "-c", 'echo "$-"'] + + - name: "+O disables a shopt option enabled with -O" + known_failure: true # TODO(args): every -O is applied after every +O, so the last one doesn't win + args: ["-O", "extglob", "+O", "extglob", "-c", "shopt extglob"] + + - name: "-c mode -- torture" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - -- + - -- + - -&-1 + - --! + - "\"-2\"" + - "''--''" + - 3--* + + - name: "-c modeonly one --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - -- + + - name: "script arguments without --" + args: + - script.sh + - -1 + - -2 + - -3 + + - name: "script arguments with --" + args: + - script.sh + - -- + - --1 + - -2 + - 3 + + - name: "script -- torture" + args: + - script.sh + - -- + - "--" + - -- + - -!-1* + - "\"-2\"" + - -- + - 3-- + + - name: "script only one --" + args: + - script.sh + - -- + + - name: "-c with -- separator and command" + args: + - "-c" + - "--" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "1" + - "-2" + - "3" + + - name: "-c with -- separator only" + ignore_stderr: true + args: + - "-c" + - "--" + + - name: "-c with -- separator where command is --" + ignore_stderr: true + args: + - "-c" + - "--" + - "--" + - "echo" + - "hi" + + - name: "-c with option before command" + args: + - "-c" + - "-u" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "1" + - "-2" + - "3" + + - name: "-c with value-taking option before command" + args: + - "-c" + - "-o" + - "errexit" + - 'echo \"$0\" \"$1\" \"$@\"' + - "1" + - "2" + + - name: "-c with option before command and no command" + ignore_stderr: true + args: + - "-c" + - "-u" + + - name: "-c with option then -- separator" + args: + - "-c" + - "-u" + - "--" + - 'echo \"$0\" \"$1\" \"$@\"' + - "1" + - "2" + + - name: "-ec with -- separator and command" + args: + - "-ec" + - "--" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "1" + - "-2" + - "3" + + # `-c` is a flag, not a value-taking option: the command string is the first + # non-option argument, so flags may be grouped with it. + - name: "-c grouped with a later flag" + args: + - "-cf" + - 'echo * \"$0\" \"$1\"' + - "name" + - "1" + + - name: "-c with no command string" + ignore_stderr: true + args: + - "-c" + + - name: "-c with an empty command string" + args: + - "-c" + - "" + - "name" + - "1" + + # Only the first `--` is the option terminator; a later one is an ordinary positional. + - name: "-c with -- separator and a later --" + args: + - "-c" + - "--" + - 'echo \"$0\" \"$1\" \"$@\"' + - "--" + - "more" + + # `--` after a boolean flag ends option parsing, so a following `-c` is a script path, + # not a flag. + - name: "-c after -- is a script path, not a flag" + ignore_stderr: true + args: + - "-e" + - "--" + - "-c" + - "echo hi" + + - name: "-o with -- is not an option terminator" + ignore_stderr: true + args: + - "-o" + - "--" + + # bash does not honor an attached value for `-o`: it takes the *next* word as the + # option name, so this fails with "--: invalid option name" (exit 2). brush's parser + # instead accepts "c" as the attached value, then treats `echo` as a script path. + - name: "-oc with -- takes its option name from the next word" + known_failure: true + ignore_stderr: true + args: + - "-oc" + - "--" + - "echo" + + # + # Where the shell takes its commands from, and what the leftover words become. + # + # Three sources are possible -- a `-c` string, a named script, or standard input -- + # and exactly one of `-c`/`-s`/a leading non-option word picks it. Getting that + # classification wrong silently changes both which commands run and what `$1`... are, + # so every combination below is pinned against bash. + # + + - name: "no args reads commands from stdin" + stdin: | + echo "count=[$#] all=[$@]" + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + case $- in *s*) echo "stdin-flag";; *) echo "no-stdin-flag";; esac + + - name: "-s with no positional args" + args: ["-s"] + stdin: | + echo "count=[$#] all=[$@]" + case $- in *s*) echo "stdin-flag";; *) echo "no-stdin-flag";; esac + + - name: "-s with one positional arg" + args: ["-s", "myarg"] + stdin: | + echo "count=[$#] one=[$1] all=[$@]" + + - name: "-s with multiple positional args" + args: ["-s", "a", "b", "c"] + stdin: | + echo "count=[$#] one=[$1] two=[$2] three=[$3] all=[$@] star=[$*]" + + # A `--` in option position ends option parsing and is dropped, as in bash. Only the + # first one is the terminator; a second `--` is an ordinary positional. + - name: "-s with -- separator followed by literal --" + args: ["-s", "--", "--", "a"] + stdin: | + echo "count=[$#] one=[$1] two=[$2]" + + - name: "-s -e with -- separator" + args: ["-s", "-e", "--", "a"] + stdin: | + echo "count=[$#] one=[$1]" + + - name: "-s with hyphen-leading positional args" + args: ["-s", "a", "-2", "c"] + stdin: | + echo "count=[$#] one=[$1] two=[$2] three=[$3] all=[$@]" + + # `-s` claims the script slot, so a script named among the trailing words is a + # positional parameter and must NOT be executed. + - name: "-s does not run a script named among its positional args" + args: ["-s", "announce.sh"] + stdin: | + echo "count=[$#] one=[$1]" + + # ...and $0 stays the shell's own name rather than being taken from the words. + - name: "-s leaves $0 alone rather than consuming the first positional" + args: ["-s", "myarg"] + stdin: | + case "$0" in myarg) echo "BAD: \$0 took the positional";; *) echo "OK: \$0 untouched";; esac + echo "one=[$1]" + + - name: "-s reports the stdin flag in \\$-" + args: ["-s", "myarg"] + stdin: | + case $- in *s*) echo "stdin-flag";; *) echo "no-stdin-flag";; esac + + # A shell reading commands from a pipe is not interactive, positional args or not. + - name: "-s with positional args on a non-terminal is not interactive" + args: ["-s", "myarg"] + stdin: | + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + echo "one=[$1]" + + # ...but -i forces it, and must not disturb the positional parameters. + - name: "-i -s with positional args is interactive" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i", "-s", "myarg"] + stdin: | + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + echo "count=[$#] one=[$1]" + + - name: "-si with positional args" + ignore_stderr: true + args: ["-si", "myarg"] + stdin: | + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + echo "count=[$#] one=[$1]" + + - name: "-is with positional args" + ignore_stderr: true + args: ["-is", "myarg"] + stdin: | + case $- in *i*) echo "interactive";; *) echo "not-interactive";; esac + echo "count=[$#] one=[$1]" + + # + # The tty-attached half of the matrix, and the one the piped cases above cannot reach: + # with `-s`, commands still come from stdin, so a terminal-attached shell is + # *interactive* -- trailing positional args notwithstanding. The `#expect:` directives + # are the assertion in these (stdout is too pty-dependent to diff). + # + # Being interactive is not cosmetic. It is what makes the shell source its rc file and + # expand aliases, so those are checked here too rather than taken on faith; the + # non-pty cases that follow pin the matching negatives. + # + + - name: "-s with positional args is interactive on a terminal" + pty: true + incompatible_configs: ["sh"] + ignore_stdout: true # pty transcripts differ in echo and escape-sequence details + ignore_whitespace: true + args: ["-s", "myarg"] + # N.B. The markers must be *computed*, or the terminal's echo of the typed command + # line satisfies the expectation on its own and the case can never fail. + stdin: | + #expect-prompt + echo "FLAGS[${-//[!i]/}] ARG[$1]" + #send:Enter + #expect:FLAGS[i] ARG[myarg] + #expect-prompt + #send:Ctrl+D + + - name: "-s with positional args loads the rc file on a terminal" + pty: true + incompatible_configs: ["sh"] + removed_default_args: ["--norc"] # `--norc` otherwise overrides `--rcfile` + ignore_stdout: true + ignore_whitespace: true + args: ["--rcfile", "testrc", "-s", "myarg"] + stdin: | + #expect-prompt + echo "RC[$RC_MARKER] ARG[$1]" + #send:Enter + #expect:RC[loaded] ARG[myarg] + #expect-prompt + aliascheck + #send:Enter + #expect:ALIAS-EXPANDED + #expect-prompt + #send:Ctrl+D + + # The negatives, which need no terminal: the same `--rcfile` must be ignored whenever + # the shell is not interactive, so the rc variable stays unset and the alias undefined. + - name: "the rc file is not loaded when -s reads from a pipe" + removed_default_args: ["--norc"] + ignore_stderr: true # only the "command not found" wording differs + args: ["--rcfile", "testrc", "-s", "myarg"] + stdin: | + echo "RC[$RC_MARKER] ARG[$1]" + aliascheck + + - name: "the rc file is not loaded for a -c command" + removed_default_args: ["--norc"] + args: ["--rcfile", "testrc", "-c", 'echo "RC[$RC_MARKER]"'] + + - name: "the rc file is not loaded for a named script" + removed_default_args: ["--norc"] + args: ["--rcfile", "testrc", "rccheck.sh"] + + # + # A named script wins over stdin: the script runs, and whatever is left on stdin is + # data for the script, never commands for the shell. + # + + - name: "a named script runs and stdin is not read as commands" + args: ["announce.sh", "a", "b"] + stdin: | + echo STDIN-SHOULD-NOT-RUN + + - name: "-i with a named script still runs the script" + ignore_stderr: true + args: ["-i", "announce.sh"] + stdin: | + echo STDIN-SHOULD-NOT-RUN + + - name: "a script sees stdin as data, not commands" + args: ["reader.sh"] + stdin: | + first-line + second-line + + # + # `-c` wins over everything, including `-s` and a named script. + # + + - name: "-c runs its command and stdin is not read as commands" + args: ["-c", "echo from-c"] + stdin: | + echo STDIN-SHOULD-NOT-RUN + + - name: "-s followed by -c runs the -c command" + args: ["-s", "-c", "echo from-c"] + stdin: | + echo STDIN-SHOULD-NOT-RUN + + - name: "-c with -i is interactive but still runs the -c command" + ignore_stderr: true + args: ["-i", "-c", 'echo from-c; case $- in *i*) echo interactive;; *) echo not-interactive;; esac'] + stdin: | + echo STDIN-SHOULD-NOT-RUN + + # + # `--` and `-` as end-of-options markers. brush consumes `--` but presently keeps `-` + # as an ordinary positional word, so the `-` cases are pinned as known failures -- + # they document the gap and will flag the day it gets fixed. + # + + - name: "-s with -- before positional args" + args: ["-s", "--", "myarg"] + stdin: | + echo "count=[$#] one=[$1] all=[$@]" + + - name: "-s with a lone --" + args: ["-s", "--"] + stdin: | + echo "count=[$#] one=[$1] all=[$@]" + + - name: "-s with a lone -" + known_failure: true + args: ["-s", "-"] + stdin: | + echo "count=[$#] one=[$1] all=[$@]" + + - name: "-- before a script path" + args: ["--", "announce.sh"] + + # Once a script is named, a later `--` is that script's argument, not the shell's. + - name: "-- before a script path with a later --" + args: ["--", "script.sh", "--", "x"] + + - name: "- before a script path" + known_failure: true + ignore_stderr: true + args: ["-", "announce.sh"] + + # After the `-c` command string, every remaining word is a positional parameter -- + # even one that looks like an option. + - name: "-c with an option-like word as $0" + args: ["-c", 'echo "0=[$0] 1=[$1]"', "-s"] + + - name: "-c with -i as $0" + ignore_stderr: true + args: ["-c", 'echo "0=[$0] 1=[$1]"', "-i"] + + - name: "+o disables an option enabled with -o" + args: ["-o", "errexit", "+o", "errexit", "-c", 'false; echo "after false: $-"'] + + # Repeating a boolean option is harmless in bash. + - name: "repeated -s" + ignore_stderr: true + args: ["-s", "-s"] + stdin: | + echo "count=[$#] all=[$@]" + + # + # The shell's own flags. Long options come first in each case: bash reads none after a + # short one. + # + + - name: "--posix turns on the posix option" + args: ["--posix", "-c", "shopt -oq posix"] + + - name: "--noediting runs a -c command" + args: ["--noediting", "-c", "echo hi"] + + - name: "repeated --norc and --noprofile" + args: ["--norc", "--noprofile", "--norc", "-c", "echo hi"] + + - name: "--init-file names the rc file of an interactive shell" + removed_default_args: ["--norc"] + ignore_stderr: true # bash warns that it has no job control + args: ["--init-file", "testrc", "-i", "-c", 'echo "RC[$RC_MARKER]"'] + + - name: "--rcfile names the rc file of an interactive -c shell" + removed_default_args: ["--norc"] + ignore_stderr: true # bash warns that it has no job control + args: ["--rcfile", "testrc", "-i", "-c", 'echo "RC[$RC_MARKER]"'] + + - name: "--rcfile with no file name" + ignore_stderr: true + args: ["--rcfile"] + + - name: "--rcfile does not take an attached file name" + known_failure: true # brush accepts `--rcfile=FILE` and runs the command; bash rejects it (exit 2) + ignore_stderr: true + args: ["--rcfile=testrc", "-c", "echo hi"] + + - name: "an abbreviated long option is rejected" + ignore_stderr: true + args: ["--nor", "-c", "echo hi"] + + - name: "a value attached to a long switch is rejected" + ignore_stderr: true + args: ["--norc=1", "-c", "echo hi"] + + - name: "an unknown long option is rejected" + ignore_stderr: true + args: ["--bogus", "-c", "echo hi"] + + - name: "an unknown short option is rejected" + ignore_stderr: true + args: ["-Z", "-c", "echo hi"] + + - name: "--version with an unknown long option is rejected" + ignore_stderr: true + args: ["--version", "--bogus"] + + - name: "--version after the -c command is a positional" + args: ["-c", 'echo "0=[$0]"', "--version"] + + - name: "--debug takes no value" + known_failure: true # brush's --debug takes an event class, so this fails with "`--debug` needs a value" (exit 2) + args: ["--debug", "-c", "echo hi"] + + - name: "-o with an unknown option name" + known_failure: true # brush ignores the unknown name and runs the command; bash fails (exit 2) + ignore_stderr: true + args: ["-o", "nosuch", "-c", "echo after"] + + - name: "-O with an unknown option name" + known_failure: true # brush ignores the unknown name and runs the command; bash fails (exit 2) + ignore_stderr: true + args: ["-O", "nosuch", "-c", "echo after"] + + - name: "-o with no option name lists the options" + known_failure: true # brush fails with "`-o` needs a value" (exit 2) + args: ["-o"] + + - name: "-O with no option name lists the shopt options" + known_failure: true # brush fails with "`-O` needs a value" (exit 2) + args: ["-O"] + + - name: "-o followed by -c" + ignore_stderr: true + args: ["-o", "-c", "echo hi"] + + - name: "-O followed by -c" + ignore_stderr: true + args: ["-O", "-c", "echo hi"] + + - name: "-O takes its option name from the next word" + known_failure: true # brush takes the attached `nullglob` as the option name; bash takes none attached (exit 2) + ignore_stderr: true + args: ["-Onullglob", "-c", "shopt nullglob"] + + - name: "+O disables extglob" + known_failure: true # brush turns extglob on as the shell starts, after the command line is applied + args: ["+O", "extglob", "-c", "shopt extglob"] + + - name: "+e disables errexit enabled with -e" + known_failure: true # brush accepts only `+o` and `+O`, so `+e` is an unknown flag (exit 2) + args: ["-e", "+e", "-c", "false; echo after"] + + - name: "-v prints input as it is read" + known_failure: true # brush accepts -v but prints nothing + args: ["-v", "-c", "echo hi"] + + - name: "--verbose prints input as it is read" + known_failure: true # brush accepts --verbose but prints nothing + args: ["--verbose", "-c", "echo hi"] + + # Single-letter `set` options that bash also takes on its command line. + - name: "-a on the command line" + known_failure: true # brush rejects -a as an unknown flag (exit 2) + args: ["-a", "-c", 'echo "$-"'] + + - name: "-b on the command line" + known_failure: true # brush rejects -b as an unknown flag (exit 2) + args: ["-b", "-c", 'echo "$-"'] + + - name: "-h on the command line" + known_failure: true # brush rejects -h as an unknown flag (exit 2) + args: ["-h", "-c", 'echo "$-"'] + + - name: "-k on the command line" + known_failure: true # brush rejects -k as an unknown flag (exit 2) + args: ["-k", "-c", 'echo "$-"'] + + - name: "-m on the command line" + known_failure: true # brush rejects -m as an unknown flag (exit 2) + ignore_stderr: true # bash warns that it has no job control + args: ["-m", "-c", 'echo "$-"'] + + - name: "-p on the command line" + known_failure: true # brush rejects -p as an unknown flag (exit 2) + args: ["-p", "-c", 'echo "$-"'] + + - name: "-r on the command line" + known_failure: true # brush rejects -r as an unknown flag (exit 2) + args: ["-r", "-c", 'echo "$-"'] + + - name: "-B on the command line" + known_failure: true # brush rejects -B as an unknown flag (exit 2) + args: ["-B", "-c", 'echo "$-"'] + + - name: "-D on the command line" + known_failure: true # brush rejects -D as an unknown flag (exit 2) + args: ["-D", "-c", 'echo $"hi"'] + + - name: "-E on the command line" + known_failure: true # brush rejects -E as an unknown flag (exit 2) + args: ["-E", "-c", 'echo "$-"'] + + - name: "-H on the command line" + known_failure: true # brush rejects -H as an unknown flag (exit 2) + args: ["-H", "-c", 'echo "$-"'] + + - name: "-P on the command line" + known_failure: true # brush rejects -P as an unknown flag (exit 2) + args: ["-P", "-c", 'echo "$-"'] + + - name: "-T on the command line" + known_failure: true # brush rejects -T as an unknown flag (exit 2) + args: ["-T", "-c", 'echo "$-"'] + + - name: "a child shell inherits open file descriptors" + known_failure: true # without --inherit-fd, brush gives its shell only 0-2, so `>&3` fails with "bad file descriptor" + incompatible_platforms: ["wasi", "windows"] # spawns a child shell; fd 3 is a Unix notion + stdin: | + "$BASH" -c 'echo via3 >&3' 3>&1 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml b/userland/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml new file mode 100644 index 0000000..c5986b9 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml @@ -0,0 +1,478 @@ +name: "Arithmetic" +cases: + - name: "Empty expression" + stdin: | + echo $(()) + + - name: "Unquoted arithmetic" + stdin: | + echo $((1+1)) + + - name: "Arithmetic literals" + stdin: | + echo "$((0))" + echo "$((1))" + echo "$((10))" + echo "$((010))" + echo "$((0010))" + echo "$((0x10))" + echo "$((0x010))" + + - name: "Arithmetic literals with explicit bases" + stdin: | + echo "$((2#1010))" + + echo "$((8#16))" + + echo "$((16#faf))" + echo "$((16#FAF))" + + echo "$((36#vvv))" + echo "$((36#VVV))" + + # Base 37: A-Z map to 36-61 + echo "$((37#a))" + echo "$((37#a0))" + echo "$((37#A))" + + # Base 62: a-z=10-35, A-Z=36-61 + echo "$((62#A))" + echo "$((62#a))" + echo "$((62#Z))" + echo "$((62#z))" + + # Base 63: @ = 62 + echo "$((63#@))" + echo "$((63#@9))" + + # Base 64: _ = 63 + echo "$((64#_))" + echo "$((64#@))" + echo "$((64#_1))" + echo "$((64#zzz))" + + - name: "Unary arithmetic operators with space" + stdin: | + echo "$(( - 1 ))" + echo "$(( + 1 ))" + echo "$(( ~ 10 ))" + echo "$(( ! 10 ))" + + - name: "Parentheses" + stdin: | + echo "$(((10)))" + echo "$((((10))))" + echo "$(((((10)))))" + + echo "$(( (10) ))" + echo "$(( (1+2) ))" + echo "$(( (1 + 2) ))" + + echo "$(( ((1+2)) ))" + echo "$(( ((1 + 2)) ))" + + echo "$(((1+2)*3))" + echo "$(((1 + 2) * 3))" + echo "$(( (1 + 2) * 3 ))" + + echo "$((((1+2)*3)))" + echo "$(( ((1 + 2) * 3) ))" + echo "$(( ( (1 + 2) * 3 ) ))" + + - name: "Basic quoted arithmetic" + stdin: | + echo " 1 + 1 == $((1+1))" + echo " 2 * 3 == $((2*3))" + echo " 9 / 3 == $((9/3))" + echo "10 % 3 == $((10%3))" + echo " 2 ** 3 == $((2**3))" + echo " 2 | 4 == $((2|4))" + echo " 2 & 4 == $((2&4))" + echo " 2 ^ 3 == $((2^3))" + echo " 1 && 1 == $((1&&1))" + echo " 1 && 0 == $((1&&0))" + echo " 1 || 0 == $((1||0))" + echo " 0 || 1 == $((1||0))" + echo " 1 , 2 == $((1,2))" + + - name: "Unary operators" + stdin: | + echo " -1 == $((-1))" + echo " +1 == $((+1))" + echo " ~10 == $((~10))" + echo " !10 == $((!10))" + + - name: "Conditional operator" + stdin: | + echo "1 ? 2 : 3 == $((1?2:3))" + echo "0 ? 2 : 3 == $((0?2:3))" + + - name: "Arithmetic with spacing" + stdin: | + echo $(( 75 + 68 )) + + - name: "Divide by zero" + ignore_stderr: true + stdin: | + echo "1 / 0 == $((1/0))" + echo "Result: $?" + + echo "1 % 0 == $((1%0))" + echo "Result: $?" + + - name: "Shift arithmetic" + stdin: | + echo "32 >> 2 == $((32>>2))" + echo " 1 << 4 == $((1<<4))" + + - name: Shift arithmetic in conditional" + stdin: | + (( 1 >> 1 )) && echo "1. true" + (( 1 << 1 )) && echo "2. true" + + - name: "Variable references" + stdin: | + x=10 + echo "x => $((x))" + echo "x + 1 => $((x+1))" + + y= + echo "y => $((y))" + echo "y + 1 => $((y+1))" + + z=notnumber + echo "z => $((z))" + echo "z + 1 => $((z+1))" + + - name: "Complex variable references" + stdin: | + x=10 + y=20 + z=x+y + a=z+z + + echo "z => $((z))" + echo "a => $((a))" + + - name: "Nested expressions" + stdin: | + echo "1: $(($(echo -n 1; echo 2) + 37))" + + op="+" + echo "2: $((10 ${op} 20))" + + expr="13 * 7" + echo "3: $(($expr))" + + - name: "Assignment arithmetic" + stdin: | + x=0 + + echo "x = 1 => $((x = 1))" + echo "x is now $x" + + echo "x += 3 => $((x += 3))" + echo "x is now $x" + + echo "x -= 1 => $((x -= 1))" + echo "x is now $x" + + echo "x *= 2 => $((x *= 2))" + echo "x is now $x" + + echo "x++ == $((x++))" + echo "x is now $x" + + echo "++x == $((++x))" + echo "x is now $x" + + echo "x-- == $((x--))" + echo "x is now $x" + + echo "--x == $((--x))" + echo "x is now $x" + + echo "x |= 3 => $((x |= 3))" + echo "x is now $x" + + echo "x &= 12 => $((x &= 12))" + echo "x is now $x" + + echo "x %= 8 => $((x %= 8))" + echo "x is now $x" + + echo "x ^= 7 => $((x ^= 7))" + echo "x is now $x" + + - name: "Assignment with references" + stdin: | + x=10+3 + echo "y = x => $((y = x))" + echo "y is now $y" + + - name: "Assignments in logical boolean expressions" + stdin: | + x=0 + echo "0 && x+=1 => $(( 0 && (x+=1) ))" + echo "x: $x" + + x=0 + echo "1 && x+=1 => $(( 1 && (x+=1) ))" + echo "x: $x" + + x=0 + echo "0 || x+=1 => $(( 0 || (x+=1) ))" + echo "x: $x" + + x=0 + echo "1 || x+=1 => $(( 1 || (x+=1) ))" + echo "x: $x" + + - name: "Array arithmetic" + stdin: | + a=(1 2 3) + declare -p a + echo "a[0]: $((a[0]))" + echo "a[2]: $((a[2]))" + echo "a[3]: $((a[3]))" + + echo "b[2]: $((b[2]))" + + echo "a[1]=4 => $((a[1]=4))" + echo "a[1]: $((a[1]))" + + echo "a[1]+=1 => $((a[1]+=1))" + echo "a[1]: $((a[1]))" + + echo "c[1] += 3 => $((c[1] += 3))" + + - name: "Basic arithmetic comparison" + stdin: | + echo "0 < 1: $((0 < 1))" + echo "0 <= 1: $((0 <= 1))" + echo "0 == 1: $((0 == 1))" + echo "0 != 1: $((0 != 1))" + echo "0 > 1: $((0 > 1))" + echo "0 >= 1: $((0 >= 1))" + echo "0 < 0: $((0 < 0))" + echo "0 <= 0: $((0 <= 0))" + echo "0 == 0: $((0 == 0))" + echo "0 != 0: $((0 != 0))" + echo "0 > 0: $((0 > 0))" + echo "0 >= 0: $((0 >= 0))" + echo "1 < 0: $((1 < 0))" + echo "1 <= 0: $((1 <= 0))" + echo "1 == 0: $((1 == 0))" + echo "1 != 0: $((1 != 0))" + echo "1 > 0: $((1 > 0))" + echo "1 >= 0: $((1 >= 0))" + + - name: "Legacy bracket arithmetic syntax" + stdin: | + x=5 + x=$[x + 1] + echo "x after increment: $x" + echo "Direct: $[1 + 2]" + + - name: "Legacy bracket arithmetic in extended test" + stdin: | + number=4 + [[ $[number % 2] == 0 ]] && echo "even" || echo "odd" + number=5 + [[ $[number % 2] == 0 ]] && echo "even" || echo "odd" + + - name: "Legacy bracket arithmetic in double-quoted string" + stdin: | + echo "Quoted: result: $[2 * 3]" + + - name: "Legacy bracket arithmetic complex expression" + stdin: | + echo "Complex: $[1 + 2 * 3 - 4 / 2 % 2]" + + - name: "Legacy bracket arithmetic with array subscript" + stdin: | + arr=(10 20 30) + echo "Array elem: $[arr[1] + 5]" + + - name: "Prefix increment/decrement with space before lvalue" + stdin: | + x=5 + echo "++ x => $(( ++ x ))" + echo "x is now $x" + echo "-- x => $(( -- x ))" + echo "x is now $x" + + # Also test in (( )) command form + y=10 + (( ++ y )) + echo "y after ++ y: $y" + (( -- y )) + echo "y after -- y: $y" + + - name: "INT64_MIN and wrapping arithmetic" + stdin: | + # INT64_MIN via unary minus of the literal + echo "$(( -9223372036854775808 ))" + + # Wrapping negation of INT64_MIN + echo "$(( - (-9223372036854775808) ))" + + # INT64_MIN % -1 should be 0 (not crash) + echo "$(( (-9223372036854775808) % (-1) ))" + + # INT64_MIN / -1 wraps + echo "$(( (-9223372036854775808) / (-1) ))" + + # Wrapping increment/decrement near boundaries + echo "$(( 9223372036854775807 + 1 ))" # wraps to INT64_MIN + + - name: "Non-cyclic variable dereference chain" + stdin: | + a=b + b=c + c=42 + echo "$((a))" + + - name: "Self-referencing variable in arithmetic" + ignore_stderr: true + stdin: | + a=a + echo "$(( a ))" + echo "exit: $?" + + - name: "Recursive variable evaluation with depth limit" + ignore_stderr: true + stdin: | + # Recursive variables should not cause stack overflow + a=b + b=a + echo "$(( a ))" + echo "exit: $?" + + - name: "Arithmetic with nameref variable" + known_failure: true + stdin: | + counter=10 + declare -n ref=counter + echo "before: $counter" + (( ref++ )) + echo "after ++: $counter" + (( ref += 5 )) + echo "after +=5: $counter" + echo "expr: $(( ref * 2 ))" + + - name: "Legacy arithmetic expansion with array subscripts" + stdin: | + a=(5 6) + echo $[ a[1] + 1 ] $[a[a[0]-4]*2] $[ a[1]*a[0] ] + + - name: "Unmatched bracket in arithmetic expansion does not execute commands" + ignore_stderr: true # arithmetic error wording differs + stdin: | + echo $((echo unexpected [)) + echo "not reached" + + - name: "Legacy arithmetic expansion containing nested expansions" + stdin: | + a=(5 6) + echo $[ ${a[1]} + 1 ] $[ ${#a[@]} ] $[ $[1] + 1 ] $[ a[$[1]] ] + + - name: "Blanks inside array subscripts in arithmetic" + known_failure: true # TODO(arithmetic): the arithmetic parser rejects blanks inside a subscript's brackets + stdin: | + a=(1 2) + echo $(( a[ 1 ] )) $(( a[ a[0] ] )) + + - name: "Double quotes inside legacy arithmetic expansion in double quotes" + known_failure: true # TODO(tokenizer): in double quotes, bash doesn't nest quotes inside '$[...]', so the '$[' is left unclosed (a bad substitution); brush expands it + ignore_stderr: true # error wording differs + stdin: | + echo "[$[ "1" + 2 ]]" + echo "rc=$?" + + - name: "Shift after nested parentheses in arithmetic command" + stdin: | + (( y = (1 + (2)) << 2 )); echo "$y" + (( y = ((1)) << 2 )); echo "$y" + x=$( (( ((1)) << 2 )); echo ok ); echo "$x" + (( ((1)) << 2 )); cat < ${assoc_array[${key}]}" + done + } +cases: + - name: "Basic indexed array definition and access" + stdin: | + var=(a b c) + echo "var: $var" + echo "var: ${var}" + echo "var[0]: ${var[0]}" + echo "var[1]: ${var[1]}" + echo "var[2]: ${var[2]}" + echo "var[3]: ${var[3]}" + echo "var[4]: ${var[4]}" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Negative indexed array access" + stdin: | + var=(a b c) + echo "var[-1]: ${var[-1]}" + echo "var[-2]: ${var[-2]}" + echo "var[-3]: ${var[-3]}" + + - name: "Too large negative indexed array access" + ignore_stderr: true + stdin: | + var=(a b c) + echo "var[-4]: ${var[-4]}" + + - name: "Negative indexed array access (set)" + stdin: | + var=(a b c d e f g) + var[-1]=x + var[-3]=z + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Too large negative indexed array access (set)" + ignore_stderr: true + stdin: | + var=(a b c) + var[-4]=x + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Define array with expansion" + stdin: | + body='a b' + + var=($body) + declare -p var + + declare -a var2=($body) + declare -p var2 + + - name: "Arrays with empty values" + stdin: | + var1=(a "" c) + declare -p var1 + + var2=(a "$nonexistent" c) + declare -p var2 + + var3=("" "" "") + declare -p var3 + + - name: "Basic associative array definition and access" + stdin: | + declare -A var=([0]=99 [a]=3 [b]=4 [c]=5) + echo "var: $var" + echo "var: ${var}" + echo "var[0]: ${var[0]}" + echo "var[a]: ${var[a]}" + echo "var[b]: ${var[b]}" + echo "var[c]: ${var[c]}" + echo "var[d]: ${var[d]}" + + key="a" + echo "var[key]: ${var[${key}]}" + + - name: "Array updating via index" + stdin: | + var=(a b c) + var[1]="d" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Array index assignment" + stdin: | + x=(3 2 1) + y[${x[0]}]=10 + y[x[1]]=11 + declare -p y + + - name: "Arithmetic index assignment on declared-but-unset variable" + stdin: | + declare a + i=1 + a[i]=help + declare -p a + + - name: "Arithmetic index assignment on local declared-but-unset variable" + stdin: | + f() { + local a + i=1 + a[i]=help + declare -p a + } + f + + - name: "Arithmetic index assignment on scalar variable" + stdin: | + a=foo + i=1 + a[i]=help + declare -p a + + - name: "Arithmetic index assignment on undeclared variable" + stdin: | + i=1 + a[i]=help + declare -p a + + - name: "Replacing array with string" + stdin: | + var=(a b c) + var="x" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Array replacing" + stdin: | + var=(a b c) + var=(d e f) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending array to indexed array" + stdin: | + var=(a b c) + var+=(e f g) + echo "var[*]: ${var[*]}" + declare -p var + + var+=("" "" "") + declare -p var + + var+=(h "i" "$nonexistent" j) + var+=(j k l) + declare -p var + + - name: "Appending array to declared-but-unset indexed array" + stdin: | + declare -a var + var+=(e f g) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to indexed array" + stdin: | + var=(a b c) + var+=d + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to element of indexed array" + stdin: | + declare -a var=(1 2 3) + var[1]+=2 + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to element of int indexed array" + stdin: | + declare -ai var=(1 2 3) + var[1]+=2 + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending items to int indexed array" + stdin: | + declare -ai var=(1 2 3) + var+=(4 value 6) + declare -p var + + - name: "Appending array to associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=([d]=4 [e]=5 [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending array to declared-but-unset associative array" + stdin: | + source helpers.sh + declare -A assoc_array + assoc_array+=([d]=4 [e]=5 [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending item to associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=d + stable_print_assoc_array assoc_array + + - name: "Appending item to element of associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array[b]+=2 + stable_print_assoc_array assoc_array + + - name: "Appending item to element of int associative array" + stdin: | + source helpers.sh + declare -Ai assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array[b]+=2 + stable_print_assoc_array assoc_array + + - name: "Appending items to int associative array" + stdin: | + source helpers.sh + declare -Ai assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=([d]=4 [e]=value [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending array to item" + stdin: | + var=x + var+=(a b c) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Fill associative array" + stdin: | + declare -Ag myarray + + myfunc() { + local myarg=$1 element + shift + for element in "$@"; do + echo "Setting: myarray[$element]=$myarg" + myarray[$element]="${myarg}${element}" + done + } + + myfunc 'first' bunzip2 bzcat pbunzip2 pbzcat lbunzip2 lbzcat + + - name: "Declare via array index" + stdin: | + declare -A assoc_array["key"]="value" + declare -p assoc_array + + declare -a indexed_array[5]="value" + declare -p indexed_array + + declare implicit_array[5]="value" + declare -p implicit_array + + declare -A no_value_assoc_array["key"] + declare -p no_value_assoc_array + + declare no_value_indexed_array[33] + declare -p no_value_indexed_array + + - name: "Create array via index update" + stdin: | + i=0 + my_array[i++]="c" + echo "0: i=$i array=${my_array[@]}" + my_array[i++]="b" + echo "1: i=$i array=${my_array[@]}" + my_array[i++]="a" + echo "2: i=$i array=${my_array[@]}" + + - name: "Expansion of array body" + stdin: | + elements="a b c" + + array1=(${elements}) + declare -p array1 + + array2=("${elements}") + declare -p array2 + + - name: "Copy array" + stdin: | + src=(a b c) + + dest1=("${src[*]}") + declare -p dest1 + + dest2=(${src[*]}) + declare -p dest2 + + dest3=("${src[@]}") + declare -p dest3 + + dest4=(${src[@]}) + declare -p dest4 + + - name: "Multi-line definition of array" + stdin: | + array=( + a + b + c + ) + declare -p array + + - name: "Dump values from non-existent array" + stdin: | + for key in ${NONEXISTENT[@]}; do + echo "@-Key: ${key}" + done + for key in "${NONEXISTENT[@]}"; do + echo "@-Quoted Key: ${key}" + done + + for key in ${NONEXISTENT[*]}; do + echo "*-Key: ${key}" + done + for key in "${NONEXISTENT[*]}"; do + echo "*-Quoted Key: ${key}" + done + + - name: "Use non-existent var in indexed array index" + stdin: | + declare -a myarray=(10) + echo "Value: ${myarray[$non_existent_var]}" + + - name: "Use non-existent var in associative array index" + ignore_stderr: true + stdin: | + declare -A myarray=([0]="default" ["a"]="other") + echo "Value: ${myarray[$non_existent_var]}" + + - name: "Nested array reference" + stdin: | + x=(2 3 4) + echo ${x[${x[0]}]} + + - name: "Append array-resulting expansion to array" + stdin: | + declare -a otherarray=("a" "b" "c") + declare -a ourarray=() + + ourarray+=("${otherarray[@]/a/x}") + declare -p otherarray + declare -p ourarray + + - name: "Array declaration in a function" + stdin: | + outer() { + inner=() + if test true; then + echo true + else + echo false + fi + } + + outer + + - name: "Array element expansion with non-standard IFS" + stdin: | + IFS='|' + + declare -a myarray=(abc 1 f) + echo "\"\${myarray[@]}\": ${myarray[@]}" + echo "\"\${myarray[*]}\": ${myarray[*]}" + echo "\${myarray[@]}:" ${myarray[@]} + echo "\${myarray[*]}:" ${myarray[*]} + + # + # Field splitting applies to what an expansion produced, not to the literal text around it. A + # compound value's elements are separated by the parser on whitespace; an IFS character sitting + # in literal text is just a character. + # + - name: "Literal text in a compound value is not field-split" + stdin: | + IFS=, + arr=(a,b) + declare -p arr + + - name: "A compound value field-splits an expansion but not the literal beside it" + stdin: | + IFS=, + v="p,q" + arr=(a,b $v keep,me) + declare -p arr + + - name: "Empty array with newline" + stdin: | + declare -a myarray=( + ) + + - name: "Parenthesized arithmetic in array index assignment" + known_failure: true # TODO(tokenizer): #1211: '(' inside an assignment's subscript is tokenized as an operator + stdin: | + c[(1)]=x + declare -p c + i=1 + d[(i+1)*2]=y + declare -p d + + - name: "Compound assignment appends to an element with [key]+=" + known_failure: true # TODO(parser): an element's += isn't parsed, so the word is taken as a value + stdin: | + a=(x y) + a+=([0]+=z) + declare -p a + declare -A m=([k]=v) + m+=([k]+=w [j]+=u) + declare -p m + declare -a d=(p q) + declare -a d+=([1]+=r) + declare -p d + + - name: "Compound assignment evaluates an indexed array's subscripts" + known_failure: true # TODO(arrays): an element's subscript isn't evaluated as arithmetic + stdin: | + a=([1+1]=x) + declare -p a + i=1 + declare -a b=([i+1]=y [i*5]=z) + declare -p b + + # + # Ordering between value expansion and subscript evaluation. A shell expands an + # assignment's words before it evaluates any arithmetic subscript, so side effects + # in a subscript are not visible to the value being assigned. + # + - name: "Index arithmetic is evaluated after the value is expanded" + stdin: | + i=0 + a[i++]=$i + declare -p a + echo "i=$i" + + - name: "Append with an arithmetic subscript" + stdin: | + i=0 + a[i++]=$i + a[i++]+=$i + declare -p a + echo "i=$i" + + - name: "Associative subscripts are not arithmetically evaluated" + stdin: | + i=0 + declare -A m + m[i++]=$i + declare -p m + echo "i=$i" + + - name: "Associative subscript is expanded once in a plain assignment" + stdin: | + declare -A m + k='$(echo expanded-twice)' + m[$k]=v + declare -p m + + # + # An empty subscript in a parameter reference is rejected while the word is parsed, before any + # array is consulted. + # + - name: "An empty subscript is a bad substitution" + known_failure: true # TODO: the word parser accepts an empty subscript and treats it as element 0 + ignore_stderr: true + stdin: | + zz_a=(1 2 3) + echo "[${zz_a[]}]" + echo "status: $?" + + - name: "Negative index into sparse indexed array" + stdin: | + b=(a b c d) + b[5]=q + echo "<${b[-2]}> <${b[-1]}>" + b[-2]=Z + b[-1]+=r + declare -p b + unset 'b[-3]' + declare -p b diff --git a/userland/brush/source/brush-shell/tests/cases/compat/assignments.yaml b/userland/brush/source/brush-shell/tests/cases/compat/assignments.yaml new file mode 100644 index 0000000..8840b3b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/assignments.yaml @@ -0,0 +1,85 @@ +name: "Assignments" +cases: + - name: "First char is equals sign" + ignore_stderr: true + stdin: | + =x + + - name: "Basic assignment" + stdin: | + x=yz + echo "x: ${x}" + + - name: "Invalid variable name" + ignore_stderr: true + stdin: | + @=something + + - name: "Quoted equals sign" + ignore_stderr: true + stdin: | + x"="3 + + - name: "Multiple equals signs" + stdin: | + x=y=z + echo "x: ${x}" + + - name: "Assignment with tilde expansion" + env: + HOME: /some/dir + stdin: | + # Basic tilde at start (currently works) + var=~/file1.txt + echo "~/file1.txt: ${var}" + + # Tilde after colon in assignment (currently broken - doesn't expand second tilde) + var=~/file1.txt:~/file2.txt + echo "~/file1.txt:~/file2.txt: ${var}" + + # Multiple tildes after colons + var=~:~/bin:~/local/bin + echo "~:~/bin:~/local/bin: ${var}" + + # Mixed with regular paths + PATH=/usr/bin:~:/bin:~/local + echo "PATH-like: ${PATH}" + + - name: "Array assignment with tilde expansion" + env: + HOME: /testhome + stdin: | + # Array elements with tildes + arr=(~ ~/bin ~root) + echo "Array elements:" + for i in "${arr[@]}"; do + echo " $i" + done + + # Array elements with colon-tilde + arr2=(~:~/bin /usr/bin:~) + echo "Array with colons:" + for i in "${arr2[@]}"; do + echo " $i" + done + + - name: "Append assignment with tilde expansion" + env: + HOME: /testhome + stdin: | + # Basic append + PATH=/usr/bin + PATH+=:~ + echo "After append tilde: ${PATH}" + + # Append with colon-tilde + PATH=/usr/bin + PATH+=:~:~/local + echo "After append colon-tilde: ${PATH}" + + - name: "A compound assignment after an ordinary command is a syntax error" + known_failure: true # TODO(parser): `echo a=(1 2)` is accepted and prints `a=(1 2)` + ignore_stderr: true + stdin: | + echo a=(1 2) + echo "after: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml b/userland/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml new file mode 100644 index 0000000..a5040da --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml @@ -0,0 +1,183 @@ +name: "Background jobs" +cases: + # === Implicit wait on exit: shell must wait for bg jobs before terminating === + # These use file side-effects rather than stdout to avoid racy output ordering. + + - name: "Single background job via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch bg_done &" + + - name: "Single background job via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch bg_done & + + - name: "Background compound command via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "{ touch a_done; touch b_done; } &" + + - name: "Background compound command via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + { touch a_done; touch b_done; } & + + - name: "Multiple background jobs via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch one & touch two & touch three &" + + - name: "Multiple background jobs via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch one & touch two & touch three & + + - name: "Background and foreground via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch bg_done & touch fg_done" + + - name: "Background and foreground via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch bg_done & touch fg_done + + # === Explicit wait === + + - name: "Background job side effect observed after wait via -c" + args: + - "-c" + - "touch bg_done & wait; test -f bg_done && echo exists" + + - name: "Background job side effect observed after wait via stdin" + stdin: | + touch bg_done & + wait + test -f bg_done && echo exists + + - name: "Single background job with explicit wait via -c" + args: + - "-c" + - | + echo hello & + wait + + - name: "Single background job with explicit wait via stdin" + stdin: | + echo hello & + wait + + - name: "Two sequential background jobs with explicit wait via -c" + args: + - "-c" + - | + echo 1 & + wait + echo 2 & + wait + + - name: "Two sequential background jobs with explicit wait via stdin" + stdin: | + echo 1 & + wait + echo 2 & + wait + + - name: "Kill a background pipeline by job spec" + known_failure: true # TODO(jobs): a background job isn't tied to its processes, so `kill %1` fails, and the job runs to completion + ignore_stderr: true + stdin: | + sleep 3 | sleep 3 & + kill %1 + wait %1 + echo "status: $?" + + - name: "$! names the last background job's process" + known_failure: true # TODO(jobs): a background job isn't tied to its processes, so `$!` is empty + stdin: | + sleep 0.1 & + if [ -n "$!" ] && kill -0 "$!"; then echo "running"; fi + wait + + - name: "Background job started in a pipeline stage" + known_failure: true # TODO(jobs): the background job's task holds the stage's standard output open, so the next stage waits for the job, though its program writes elsewhere + timeout_in_seconds: 2 + stdin: | + { sleep 3 >/dev/null & } | cat + echo "done" + + - name: "A background program stops on writing to the terminal, with tostop set" + known_failure: true # TODO(jobs): programs inherit the ignored SIGTTOU an interactive shell sets for itself, so the program writes instead + pty: true + ignore_stdout: true # Prompts and job notices don't line up + stdin: | + #expect-prompt + stty tostop + #send:Enter + #expect-prompt + sh -c 'echo to-terminal; touch written.txt' & + #send:Enter + #expect-prompt + sleep 1 + #send:Enter + #expect-prompt + kill -9 %1; stty -tostop + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A background job started in a command substitution runs during it" + stdin: | + x=$( + (sleep 0.1; echo ran > ran.txt) & + SECONDS=0 + while [ ! -e ran.txt ] && [ $SECONDS -lt 3 ]; do :; done + [ -e ran.txt ] && echo "ran during the loop" + wait + ) + echo "x=$x" + + - name: "A program can signal a job by its $!" + known_failure: true # Deliberate (docs/reference/signals.md, "Known differences"): a job's $! is an ID of the shell's own, which no process has + ignore_stderr: true + stdin: | + sleep 0.3 & + sh -c "kill -0 $!" && echo "signaled" + wait + + # bash forks a subshell for a job of several commands; a signal to its $! ends the subshell + # and leaves the program it was running without it. brush's job signals its programs too. + - name: "A signal to a compound job's $! leaves its program running" + skip: true # TODO(signals): hangs: a wait for a job's program never returns + ignore_stderr: true + stdin: | + { sh -c 'trap "echo got-term; exit 0" TERM; echo $$ > pidfile; while :; do sleep 0.05; done'; } & + while [ ! -s pidfile ]; do sleep 0.05; done + kill $! + wait $! + echo "job: $?" + sleep 0.2 + echo "then" + kill "$(cat pidfile)" + sleep 0.2 + rm pidfile + + - name: "An error or exit in a background job ends only that job" + ignore_stderr: true + stdin: | + { echo ${y?b}; } & + wait + echo "after error: $?" + eval 'echo ${y?b}' & + wait + echo "after eval: $?" + f() { exit 5; } + f & + wait + echo "after exit: $?" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/basic.yaml b/userland/brush/source/brush-shell/tests/cases/compat/basic.yaml new file mode 100644 index 0000000..8a9c741 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/basic.yaml @@ -0,0 +1,153 @@ +name: "Basic tests" +cases: + - name: "Basic -c usage" + args: + - "-c" + - "echo hi" + + - name: "Trailing backslash at end of -c string" + known_failure: true # TODO(tokenizer): a backslash ending complete input is reported as an unterminated escape; bash takes it literally + args: + - "-c" + - "printf '<%s>\\n' \\" + + - name: "Basic -c usage: output" + stdin: | + # Capture raw output of command + raw_output=$($0 -c 'echo hi') + + # Dump raw output as characters + echo "[Character output]" + for ((i = 0; i < ${#raw_output}; i++)); do + char="${raw_output:i:1}" + printf '%c' "$char" + done + printf '\n\n' + + # Dump raw output as hex + echo "[Hex output]" + for ((i = 0; i < ${#raw_output}; i++)); do + if (( i % 16 == 0 )); then + printf '%04x: ' "$i" + fi + + char="${raw_output:i:1}" + printf '%02x ' "'$char" + + if (( (i + 1) % 16 == 0 )); then + printf '\n' + fi + done + + printf '\n' + + - name: "Basic stdin usage" + stdin: | + echo hi + + - name: "Basic sequence" + stdin: | + echo 'hi'; echo 'there' + + - name: "Basic -s usage with -c" + args: ["-s", "-c", "echo hi1 hi2"] + stdin: | + [[ $(type -P $0) == $(type -P $BASH) ]] && echo '$0 matches $BASH' + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + + - name: "Basic -s usage with positional args" + args: ["-s", "arg1", "arg2", "arg3"] + stdin: | + [[ $(type -P $0) == $(type -P $BASH) ]] && echo '$0 matches $BASH' + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + + - name: "Basic -i usage with commands via stdin" + args: ["-i"] + ignore_stderr: true + stdin: | + echo hi + echo there + + - name: "Basic script execution" + test_files: + - path: "script.sh" + contents: | + echo "ARGS: $@" + exit 22 + args: ["./script.sh", 1, 2, 3] + + - name: "Passing option-like arg to script" + test_files: + - path: "script.sh" + contents: | + echo "ARGS: $@" + exit 22 + args: ["./script.sh", "-v"] + + - name: "Ensure ~ is resolvable" + stdin: "test ~" + + - name: "Rc file loading (interactive)" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "testrc", "-i"] + env: + PS1: "" + test_files: + - path: "testrc" + contents: | + echo "Executing in rc file" + stdin: | + echo "Executing in main script body" + + - name: "Rc file exit ends the shell (interactive)" + known_failure: true # TODO(startup): an rc file's control flow is dropped + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "testrc", "-i"] + env: + PS1: "" + test_files: + - path: "testrc" + contents: | + echo "Executing in rc file" + exit 5 + stdin: | + echo "Executing in main script body" + + - name: "Rc file loading (non-interactive)" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "testrc"] + env: + PS1: "" + test_files: + - path: "testrc" + contents: | + echo "Executing in rc file" + stdin: | + echo "Executing in main script body" + + - name: "Missing rc file" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "non-existent-file", "-i"] + env: + PS1: "" + stdin: | + echo "Executing in main script body" + + - name: "Unknown option" + ignore_stderr: true + args: ["--unknown-option", "-c", "echo hi"] + + - name: "-f on the command line" + args: ["-f"] + stdin: | + touch abc + touch abd + echo a* diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml new file mode 100644 index 0000000..d3be7fa --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml @@ -0,0 +1,319 @@ +name: "Builtins: alias" +cases: + # + # Defining, listing, and removing aliases. + # + + - name: "Basic alias usage" + stdin: | + shopt -s expand_aliases + alias myalias=echo + alias + myalias 'hello' + + - name: "Alias listing" + stdin: | + alias a=echo + alias b='ls -l' + alias + echo "---" + alias -p + echo "---" + alias a + echo "exit=$?" + + - name: "Alias listing is sorted by name" + stdin: | + alias e=5 + alias c=3 + alias a=1 + alias f=6 + alias d=4 + alias b=2 + alias + + - name: "Redefining an alias" + stdin: | + shopt -s expand_aliases + alias r='echo one' + r + alias r='echo two' + r + + - name: "Unalias" + stdin: | + shopt -s expand_aliases + alias x='echo aliased' + x + unalias x + x 2>/dev/null || echo "gone: $?" + + - name: "Unalias all" + stdin: | + alias a=echo + alias b=echo + unalias -a + alias + echo "exit=$?" + + - name: "Unalias non-existent alias" + ignore_stderr: true # brush doesn't prefix errors with script name and line + stdin: | + unalias nosuchalias + echo "exit=$?" + + # + # Expansion of the alias body: quoting and word expansions. + # + + - name: "Alias body with quoted args" + stdin: | + shopt -s expand_aliases + alias fmt='printf "[%s]\n" -a --format "table {{.Names}}\t{{.State}}"' + fmt + + - name: "Alias body with single-quoted args" + stdin: | + shopt -s expand_aliases + alias q="printf '[%s]\n' 'a b' c" + q + + - name: "Alias body with escaped chars" + stdin: | + shopt -s expand_aliases + alias esc='printf "[%s]\n" a\ b c' + esc + + - name: "Alias body with ANSI-C quoting" + stdin: | + shopt -s expand_aliases + alias d='printf "[%s]\n" $'"'"'a\tb'"'"'' + d + + - name: "Alias body with variable expansion" + stdin: | + shopt -s expand_aliases + export MYVAR="a b" + alias e='printf "[%s]\n" $MYVAR "$MYVAR"' + e + + - name: "Alias body with tilde expansion" + stdin: | + shopt -s expand_aliases + alias t='echo ~/sub' + [ "$(t)" = "$HOME/sub" ] && echo "tilde expanded" + + - name: "Alias body with command substitution" + stdin: | + shopt -s expand_aliases + alias c='echo $(echo inner)' + c + + - name: "Alias body with pathname expansion" + stdin: | + shopt -s expand_aliases + touch aa.txt ab.txt + alias g='printf "[%s]\n" *.txt' + g + + - name: "Alias body is expanded at use time" + stdin: | + shopt -s expand_aliases + alias e='echo $MYVAR' + MYVAR=first + e + MYVAR=second + e + + - name: "Alias with empty body" + stdin: | + shopt -s expand_aliases + alias empty='' + empty + echo "exit=$?" + + - name: "Args are appended after the alias body" + stdin: | + shopt -s expand_aliases + alias p='printf "[%s]\n" first' + p second "third fourth" + + # + # Where alias expansion applies. + # + + - name: "Alias takes precedence over function" + stdin: | + shopt -s expand_aliases + myfunc() { echo function-version; } + alias myfunc='echo alias-version' + myfunc + unalias myfunc + myfunc + + - name: "Alias for a command name is not recursively re-expanded" + stdin: | + shopt -s expand_aliases + alias echo='echo prefix' + echo hello + + - name: "Aliases are not expanded when expand_aliases is unset" + stdin: | + alias x='echo aliased' + x 2>/dev/null || echo "not-expanded" + + - name: "Alias used in a pipeline" + stdin: | + shopt -s expand_aliases + alias u='tr a-z A-Z' + echo hello | u + + - name: "Alias used after && and ||" + stdin: | + shopt -s expand_aliases + alias e='echo ran' + true && e + false || e + + - name: "Alias used in a function body" + stdin: | + shopt -s expand_aliases + alias body='echo from-alias' + f() { body; } + f + + - name: "Alias used in a for loop body" + stdin: | + shopt -s expand_aliases + alias pr='echo item' + for i in 1 2; do pr $i; done + + # + # Cases the current (execution-time) alias implementation does not handle. + # These need alias substitution to move into the tokenizer; see issue #57. + # + + - name: "Quoting the command name suppresses alias expansion" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias e='echo aliased' + e + \e 2>/dev/null || echo "backslash-suppressed" + "e" 2>/dev/null || echo "quote-suppressed" + + - name: "Alias body containing a pipeline" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias pl='echo hello | tr a-z A-Z' + pl + + - name: "Alias body containing a redirection" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias w='echo written > out.txt' + w + cat out.txt + + - name: "Alias body containing a command separator" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias two='echo one; echo two' + two + + - name: "Alias defined and used on the same line" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias sl='echo same-line'; sl + + - name: "Alias with trailing space" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias cmd='echo ' + alias other='replaced ' + alias otherother='also-replaced' + + cmd other otherother + + - name: "Alias referencing to alias" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias myalias=echo + alias outeralias=myalias + outeralias 'hello' + + - name: "Alias to keywords" + known_failure: true # Issue #286 + stdin: | + shopt -s expand_aliases + alias myalias=if + myalias true; then echo "true"; fi + + - name: "alias display quotes values" + stdin: | + alias q1="a'b" + alias q2="'" + alias q3="a''b" + alias q4='a "b" $c' + alias q5="$(printf 'a\tb')" + alias q6="a + b" + + alias + alias -p + alias q1 q3 + + - name: "alias lists a definition without the alias prefix in POSIX mode" + known_failure: true # TODO(alias): brush prints `alias x=...` in POSIX mode, where bash prints `x=...` + stdin: | + set -o posix + alias x=y + alias x + alias -p + + - name: "alias rejects a compound value" + known_failure: true # TODO(alias): brush defines the alias from the compound value; bash reports the name as not found + ignore_stderr: true + stdin: | + alias a=(1 2); echo "rc=$?" + alias a 2>/dev/null; echo "rc=$?" + + - name: "alias and unalias take --" + stdin: | + alias -- a=b + alias a + unalias -- a + alias a 2>/dev/null; echo "rc=$?" + + - name: "alias prints a name that starts with - after --" + known_failure: true # TODO(alias): prints `alias -a='b'`, without `--` + stdin: | + alias -- -a=b + alias -- -a + + # + # An alias body is expanded before the resulting words are inspected, so an alias can supply + # the name of a declaration builtin and have the operands that follow it still be treated as + # assignments rather than ordinary strings. + # + - name: "alias supplying a declaration builtin still splits its compound operand" + stdin: | + shopt -s expand_aliases + alias mydecl='declare -a' + v="a b" + mydecl arr=($v) + declare -p arr + + - name: "alias supplying export assigns and exports" + stdin: | + shopt -s expand_aliases + alias exp='export' + exp E1=val + echo "E1=$E1" + declare -p E1 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml new file mode 100644 index 0000000..88afacd --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml @@ -0,0 +1,186 @@ +name: "Builtins: bind" +cases: + - name: "bind succeeds in non-interactive mode" + stdin: | + bind > /dev/null 2>&1 + echo "exit: $?" + + - name: "bind -l" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + min_oracle_version: "5.3" # Added functions in 5.3 + test_files: + - path: "run.sh" + contents: | + # List bindable actions but ignore brush custom entries + echo "[bind -l]" + bind -l | grep -v brush- | sort + echo "bind -l result: ${PIPESTATUS[@]}" + + - name: "bind -q/-P/-p" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + # Basic-check some known bindings + well_known_func="clear-screen" + + echo "[bind -q]" + bind -q ${well_known_func} + echo "bind -q result: ${PIPESTATUS[@]}" + + echo "[bind -P]" + bind -P | grep ${well_known_func} + echo "bind -P result: ${PIPESTATUS[@]}" + + echo "[bind -p]" + bind -p | grep ${well_known_func} + echo "bind -p result: ${PIPESTATUS[@]}" + + - name: "bind -x/-X/-r/-u" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + min_oracle_version: "5.3" # -X output changed in 5.3 + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + # Bind a key and check it + echo "[bind -x]" + bind -x '"\C-t":echo hi' + echo "bind -x result: $?" + + echo "[bind -X]" + bind -X + echo "bind -X result: $?" + + # Unbind it via -r + echo "[bind -r]" + bind -r '\C-t' + echo "bind -r result: $?" + bind -X + echo "bind -X result: $?" + echo "[bind -r (again)]" + bind -r '\C-t' + echo "bind -r (again) result: $?" + + # Unbind via -u + well_known_func="clear-screen" + echo "[bind -u]" + bind -u ${well_known_func} + echo "bind -u result: $?" + echo "[bind -q (after -u)]" + bind -q ${well_known_func} + echo "bind -q (after -u) result: $?" + + - name: "bind keyseq" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + well_known_func="clear-screen" + bind -u ${well_known_func} # Ensure it's unbound first + + # Bind a keyseq + echo "[bind keyseq]" + bind '"\C-t":clear-screen' + echo "bind keyseq result: $?" + + # List current bindings + bind -q ${well_known_func} + echo "bind -q result: $?" + + - name: "bind macro 1" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + key_to_bind='\C-r' + + echo "[bind -S (pre)]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" + + echo "[bind -s (pre)]" + bind -s | grep "${key_to_bind}" + echo "bind -s result: ${PIPESTATUS[@]}" + + echo "[bind macro]" + bind '"\C-r": "\e[0;1A\e[0;0A"' + echo "bind macro result: $?" + + echo "[bind -S]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" + + echo "[bind -s]" + bind -s | grep "${key_to_bind}" + echo "bind -s result: ${PIPESTATUS[@]}" + + echo "[bind -r]" + bind -r "${key_to_bind}" + echo "bind -r result: ${PIPESTATUS[@]}" + + echo "[bind -s (after -r)]" + bind -s | grep "${key_to_bind}" + echo "bind -s (after -r) result: ${PIPESTATUS[@]}" + + echo "[bind -S (after -r)]" + bind -S | grep "${key_to_bind}" + echo "bind -S (after -r) result: ${PIPESTATUS[@]}" + + - name: "bind macro 2" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + key_to_bind='\ec' + + echo "[bind macro]" + bind '"\ec": " \C-b\C-k`something`\e\C-e"' + echo "bind macro result: $?" + + echo "[bind -S]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" + + - name: "bind -m takes each keymap name" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + for keymap in emacs emacs-standard emacs-meta emacs-ctlx vi vi-move vi-command vi-insert; do + bind -m "${keymap}" -p >/dev/null + echo "${keymap}: $?" + done + bind -memacs -p >/dev/null; echo "attached: $?" + bind -m; echo "no keymap: $?" + + - name: "bind -m with an unknown keymap fails with status 1" + known_failure: true # TODO(bind): fails with status 2 + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + bind -m bogus -p >/dev/null; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml new file mode 100644 index 0000000..7b61618 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml @@ -0,0 +1,133 @@ +name: "Builtins: builtin" +cases: + - name: "builtin with no builtin" + stdin: builtin + + - name: "builtin with unknown builtin" + ignore_stderr: true + stdin: builtin not-a-builtin args + + - name: "valid builtin" + stdin: builtin echo "Hello world" + + - name: "valid builtin with hyphen args" + stdin: builtin echo -e "Hello\nWorld" + + - name: "builtin passing through results" + stdin: | + builtin false; echo "builtin false => $?" + builtin true; echo "builtin true => $?" + + - name: "builtin with non-decl builtin" + stdin: | + builtin echo variable=value + + - name: "builtin expands assignment-looking argument for ordinary builtin" + stdin: | + value=world + builtin echo argument=$value + + - name: "builtin expands assignment-looking argument for simple builtin" + stdin: | + value=world + builtin true argument=$value + echo "[$_]" + + - name: "builtin expands assignment-looking builtin name" + stdin: | + value=world + builtin missing=$value 2>/dev/null + printf 'status=%s last=<%s>\n' "$?" "$_" + + - name: "builtin with decl builtin" + stdin: | + builtin declare variable=value + declare -p variable + + - name: "builtin forwards expanded declaration assignments" + stdin: | + value=world + builtin declare declared=$value + builtin export exported=$value + declare -p declared exported + + - name: "builtin with disabled builtin" + ignore_stderr: true + incompatible_os: + - opensuse-tumbleweed + stdin: | + cd . + echo $? + + enable -n cd + builtin cd . + echo $? + + enable cd + builtin cd . + echo $? + + - name: "Function shadowing builtin receives its arguments" + stdin: | + builtin() { echo "shadow builtin: [$*] count=$#"; } + builtin echo hi + unset -f builtin + + - name: "builtin forwards a value with no field separators intact" + stdin: | + value=world + builtin declare declared=$value + builtin export exported=$value + declare -p declared exported + + - name: "builtin does not preserve assignment splitting semantics" + # brush treats `builtin` as a declaration wrapper, so an assignment argument passed through it + # keeps assignment semantics (no field splitting). Bash applies ordinary word expansion here, + # splitting the value and leaving a separate bare-name operand. + known_failure: true + ignore_stderr: true + stdin: | + v="x y" + builtin declare c=$v + declare -p c y + + - name: "builtin splits assignment-shaped words for ordinary builtins" + known_failure: true # TODO(builtin): brush's builtin is a declaration utility, so a=$x isn't split + stdin: | + x="1 2" + builtin printf '[%s]' a=$x + echo + + - name: "builtin --help prints help on stdout" + known_failure: true # TODO(builtin): --help is looked up as a builtin's name + stdin: | + builtin --help >/dev/null 2>&1; echo "rc=$?" + [[ -n $(builtin --help 2>/dev/null) ]] && echo "help on stdout" + + - name: "builtin rejects options" + known_failure: true # TODO(builtin): options are looked up as builtin names + ignore_stderr: true + stdin: | + builtin -z true; echo "rc=$?" + builtin -- true; echo "rc=$?" + + - name: "builtin drops a special builtin's fatal errors in POSIX mode" + ignore_stderr: true + stdin: | + set -o posix + builtin export --bogus + echo "still running: $?" + + - name: "builtin expands a ~ after the = of a declaration it passes on" + home_dir: . + stdin: | + test_root=~ + builtin export y=~/z + echo "${y//${test_root}/ROOT}" + builtin declare d=~/w + echo "${d//${test_root}/ROOT}" + + - name: "builtin passes --help on to the builtin it runs" + stdin: | + builtin cd --help >/dev/null 2>&1; echo "rc=$?" + [[ "$(builtin cd --help; echo .)" == "$(help cd; echo .)" ]] && echo "same as help cd" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml new file mode 100644 index 0000000..c5f3927 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml @@ -0,0 +1,67 @@ +name: "caller builtin" +cases: + - name: "caller in function" + test_files: + - path: script.sh + contents: | + function outer() { + echo "In outer" + inner + } + + function inner() { + echo "In inner" + + echo "[Calling caller 0]" + caller 0 + + echo "[Calling caller 1]" + caller 1 + } + stdin: | + source script.sh + outer + + - name: "caller no args" + known_failure: true # TODO(caller): needs investigation + test_files: + - path: script.sh + contents: | + function myfunc() { + echo "In myfunc" + caller + } + stdin: | + source script.sh + myfunc + + - name: "caller no args (nested)" + test_files: + - path: script.sh + contents: | + function myfunc() { + echo "In myfunc" + caller + } + + function myouterfunc() { + echo "In myouterfunc" + myfunc + } + stdin: | + source script.sh + myouterfunc + + - name: "caller invalid expr" + ignore_stderr: true # Different error messages + stdin: | + f() { caller x; } + f + + - name: "caller out of range" + stdin: | + caller 100 + + - name: "caller not in function" + stdin: | + caller diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml new file mode 100644 index 0000000..92662ee --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml @@ -0,0 +1,224 @@ +name: "Builtins: cd" +cases: + - name: "Basic cd usage" + ignore_stderr: true + stdin: | + echo "cd /" + cd / + echo $? + echo "pwd: $PWD" + + echo "cd usr" + cd usr + echo $? + echo "pwd: $PWD" + + echo "cd" + cd + echo $? + echo "pwd: $PWD" + + - name: "cd to file" + ignore_stderr: true + test_files: + - path: "file" + contents: "file contents" + stdin: | + ls -1 + cd file + echo $? + ls -1 + + - name: "cd -" + ignore_stderr: true + stdin: | + cd / + echo "pwd: $PWD" + cd usr + echo "pwd: $PWD" + echo "oldpwd: $OLDPWD" + cd - + echo $? + echo "pwd: $PWD" + + - name: "cd ~" + stdin: | + mkdir ./my_home + export HOME="$(realpath ./my_home)" + echo "Set HOME: $(basename $HOME)" + ( + echo "Subshell 1: HOME=$(basename $HOME)" + cd ~ + echo "pwd: $(basename $PWD)" + ) + ( + echo "Subshell 2: HOME=$(basename $HOME)" + cd -L ~ + echo "pwd: $(basename $PWD)" + ) + ( + echo "Subshell 3: HOME=$(basename $HOME)" + cd -P ~ + echo "pwd: $(basename $PWD)" + ) + + - name: "cd with symlink" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + # -L by default + ( + cd ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + + - name: "cd -L" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + echo "[Case 1]" + ( + cd ./symlink + echo "$(basename $PWD)" + cd -L .. + echo "$(basename $PWD)" + ) + + echo "[Case 2]" + ( + cd -L ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + + # without pwd + echo "[Case 3]" + ( + cd -L ./symlink + echo "$(basename $PWD)" + export PWD= + cd -L .. + echo "$(basename $PWD)" + ) + + echo "[Case 4]" + ( + cd -L ./symlink + export PWD= + # start a shell without $PWD + ( + cd . + echo "$(basename $PWD)" + ) + ) + + - name: "cd -P" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + echo "[Case 1]" + ( + cd ./symlink + echo "cd ./symlink => $(basename $PWD)" + cd -P .. + echo "cd -P .. => $(basename $PWD)" + ) + + echo "[Case 2]" + ( + cd -P ./symlink + echo "cd -P ./symlink => $(basename $PWD)" + cd .. + echo "cd .. => $(basename $PWD)" + ) + + - name: "OLDPWD" + env: + OLDPWD: "/nonexistent/path" + stdin: | + mkdir subdir + + echo "[0]" + echo ${OLDPWD+set} + + echo "[1]" + echo ${OLDPWD-NOTSET} + + echo "[2]" + declare -p OLDPWD 2>&1 | grep -o "declare -[a-z]* OLDPWD" + + echo "[3]" + env | grep "^OLDPWD=" || echo "NOT_IN_ENV" + + echo + echo "[Unsetting + cd'ing...]" + unset OLDPWD + cd $PWD/subdir + + echo "[4]" + declare -p OLDPWD 2>&1 | grep -o "declare -[a-z]* OLDPWD" + + echo "[5]" + env | grep -o "^OLDPWD=" + + - name: "cd .. from a deleted directory" + known_failure: true # TODO(cd): `cd ..` fails from a working directory that's been deleted + stdin: | + start=$PWD + mkdir gone + cd gone + rmdir "${start}/gone" + cd ..; echo "status: $?" + [[ $PWD == "${start}" ]] && echo "back at the start" || echo "somewhere else" + + - name: "cd --help prints help on stdout" + stdin: | + cd --help >/dev/null 2>&1; echo "rc=$?" + [[ -n $(cd --help 2>/dev/null) ]] && echo "help on stdout" + [[ -z $(cd --help 2>&1 >/dev/null) ]] && echo "nothing on stderr" + + - name: "cd takes -- and both of -L and -P" + stdin: | + mkdir -- -d + cd -- -d && basename -- "$PWD" + cd -- /; pwd + + - name: "cd takes the last of -L and -P" + stdin: | + mkdir real; ln -s real link + cd -LP link; basename -- "$PWD"; cd .. + cd -PL link; basename -- "$PWD"; cd .. + + - name: "cd --help prints what help cd prints" + stdin: | + [[ "$(cd --help; echo .)" == "$(help cd; echo .)" ]] && echo "same" + + - name: "cd -h is an invalid option and prints nothing on stdout" + stdin: | + x=$(cd -h 2>/dev/null); echo "rc=$? len=${#x}" + [[ -n $(cd -h 2>&1 >/dev/null) ]] && echo "error on stderr" + + - name: "cd --help after an operand is an operand" + known_failure: true # TODO(cd): --help anywhere is taken as a request for help + ignore_stderr: true + stdin: | + cd /tmp --help; echo "rc=$?" + + - name: "cd --help with stdout closed or read-only fails quietly" + stdin: | + { cd --help >&-; echo "rc=$?"; } 2>&1 + { cd --help 1&1 + + - name: "cd --help with stdout full fails quietly" + incompatible_platforms: ["macos"] # no /dev/full + stdin: | + { cd --help >/dev/full; echo "rc=$?"; } 2>&1 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml new file mode 100644 index 0000000..541e33b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml @@ -0,0 +1,13 @@ +name: "Builtins: colon" +cases: + - name: "Basic colon usage" + ignore_stderr: true + stdin: | + : + echo "Result 1: $?" + : something + echo "Result 2: $?" + : --anything=here or here + echo "Result 3: $?" + : --help + echo "Result 4: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml new file mode 100644 index 0000000..9900bf6 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml @@ -0,0 +1,483 @@ +name: "Builtins: command" +cases: + - name: "Basic command usage" + ignore_stderr: true + stdin: | + echo "Executing echo built-in" + command echo "Hello" + + echo "Executing ls using name" + command ls -d / + + echo "Executing ls using absolute path" + command $(which ls) -d / + + echo "Executing non-existent command by name" + command non-existent + + echo "Executing non-existent command by path" + command /usr/bin/non-existent + + - name: "Non-executable command" + ignore_stderr: true + stdin: | + mkdir subdir + + echo "Before executing" + ./subdir + echo "After executing: result=$?" + + echo "Before and-or list execution" + ./subdir || true + echo "After and-or list execution: result=$?" + + - name: "command -v" + stdin: | + echo "PATH: $PATH" + + echo "[echo]" + command -v echo + + echo "[non-existent]" + command -v non-existent || echo "1. Not found" + + echo "[/usr/bin/non-existent]" + command -v /usr/bin/non-existent || echo "2. Not found" + + - name: "command -v -p" + # On NixOS, `command -p`'s default "standard utilities" path diverges between + # brush and the oracle. brush derives it from glibc's confstr(_CS_PATH), which + # nixpkgs patches to include the Nix profile (so `cat` is found there), whereas + # nixpkgs' bash resolves a default path that excludes the Nix profile (so it + # finds nothing). This reflects where NixOS keeps its standard utilities, not a + # brush behavior difference, so it can't be reconciled against the oracle. + incompatible_os: ["nixos"] + stdin: | + unset PATH + + echo "[no -p]" + command -v cat + + echo "[-p]" + command -v -p cat + + - name: "command -v with full paths" + stdin: | + echo "[cat]" + command -v cat + + echo "[\$(command -v cat)]" + command -v $(command -v cat) + + - name: "command -v with multiple operands" + stdin: | + echo "[resolved builtins]" + command -v echo true pwd + echo "rc=$?" + + echo "[skipped miss]" + command -v echo non-existent pwd + echo "rc=$?" + + echo "[all misses]" + command -v non-existent also-non-existent + echo "rc=$?" + + - name: "command -V with multiple operands" + ignore_stderr: true # miss wording on stderr differs between brush and bash + stdin: | + command -V echo true + echo "rc=$?" + + command -V non-existent || echo "not found" + + - name: "command -V" + ignore_stderr: true + stdin: | + command -V echo + command -V ls + command -V $(which ls) + + command -V non-existent || echo "1. Not found" + command -V /usr/bin/non-existent || echo "2. Not found" + + - name: "command with --" + stdin: | + command -- ls + + - name: "command with --help" + stdin: | + command ls --help + + - name: "command forwards expanded declaration assignments" + known_failure: true # TODO: command builtin improvements required + stdin: | + value=world + command declare declared=$value + command export exported=$value + declare -p declared exported + + - name: "nested wrappers resolve ordinary and declaration targets" + known_failure: true # TODO: command builtin improvements required + stdin: | + value=world + command builtin echo ordinary=$value + command builtin declare declared=$value + builtin command echo reverse=$value + declare -p declared + + - name: "command expands assignment-looking argument for ordinary builtin" + stdin: | + value=world + command echo argument=$value + + - name: "command expands assignment-looking argument for external command" + stdin: | + value=world + command sh -c 'printf "<%s>\n" "$1"' sh argument=$value + + - name: "command expands assignment-looking command name" + stdin: | + value=world + command missing=$value 2>/dev/null + printf 'status=%s last=<%s>\n' "$?" "$_" + + - name: "command with -p" + ignore_stderr: true # In case it fails on the oracle as well, ignore the specific error message. + # See "command -v -p" above: `command -p`'s default utility path resolves + # differently on NixOS for brush (glibc confstr) vs. the oracle (nixpkgs bash). + incompatible_os: ["nixos"] + stdin: | + unset PATH + + command -p -- ls + + # + # Regression coverage: `command` does not change whether an argument is an assignment word, so + # its arguments undergo ordinary word expansion and the nested builtin recognizes any assignment + # syntax that survives. + # + - name: "command does not preserve assignment splitting semantics" + known_failure: true # TODO: command builtin improvements required + ignore_stderr: true + stdin: | + v="x y" + command declare b=$v + declare -p b y + + - name: "command forwards a value with no field separators intact" + known_failure: true # TODO: command builtin improvements required + stdin: | + value=world + command declare declared=$value + command readonly fixed=$value + declare -p declared fixed + + - name: "Function shadowing command receives its arguments" + stdin: | + command() { echo "shadow command: [$*] count=$#"; } + command echo hi + unset -f command + + - name: "command -v finds aliases, keywords, and functions" + stdin: | + shopt -s expand_aliases + alias al="echo hi" + f() { :; } + + command -v al; echo "rc=$?" + command -v if; echo "rc=$?" + command -v f; echo "rc=$?" + command -v echo; echo "rc=$?" + + - name: "command -V describes aliases, keywords, and functions" + stdin: | + shopt -s expand_aliases + alias al="echo hi" + f() { :; } + + command -V al; echo "rc=$?" + command -V if; echo "rc=$?" + command -V f; echo "rc=$?" + command -V echo; echo "rc=$?" + + - name: "command -v/-V with multiple names" + stdin: | + # Normalize away the leading "shell: line N:" that the oracle prefixes onto diagnostics. + strip() { sed 's/^.*command:/command:/'; } + + shopt -s expand_aliases + alias al="echo hi" + f() { :; } + + echo "[all found]" + command -v al f if echo; echo "rc=$?" + command -V al f if echo; echo "rc=$?" + + echo "[some found]" + command -v nonexistent f 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + command -v f nonexistent 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + command -V f nonexistent 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + echo "[none found]" + command -v nope1 nope2 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + command -V nope1 nope2 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: "command -v/-V output is reusable/quoted for odd alias values" + stdin: | + shopt -s expand_aliases + alias q="echo 'it'\''s" + alias q2='a "b" $c' + alias q3="'" + alias q4="a''b" + + command -v q + command -v q2 + command -v q3 + command -v q4 + command -V q + + - name: "command -v/-V honors expand_aliases" + stdin: | + strip() { sed 's/^.*command:/command:/'; } + alias za="ls" + + shopt -u expand_aliases + command -v za; echo "rc=$?" + command -V za 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + shopt -s expand_aliases + command -v za; echo "rc=$?" + + - name: "command -v/-V: last of -v/-V wins" + stdin: | + command -v -V echo + command -V -v echo + command -vV echo + command -Vv echo + command -V -v nonexistent; echo "rc=$?" + + - name: "command options given more than once" + stdin: | + command -vv echo; echo "rc=$?" + command -v -v echo; echo "rc=$?" + command -pp echo hi; echo "rc=$?" + command -vV -v echo; echo "rc=$?" + + - name: "command with an unknown option fails with status 2" + ignore_stderr: true + stdin: | + command -Z echo hi; echo "rc=$?" + command -v -Z echo; echo "rc=$?" + command --v echo; echo "rc=$?" + + - name: "command passes options after the command name on" + stdin: | + command echo -v x + command printf '%s\n' -v + command -v echo -v; echo "rc=$?" + command -v -- echo; echo "rc=$?" + + - name: "command -V reports hashed commands" + stdin: | + # Normalize away the temporary directory the test runs in. + scrub() { sed -E "s|$PWD||g"; } + + mkdir -p bin + printf '#!/bin/sh\n:\n' > bin/mytool + chmod +x bin/mytool + PATH="$PWD/bin:$PATH" + + hash mytool + command -v mytool | scrub + command -V mytool | scrub + + - name: "command -v/-V report path names as given" + stdin: | + printf '#!/bin/sh\necho hi\n' > prog + chmod +x prog + + command -v ./prog; echo "rc=$?" + command -V ./prog; echo "rc=$?" + + - name: "command -v with non-executable and non-file path names" + stdin: | + strip() { sed 's/^.*command:/command:/'; } + mkdir subdir + touch plainfile + + command -v ./subdir; echo "rc=$?" + command -V ./subdir 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + command -v ./plainfile; echo "rc=$?" + + - name: "command -v with empty name" + stdin: | + strip() { sed 's/^.*command:/command:/'; } + + command -v ""; echo "rc=$?" + command -V "" 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: "command -p searches the default path" + # See "command -v -p" above: on NixOS the oracle's default utility path finds no + # standard utilities at all, so the `sh` lookup below can't be reconciled there. + incompatible_os: ["nixos"] + stdin: | + strip() { sed 's/^.*command:/command:/'; } + + echo "[builtins are still found]" + command -p -v echo; echo "rc=$?" + command -p -V echo; echo "rc=$?" + + echo "[externals come from the default path]" + # bash's default path is compiled in and brush asks the C library for it, so the two + # can name different directories on the same host; only the basename is comparable. + dirless() { sed 's#/[^ ]*/#/#'; } + command -p -v sh | dirless; echo "rc=${PIPESTATUS[0]}" + command -p -V sh | dirless; echo "rc=${PIPESTATUS[0]}" + + echo "[PATH is ignored]" + mkdir -p bin + printf '#!/bin/sh\n:\n' > bin/mytool + chmod +x bin/mytool + PATH="$PWD/bin:$PATH" + command -v mytool > /dev/null; echo "rc=$?" + command -p -v mytool; echo "rc=$?" + + echo "[missing name]" + command -p -V definitely-not-a-real-command 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: "command -V reports a PATH-found command with an absolute path" + stdin: | + # Normalize away the temporary directory the test runs in. + scrub() { sed -E "s|$PWD||g"; } + + mkdir -p d1 + printf '#!/bin/sh\n:\n' > d1/ex + chmod +x d1/ex + touch d1/only + PATH="d1:$PATH" + + echo "[a non-executable in PATH is still reported]" + command -v only; echo "rc=$?" + command -V only | scrub; echo "rc=${PIPESTATUS[0]}" + + command -v ex; echo "rc=$?" + command -V ex | scrub; echo "rc=${PIPESTATUS[0]}" + + echo "[a \".\" entry contributes no \"./\" to the absolute path]" + printf '#!/bin/sh\n:\n' > here; chmod +x here + PATH=".:$PATH" command -v here; echo "rc=$?" + PATH=".:$PATH" command -V here | scrub; echo "rc=${PIPESTATUS[0]}" + + echo "[...but explicit and hashed paths are left alone]" + printf '#!/bin/sh\n:\n' > prog; chmod +x prog + command -V ./prog; echo "rc=$?" + hash -p rel/path hp + command -V hp; echo "rc=$?" + + - name: "command -v/-V with no operands" + stdin: | + command -v; echo "rc=$?" + command -V; echo "rc=$?" + + - name: "command -v/-V skips a disabled builtin" + stdin: | + # Normalize away the temporary directory the test runs in. + scrub() { sed -E "s|$PWD||g"; } + + mkdir -p bin + printf '#!/bin/sh\n:\n' > bin/echo + chmod +x bin/echo + PATH="$PWD/bin:$PATH" + + # `echo` is disabled for the rest of this block, so report through printf. + enable -n echo + command -v echo | scrub; disabled_v=${PIPESTATUS[0]} + command -V echo | scrub; disabled_V=${PIPESTATUS[0]} + enable echo + printf 'rc=%s rc=%s\n' "$disabled_v" "$disabled_V" + + command -v echo; echo "rc=$?" + + - name: "command -v/-V reports an alias ahead of a same-named function" + stdin: | + f() { :; } + shopt -s expand_aliases + alias f='echo A' + + command -v f; echo "rc=$?" + command -V f; echo "rc=$?" + + - name: "command -p -v/-V still honors the hash table" + stdin: | + hash -p /nonexistent/cat cat + command -v cat; echo "rc=$?" + command -p -v cat; echo "rc=$?" + command -p -V cat; echo "rc=$?" + + - name: "command passes on declaration expansion in POSIX mode" + known_failure: true # TODO(command): in POSIX mode, command doesn't expand c=$x as the declaration utility after it would + stdin: | + set -o posix + v="x y" + command declare b=$v + command command declare c=$v + declare -p b c + + - name: "command passes on declaration expansion only to the next word" + known_failure: true # TODO(command): in POSIX mode, command doesn't expand c=$x as the declaration utility after it would + stdin: | + set -o posix + v="x y" + command -p declare d=$v + command -- declare e=$v + declare -p d e + + - name: "command -v runs nothing, so expands no declarations" + ignore_stderr: true + stdin: | + set -o posix + v="x y" + command -v printf b=$v; echo "rc=$?" + + - name: "command drops a special builtin's fatal errors in POSIX mode" + ignore_stderr: true + stdin: | + set -o posix + command export --bogus + echo "still running: $?" + + + - name: "command reports a forwarded builtin's errors as the builtin's own" + known_failure: true # TODO(command): a forwarded builtin's errors are attributed to command too + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + { command export -Z; } 2>&1 | head -n 1 | strip + + - name: "command's program that stops becomes a job" + known_failure: true # TODO(signals): a program that stops isn't noticed, nor made a job + ignore_stderr: true + stdin: | + set -m + command sh -c 'kill -STOP $$; echo resumed' + echo "command: $?" + jobs >jobs.txt; echo "named: $(grep -c 'command sh -c' jobs.txt)" + fg >/dev/null; echo "fg: $?" + rm jobs.txt + + - name: "command drops a special builtin's special properties for the errors it returns" + ignore_stderr: true + stdin: | + set -o posix + command . /nonexistent + echo "after .: $?" + command source /nonexistent + echo "after source: $?" + command trap "echo" BOGUS + echo "after trap: $?" + + - name: "command passes --help on to the builtin it runs" + stdin: | + command cd --help >/dev/null 2>&1; echo "rc=$?" + [[ "$(command cd --help; echo .)" == "$(help cd; echo .)" ]] && echo "same as help cd" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml new file mode 100644 index 0000000..82538d6 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml @@ -0,0 +1,20 @@ +name: "Builtin Common Tests" +cases: + - name: "Piping builtin output" + stdin: | + shopt | wc -l | wc -l + + - name: "Redirecting builtin output" + stdin: | + declare my_variable=10 + declare -p my_variable >out.txt + + echo "Dumping file contents..." + cat out.txt + + - name: "Overrides" + ignore_stderr: true + stdin: | + declare -p myvar + myvar=10 declare -p myvar + declare -p myvar diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml new file mode 100644 index 0000000..e8d9a33 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml @@ -0,0 +1,655 @@ +name: "Builtins: compgen" +cases: + - name: "compgen -A alias" + stdin: | + alias myalias="ls" + alias myalias2="echo hi" + + compgen -A alias myalias + + - name: "compgen -A alias is sorted by name" + stdin: | + alias e=5 + alias c=3 + alias a=1 + alias f=6 + alias d=4 + alias b=2 + + compgen -A alias + + - name: "compgen -A binding" + stdin: | + compgen -A binding tab | sort + + - name: "compgen -A builtin" + stdin: | + compgen -A builtin cd | sort + + - name: "compgen lists builtins sorted by name" + stdin: | + compgen -b c + echo --- + compgen -A builtin e + + - name: "compgen lists enabled and disabled builtins sorted by name" + stdin: | + enable -n echo cd command + compgen -A disabled + echo --- + compgen -A enabled c + echo --- + compgen -A enabled e + + - name: "compgen -A directory" + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A directory some | sort + + - name: "compgen -A file" + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A file some | sort + + - name: "compgen -A file with dot files" + stdin: | + touch .file + mkdir .dir + + echo "[without dotglob]" + shopt -u dotglob + compgen -A file | sort + + echo "[with dotglob]" + shopt -s dotglob + compgen -A file | sort + + - name: "compgen -A function" + stdin: | + myfunc() { + echo hi + } + + myfunc2() { + echo hello + } + + compgen -A function myfunc + + - name: "compgen -A function is sorted by name" + stdin: | + e() { :; } + c() { :; } + a() { :; } + f() { :; } + d() { :; } + b() { :; } + + compgen -A function + + - name: "compgen -A function only returns functions matching the prefix" + stdin: | + zzfa() { :; } + zzfb() { :; } + totally_unrelated() { :; } + + compgen -A function zzf + echo "[$?]" + compgen -A function nomatch + echo "[$?]" + + - name: "compgen -A command is sorted by name" + stdin: | + zzfe() { :; } + zzfc() { :; } + zzfa() { :; } + + compgen -A command zzf + + - name: "compgen -A command only returns functions matching the prefix" + stdin: | + zzfa() { :; } + zzfb() { :; } + totally_unrelated() { :; } + + compgen -A command zzf + + - name: "compgen -A keyword" + stdin: | + compgen -A keyword esa | sort + + - name: "compgen -A variable" + stdin: | + declare myvar=10 + declare myvar2=11 + + compgen -A variable myvar | sort + + - name: "compgen -W" + stdin: | + echo "1. Basic compgen -W" + compgen -W "one two three" -- t | sort + echo "2. compgen -W with expansion" + myvar=value compgen -W '${myvar}' + + - name: "compgen -W splits before expanding each word" + stdin: | + echo "[brace words after default splitting]" + compgen -W '{a,b} c' + + echo "[IFS separator breaks brace syntax]" + IFS=, + compgen -W 'x{1,2}' + + echo "[empty list fields are removed]" + IFS=: + compgen -W 'a::b' + + - name: "compgen -W does not pathname-expand" + stdin: | + touch foo.txt bar.txt baz.log + + echo "[glob pattern should be literal]" + compgen -W '*.txt' | sort + + echo "[question mark glob should be literal]" + compgen -W '?.log' | sort + + - name: "compgen -W with unsorted values" + stdin: | + compgen -W 'c b d a' + + - name: "compgen -W with options" + stdin: | + compgen -W '--abc --def' -- '--ab' + + - name: "compgen with no matches" + stdin: | + compgen -W yes no && echo "1. Result" + + - name: "compgen -f with tilde" + home_dir: testhome # relative to default working dir + stdin: | + mkdir testhome && cd testhome + + echo "HOME base: $(basename $HOME)" + echo "~ base:" $(basename ~) + + touch item1 + + echo "[0]" + for p in $(compgen -f ~); do + echo ${p//$HOME/HOME} + done + + echo "[1]" + for p in $(compgen -f ~/); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted tilde" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '~/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted var" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '$HOME/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted + escaped var" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '\$HOME/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen with interesting hyphens" + stdin: | + compgen -P-before -S-after -W "one two three" -- t | sort + + - name: "compgen -X" + stdin: | + echo "[Take 1]" + compgen -W 'foo bar' -X 'foo' '' + + echo "[Take 2]" + compgen -W 'foo bar' -X 'f*' '' + + echo "[Take 3]" + compgen -W '&1 foo' -X '\&*' '' + + - name: "compgen -X with a leading ! keeps only matches" + stdin: | + compgen -W 'foo bar fab' -X '!f*' '' + echo "rc=$?" + compgen -W 'foo bar' -X '!z*' '' + echo "rc=$?" + + - name: "compgen -X with replacement" + stdin: | + echo "[Take 1]" + compgen -W 'somebody something' -X '&b*' some + + - name: "compgen -X with extglob" + stdin: | + touch README + shopt -s extglob + + echo "[Take 1]" + compgen -f READ + + echo "[Take 2]" + compgen -f -X "READ" READ + + echo "[Take 3]" + compgen -f -X "README" READ + + echo "[Take 4]" + compgen -f -X "!(READ)" READ + + echo "[Take 5]" + compgen -f -X "!(README)" READ + + echo "[Take 6]" + compgen -f -X "!!(READ)" READ + + echo "[Take 7]" + compgen -f -X "!!(README)" READ + + - name: "compgen -o dirnames" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o dirnames -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o dirnames -- s | sort + + echo "[Take 3]" + mkdir cdir cdir2 + touch cfile + compgen -W 'completion' -o dirnames -- c | sort + + echo "[Take 4]" + compgen -W 'nomatch' -o dirnames -- c | sort + + echo "[Take 5]" + compgen -o dirnames -- c | sort + + echo "[Take 6]" + compgen -W 'completion' -o dirnames -o plusdirs -- c | sort + + - name: "compgen -o default" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o default -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o default -- s | sort + + - name: "compgen -o bashdefault" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o bashdefault -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o bashdefault -- s | sort + + - name: "compgen -o plusdirs" + stdin: | + echo "[Take 1]" + touch cfile + mkdir cdir + compgen -W 'completion' -o plusdirs -o default -S suffix -- c | sort + + - name: "compgen -A command" + # Temporarily marked unconstrained Path case as `skip: true` to keep CI stable. + # Environment-dependent due to host PATH contents(Ubuntu includes X11, while Arch and others do not). + skip: true + stdin: | + compgen -A command | sort | grep -v "^brush" + + - name: "compgen -A command in a constrained PATH" + stdin: | + SORT=$(command -v sort) + GREP=$(command -v grep) + PATH=$(pwd) + compgen -A command | $SORT | $GREP -v "^brush" + + - name: "compgen -A command builtins in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + compgen -A command cd | $SORT + + - name: "compgen -A command keywords in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + compgen -A command [ | $SORT + + - name: "compgen -A command function in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + myfunc() { + echo hi + } + myfunc2() { + echo hello + } + compgen -A command myfunc | $SORT + + - name: "compgen -A command with executable symlink" + stdin: | + # pwd + # drwxr-xr-x ├──  test-bin + # .rwxr-xr-x │ ├──  dummy_exe + # .rw-r--r-- │ └──  dummy_non_exe + # drwxr-xr-x └──  test-links + # lrwxrwxrwx ├──  link_valid ⇒ $(pwd)/test-bin/dummy_exe + # lrwxrwxrwx └──  link_valid_exe_rec ⇒ $(pwd)/test-links/link_valid + # lrwxrwxrwx ├──  link_non_exec ⇒ $(pwd)/test-bin/dummy_non_exe + # lrwxrwxrwx ├──  link_broken ⇒ $(pwd)/test-bin/dummy_non_exist + # + mkdir test-bin + touch test-bin/dummy_exe + touch test-bin/dummy_non_exe + chmod +x test-bin/dummy_exe + + mkdir test-links + ln -s $(pwd)/test-bin/dummy_exe $(pwd)/test-links/link_valid + ln -s $(pwd)/test-links/link_vaild $(pwd)/test-links/link_valid_rec + ln -s $(pwd)/test-bin/dummy_non_exe $(pwd)/test-links/link_non_exec + ln -s $(pwd)/test-bin/dummy_non_exist $(pwd)/test-links/link_broken + + PATH="$(pwd)/test-links:$PATH" + + echo "[Valid]" + compgen -A command -- link_valid | sort + + echo "[Invalid]" + compgen -A command -- link_non_exec + compgen -A command -- link_broken + + - name: "compgen -A command includes aliases but not disabled builtins, in order" + stdin: | + mkdir test-bin + touch test-bin/zzqx + chmod +x test-bin/zzqx + PATH="$(pwd)/test-bin:$PATH" + alias zzqc=1 zzqa=1 + zzqf() { :; } + zzqb() { :; } + enable -n caller + compgen -A command zzq + echo "[disabled]" + compgen -A command caller + + - name: "compgen -k lists keywords in bash's order" + stdin: | + compgen -k + + - name: "compgen -A command lists keywords in bash's order" + stdin: | + PATH=/nonexistent + compgen -A command -- do + for w in $(compgen -A command -- f); do + case $w in fi|for|function) echo "$w" ;; esac + done + + - name: "compgen -F ignores a function that asks for completion to restart" + ignore_stderr: true + stdin: | + _comp() { COMPREPLY=(a); return 124; } + compgen -F _comp mycmd + echo "rc=$?" + + - name: "compgen -F tells the function about an empty line" + ignore_stderr: true + stdin: | + _comp() { + echo "#=$# 1=<$1> 2=<$2> 3=<$3> LINE=<$COMP_LINE> POINT=<$COMP_POINT>" + echo "WORDS=<${COMP_WORDS[*]}> NWORDS=${#COMP_WORDS[@]} CWORD=<$COMP_CWORD>" + } + compgen -F _comp xy + + - name: "compgen -C tells the command about an empty line" + ignore_stderr: true + stdin: | + compgen -C 'printf "<%s>\n"' xy + _cmd() { echo "LINE=<$COMP_LINE> POINT=<$COMP_POINT> #=$#"; } + export -f _cmd + compgen -C _cmd xy + + - name: "compgen -F sets COMP_KEY and COMP_TYPE to 0" + ignore_stderr: true + stdin: | + _comp() { echo "KEY=${COMP_KEY:-unset} TYPE=${COMP_TYPE:-unset}"; COMPREPLY=(); } + compgen -F _comp mycmd + + - name: "compgen -C passes words with commas or braces intact" + ignore_stderr: true + stdin: | + compgen -C 'printf "<%s>\n"' -- 'a,b' | grep -F '' + compgen -C 'printf "<%s>\n"' -- '{a,b}' | grep -F '<{a,b}>' + + - name: "compgen -F leaves no COMP_* variables set" + stdin: | + f() { :; } + compgen -F f x 2>/dev/null + echo "[${COMP_LINE-unset}] [${COMP_POINT-unset}] [${COMP_CWORD-unset}]" + + - name: "compgen -F leaves no COMP_* variables set, even ones set before or re-created" + stdin: | + COMP_LINE=user + f() { unset COMP_WORDS COMP_CWORD; COMP_WORDS=(n e w); COMP_CWORD=1; } + compgen -F f x 2>/dev/null + echo "[${COMP_LINE-unset}] [${COMP_WORDS[*]-unset}] [${COMP_CWORD-unset}]" + + - name: "compgen -F function's prefix assignment to a COMP_* variable is temporary" + stdin: | + f() { + before=$COMP_LINE + COMP_LINE=changed true + [[ $COMP_LINE == "$before" ]] && echo same + env | grep -c ^COMP_LINE + } + compgen -F f x 2>/dev/null + + - name: "compgen -A helptopic includes keywords" + known_failure: true # brush's help topics are only its builtins + stdin: | + compgen -A helptopic whil + + - name: "compgen -V" + known_failure: true # brush's compgen doesn't take -V + min_oracle_version: 5.3 + stdin: | + compgen -V out -W 'a b c' -- b; echo "rc=$?" + declare -p out + + - name: "compgen -W closes a quote left open at the end of the list" + stdin: | + compgen -W 'fo"o'; echo "rc=$?" + compgen -W 'fo"o bar'; echo "rc=$?" + compgen -W 'fo"o bar' -- f; echo "rc=$?" + compgen -W "a 'b c"; echo "rc=$?" + # A backslash left dangling at the end is dropped, except in single quotes. + word='fo"o\'; compgen -W "$word"; echo "rc=$?" + word='fo\'; compgen -W "$word"; echo "rc=$?" + word="fo'o\\"; compgen -W "$word"; echo "rc=$?" + + - name: "compgen -W with a $ before a quote" + # Only the lists that bash reads as a command line would. brush reads every list that way, + # deliberately unlike bash; see the brush-specific completion cases. + stdin: | + compgen -W "\"\$'a\" a\\\$'b'" + compgen -W "\$\$'a' \$\$\"b\" \$\$\$\$'d'" | sed "s/$$/PID/g" + compgen -W "\$(echo \$'\\x41') \`echo \$'\\x42'\`" + + - name: "compgen -W with an expansion error" + known_failure: true # brush's compgen fails with status 1, and the line carries on + ignore_stderr: true + stdin: | + w='a $(echo b'; compgen -W "$w"; echo "rc=$?" + echo next1 + w='a ${x c'; compgen -W "$w"; echo "rc=$?" + echo next2 + + - name: "compgen -W with an unset variable under set -u" + known_failure: true # brush's compgen fails with status 1, and the script carries on + ignore_stderr: true + stdin: | + set -u + compgen -W '$nope'; echo "rc=$?" + echo next + + - name: "compgen -W with ${x?} on an unset variable" + known_failure: true # TODO(builtins): brush's compgen fails with status 1, and the script carries on + ignore_stderr: true + stdin: | + compgen -W '${nope?boom}'; echo "rc=$?" + echo next + + - name: "compgen -W with an unset variable under set -u, in a function" + known_failure: true # TODO(builtins): brush's compgen fails with status 1, and the function and script carry on + ignore_stderr: true + stdin: | + set -u + f() { compgen -W '$nope'; echo "in f: $?"; } + f; echo "rc=$?" + echo next + + - name: "compgen -W with an unset variable under set -u, before ||" + known_failure: true # TODO(builtins): brush's compgen fails with status 1, and || handles it + ignore_stderr: true + stdin: | + set -u + compgen -W '$nope' || echo handled; echo "rc=$?" + echo next + + - name: "command compgen -W with ${x?} on an unset variable" + known_failure: true # TODO(builtins): brush's compgen fails with status 1, and the script carries on + ignore_stderr: true + stdin: | + command compgen -W '${nope?boom}'; echo "rc=$?" + echo next + + - name: "compgen -W with an unset variable under set -u ends only its subshell" + known_failure: true # TODO(builtins): brush's compgen fails with status 1, and the subshell carries on + ignore_stderr: true + stdin: | + ( set -u; compgen -W '$nope'; echo in-sub ); echo "rc=$?" + echo next + + - name: "compgen -A shopt lists names in order" + known_failure: true # TODO(compgen): shopt names come out in a different order on each run + stdin: | + compgen -A shopt | grep -E '^(ext|exp|exe|cdable|cdspell|checkhash|checkwinsize|cmdhist)' + + - name: "compgen -A setopt lists names in order" + known_failure: true # TODO(compgen): set -o names come out in a different order on each run + stdin: | + compgen -A setopt | grep -E '^(allexport|braceexpand|emacs|errexit|errtrace|functrace|hashall|histexpand|history|ignoreeof)$' + + - name: "compgen takes the last of a repeated option argument, and repeated flags" + stdin: | + compgen -W 'a b' -W 'c d' + compgen -W x -P a -P b -S c -S d + compgen -ff -W 'xa xb' x + + - name: "compgen takes flags clustered and option arguments attached" + stdin: | + compgen -Wab + compgen -kW 'zz' -- zz + compgen -Akeyword -W 'whz' wh + + - name: "compgen takes each shorthand action flag" + stdin: | + alias zzalias=ls zyalias=pwd + export ZZEXP=1 + zzvar=1 + zzfunc() { :; } + mkdir zzdir + touch zzfile + compgen -a z | sort; echo "rc=${PIPESTATUS[0]}" + compgen -b ech; echo "rc=$?" + compgen -d zz; echo "rc=$?" + compgen -e ZZ; echo "rc=$?" + compgen -f zz | sort + compgen -k fu; echo "rc=$?" + compgen -v zz; echo "rc=$?" + compgen -j; echo "rc=$?" + compgen -A function zz; echo "rc=$?" + enable -n echo + compgen -A disabled ech; echo "rc=$?" + compgen -A enabled ech; echo "rc=$?" + + - name: "compgen rejects unknown options and names" + stdin: | + for cmd in 'compgen -x' 'compgen -A bogus' 'compgen -o bogus' 'compgen -A' 'compgen -o' \ + 'compgen -W' 'compgen -X'; do + $cmd 2>/dev/null; echo "$cmd: rc=$?" + done + + - name: "compgen ignores words after the first" + known_failure: true # TODO(compgen): brush rejects them with a usage error + stdin: | + compgen -W 'ab ac' a zz; echo "rc=$?" + compgen -W 'ab ac' -- a zz; echo "rc=$?" + + - name: "compgen with no options succeeds" + known_failure: true # TODO(compgen): brush returns 1 + stdin: | + compgen; echo "rc=$?" + compgen x; echo "rc=$?" + compgen -- -x; echo "rc=$?" + compgen -o nospace x; echo "rc=$?" + + - name: "compgen -F and -C warn that they may not work as expected" + known_failure: true # TODO(compgen): brush prints no warning + stdin: | + strip() { sed -E 's/^.*compgen:/compgen:/'; } + f() { COMPREPLY=(a); } + compgen -F f 2>&1 | strip + compgen -C 'echo b' 2>&1 | strip + + - name: "compgen -A signal lists signals by number, with DEBUG, ERR, and RETURN last" + known_failure: true # TODO(compgen): brush sorts the names, so DEBUG and ERR come first, and leaves out RETURN + stdin: | + compgen -A signal | grep -E '^(EXIT|SIGHUP|SIGINT|SIGKILL|SIGTERM|DEBUG|ERR|RETURN)$' + + - name: "compgen -A variable lists names sorted" + known_failure: true # TODO(compgen): variable names come out in a different order on each run + stdin: | + zv5=1 zv3=1 zv8=1 zv1=1 zv7=1 zv2=1 zv6=1 zv4=1 + compgen -v zv diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml new file mode 100644 index 0000000..61e1b1f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml @@ -0,0 +1,434 @@ +name: "Builtins: complete" +cases: + - name: "complete with no options" + stdin: | + complete -W foo mycmd + complete + + - name: "complete -p with no such completion spec fails" + stdin: | + strip() { sed 's/^.*complete:/complete:/'; } + + complete -p mycmd 2>err; echo "rc=$?"; strip err; echo "rc=$?"; strip err; echo "rc=$?"; strip &1 | strip; echo "rc=${PIPESTATUS[0]}" + compopt ls nope cat 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: "compopt -E or -I with no options prints the spec's options" + min_oracle_version: "5.3" # the fullquote option was added in 5.3 + stdin: | + strip() { sed -E 's/^.*compopt:/compopt:/'; } + + for flag in -D -E -I; do + compopt $flag 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + done + + complete -E -o filenames -W x + complete -I -o fullquote -W y + compopt -E; echo "rc=$?" + compopt -I; echo "rc=$?" + compopt _EmptycmD_ _InitialWorD_; echo "rc=$?" + + - name: "compopt with no options quotes names as complete -p does" + min_oracle_version: "5.3" # the fullquote option was added in 5.3 + stdin: | + complete -W x -- 'a b' "x'y" -foo '*' + compopt -- 'a b' "x'y" -foo '*'; echo "rc=$?" + + - name: "compopt with options and names prints nothing" + stdin: | + complete -W x ls + compopt -o nospace ls; echo "rc=$?" + compopt +o nospace ls; echo "rc=$?" + compopt -o nospace +o nospace ls; echo "rc=$?" + complete -D -W y + compopt -D -o nospace; echo "rc=$?" + complete -p + + - name: "complete and compopt take the fullquote option" + min_oracle_version: "5.3" # the fullquote option was added in 5.3 + stdin: | + complete -o fullquote -W x ls; echo "rc=$?" + complete -p ls + compopt +o fullquote ls; echo "rc=$?" + complete -p ls + compopt -o fullquote ls; echo "rc=$?" + complete -p ls + compgen -o fullquote -W 'a b'; echo "rc=$?" + + - name: "complete -r" + stdin: | + echo "[Removing non-existent]" + complete -r nonexistent + echo $? + + echo "[Removing existing]" + complete -W token mycmd + complete -r mycmd + echo $? + complete -p mycmd 2>/dev/null + + - name: "compopt with a name that has no completion spec fails" + stdin: | + strip() { sed -E 's/^.*compopt:/compopt:/'; } + + complete -W x a + compopt -o nospace b a 2>err; echo "rc=$?"; strip /dev/null 2>&1; echo "b has a spec: rc=$?" + rm err + + - name: "compopt -D, -E, or -I with no such completion spec fails" + stdin: | + strip() { sed -E 's/^.*compopt:/compopt:/'; } + + for flag in -D -E -I; do + compopt $flag -o nospace 2>err; echo "rc=$?"; strip &1 | strip; echo "rc=${PIPESTATUS[0]}" + compopt 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: "compopt in a function that compgen -F runs fails" + stdin: | + _f() { compopt -o nospace 2>/dev/null; echo "compopt rc=$?"; COMPREPLY=(x); } + compgen -F _f y 2>/dev/null; echo "compgen rc=$?" + + - name: "complete -r with special options" + stdin: | + complete -W token mycmd + complete -W other -E + + complete -r -E + echo $? + + complete -p + + - name: "complete -p prints options, actions, and arguments in bash's order" + stdin: | + complete -v -o nospace -A function -a -o default -F fn -G '*.c' -W 'w1 w2' -X '*.x' -P pre -S suf -C cmd mycmd + complete -p mycmd + complete -A signal -A arrayvar -b -a -A builtin other + complete -p other + complete -X '*.y' -W x 'a b' + complete -p 'a b' + + - name: "complete -p output round-trips" + stdin: | + complete -o filenames -A function -X '*.x' -P pre -W 'w1 w2' mycmd + complete -D -X '*.y' -F fn + before=$(complete -p mycmd; complete -p -D) + complete -r + eval "$before" + after=$(complete -p mycmd; complete -p -D) + echo "$after" + [[ $before == "$after" ]] && echo "round-tripped" + + - name: "complete -p quotes names like bash, so its output round-trips" + stdin: | + complete -F '#fn' -W x '~' + complete -F a,b cmd1 + complete -F fn 'a,b' + before=$(complete -p '~' cmd1 'a,b') + echo "$before" + complete -r + eval "$before" + after=$(complete -p '~' cmd1 'a,b') + [[ $before == "$after" ]] && echo "round-tripped" + + - name: "complete actions are generated once each, in bash's order" + stdin: | + alias zzqa=1 + zzqf() { :; } + compgen -A function -A alias zzq + echo --- + compgen -A alias -A alias zzq + echo --- + complete -o nosort -A function -A alias -A function mycmd + complete -p mycmd + + - name: "compgen -W preserves duplicates" + stdin: | + compgen -W "a b a c b" + + - name: "compgen -W preserves order" + stdin: | + compgen -W "z y x a" + + - name: "complete -p shows a -X pattern with a leading ! as given" + stdin: | + complete -f -X '!*.txt' mycmd + complete -p mycmd + + - name: "compopt and complete -p take words after the first name as names" + known_failure: true # TODO(complete): options after a name are still parsed as options + min_oracle_version: "5.3" # the fullquote option was added in 5.3 + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + complete -W x ls + compopt ls -o nospace 2>err; echo "rc=$?"; strip err; echo "rc=$?"; strip err; echo "rc=$?"; strip /dev/null; echo "$cmd: rc=$?" + done + complete -p + + - name: "compopt takes +D, +E, and +I as -D, -E, and -I" + min_oracle_version: "5.3" # not checked against older versions + known_failure: true # TODO(compopt): brush rejects +D, +E, and +I as unknown options + stdin: | + complete -D -W x; complete -E -W y; complete -I -W z + compopt +D -o nospace; echo "rc=$?" + compopt +E -o nospace; echo "rc=$?" + compopt +I +o nospace -o dirnames; echo "rc=$?" + for flag in -D -E -I; do complete -p $flag; done + + - name: "complete with options but no names is a usage error" + known_failure: true # TODO(complete): brush succeeds, registering nothing + stdin: | + complete -W x 2>/dev/null; echo "rc=$?" + complete -o nospace 2>/dev/null; echo "rc=$?" + complete -p + + - name: "complete -F rejects a function name with blanks or metacharacters" + min_oracle_version: "5.3" # not checked against older versions + known_failure: true # TODO(complete): brush registers the spec with that name + stdin: | + complete -F 'a b' c1 2>/dev/null; echo "rc=$?" + complete -F 'f;g' c2 2>/dev/null; echo "rc=$?" + complete -p + + - name: "complete -p shows an empty -F function name unquoted" + min_oracle_version: "5.3" # not checked against older versions + known_failure: true # TODO(complete): brush shows it as '' + stdin: | + complete -F '' c + complete -p c diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml new file mode 100644 index 0000000..774481d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml @@ -0,0 +1,1460 @@ +name: "Builtins: declare" +common_test_files: + - path: "helpers.sh" + contents: | + stable_print_assoc_array() { + # TODO(nameref): enable use of nameref when implemented; for now + # we assume the name of the array is assoc_array + # local -n assoc_array=$1 + local key + + for key in $(printf "%s\n" "${!assoc_array[@]}" | sort -n); do + echo "\"${key}\" => ${assoc_array[${key}]}" + done + } + +cases: + - name: "Display vars" + stdin: | + declare myvar=something + declare -p myvar + + myarr=(a b c) + declare -p myarr + + - name: "Display vars with interesting chars" + stdin: | + (testvar="\"abc\"" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="a b c" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="'" && declare -p testvar && declare | grep testvar=) + + - name: "Display vars with adjacent single quotes" + stdin: | + (testvar="a''b" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="''" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="'a" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="a'" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (declare -a arr=("a''b" "'") && declare -p arr) + echo "-------------------------" + (declare -A m=([k]="a''b") && declare -p m) + + - name: "Display vars with interesting chars 2" + min_oracle_version: 5.2 # some sequences render differently in older shell versions + stdin: | + (testvar=$'a\nb' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$'\x03' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$'\x08' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$(printf '\033[34mabc\033[0m') && declare -p testvar && declare | grep testvar=) + + - name: "Declare integer" + stdin: | + declare -i num=10 + declare -p num + + echo $num + num+=10 + echo $num + + declare +i num + declare -p num + + echo $num + num+=10 + echo $num + + - name: "Appending to an integer wraps around on overflow" + stdin: | + declare -i n=1 + n+=9223372036854775807 + echo "$n" + + declare -ai arr=(1) + arr[0]+=9223372036854775807 + declare -p arr + + declare -Ai assoc=([key]=-2) + assoc[key]+=-9223372036854775807 + declare -p assoc + + - name: "Declare integer with non-integer string" + stdin: | + declare -i var=value + declare -p var + + - name: "Removing integer attribute prevents arithmetic evaluation" + stdin: | + declare -i var=5 + declare +i var=1+2 + declare -p var + + - name: "Invalid integer declaration does not evaluate value" + stdin: | + side_effect=0 + declare -i 'invalid-name=side_effect=1' 2>/dev/null + echo "status: $?" + echo "side effect: $side_effect" + + - name: "Readonly integer declaration does not evaluate value" + stdin: | + side_effect=0 + readonly var=1 + declare -i 'var=side_effect=1' 2>/dev/null + echo "status: $?" + echo "side effect: $side_effect" + + - name: "Update integer with non-integer string" + stdin: | + declare -i var=10 + declare -p var + + var=value + declare -p var + + - name: "Update integer indexed array with non-integer string" + stdin: | + declare -ai arr=() + declare -p arr + + arr[0]="value" + declare -p arr + + - name: "Update integer associative array with non-integer string" + stdin: | + declare -Ai arr=() + declare -p arr + + arr['key']="value" + declare -p arr + + - name: "Declare readonly variable" + ignore_stderr: true + stdin: | + declare -r var="readonly" + declare -p var + + echo $var + + var="change" + echo "change result: $?" + echo "var: ${var}" + + declare +r var + echo "+r result: $?" + declare -p var + + - name: "Declare array" + stdin: | + declare -a arr=("element1" "element2" "element3") + declare -p arr + echo "[0]: ${arr[0]}" + echo "[1]: ${arr[0]}" + echo "[2]: ${arr[0]}" + echo "[3]: ${arr[0]}" + echo "STAR: ${arr[*]}" + echo "AT: ${arr[*]}" + + - name: "Declare associative array" + stdin: | + declare -A arr=(["x1"]=1 ["x2"]=2) + declare -p arr + echo "[x]: ${arr[x1]}" + echo "[y]: ${arr[x2]}" + echo "[z]: ${arr[x3]}" + echo "STAR: ${arr[*]}" + echo "AT: ${arr[*]}" + + - name: "Declare and export variable" + stdin: | + declare -x myexportedvar="exported variable" + env | grep myexportedvar + + - name: "Declare and export unset variable" + stdin: | + declare -x myunsetexportedvar + env | grep myunsetexportedvar + set | grep myunsetexportedvar= + + - name: "Re-declaring variable" + stdin: | + var="value" + declare var + echo "var: ${var}" + + - name: "Appending via declare" + stdin: | + declare var="value" + declare -p var + + declare var+="appended" + declare -p var + + - name: "Multiple declare operands expand before assignments are applied" + stdin: | + unset first second + declare first=one second=$first + declare -p first second + + - name: "Declare command substitutions expand left to right exactly once" + stdin: | + marker=marker.txt + : > "$marker" + declare first=$(printf one; printf x >> "$marker") second=$(read -r contents < "$marker"; printf %s "$contents") + declare -p first second + read -r contents < "$marker" + echo "marker: $contents" + + - name: "Declare expansion error occurs before any operand is applied" + known_failure: true # Requires EXIT traps to fire at subshell exit; brush only runs them + # when the top-level shell exits, so the trap below never observes the state. + stdin: | + state=state.txt + : > "$state" + (trap 'declare -p first > "$state" 2>/dev/null || printf unset > "$state"' EXIT; set -u; declare first=one second=$missing) 2>/dev/null + echo "status: $?" + read -r contents < "$state" + echo "state: ${contents:-empty}" + + - name: "Aliases preserve declaration assignment expansion" + stdin: | + shopt -s expand_aliases + alias d=declare + alias e=export + value=world + d declared=$value + e exported=$value + declare -p declared exported + + - name: "Disabled declare uses ordinary argument expansion" + stdin: | + enable -n declare + value=world + declare disabled=$value 2>/dev/null + echo "status: $?" + enable declare + declare -p disabled 2>/dev/null || echo unset + + - name: "Declaring without value" + stdin: | + [[ -v var ]] && echo "1: Variable is set" + declare var + declare -p var + [[ -v var ]] && echo "2: Variable is set" + declare var2="" + declare -p var2 + [[ -v var2 ]] && echo "3: Variable is set" + + - name: "Displaying local vars" + stdin: | + function test { + echo "Dumping local variables (should be empty)" + local -p + + local -i int_var=10 + local -A assoc_array=(["x1"]=1 ["x2"]=2) + local -a array=(a b c) + local -r ro_var="readonly" + local -t traced="value" + + echo "Dump all variables" + local -p + } + + test + + - name: "Using local to detect function presence" + stdin: | + function test { + local something 2>/dev/null && echo "In function" + } + + local something 2>/dev/null || echo "Not in function" + + test + + - name: "Displaying function names" + stdin: | + echo "Dumping function names" + declare -F + declare -p -F + + function test { + : + } + + echo "Dumping function names again" + declare -F + declare -p -F + + echo "Dumping test" + declare -F test + declare -p -F test + + - name: "Displaying functions" + stdin: | + echo "Dumping functions" + declare -f + declare -p -f + + function test { + : + } + + echo "Dumping functions again" + declare -f + declare -p -f + + echo "Dumping test" + declare -f test + declare -p -f test + + - name: "Displaying functions with compound bodies" + stdin: | + f() { local x=1; if [ 1 ]; then echo "a b"; fi; for i in 1 2; do :; done; } + declare -f f + + g() { if [ 1 ]; then echo a; echo b; else echo c; fi; } + declare -f g + + h() { while :; do break; done; { echo a; }; case x in a) echo b;; esac; } + declare -f h + + i() { until :; do echo x; done; ( echo s; echo t ); } + declare -f i + + - name: "Displaying a function whose compound commands have redirections" + known_failure: true # brush-parser's Display puts no space between a compound command and its + # redirections (`fi2>& 1`, which doesn't parse again; `}2> /dev/null`), and + # prints `2>& 1` and `|cat` where bash prints `2>&1` and `| cat` + stdin: | + f() { if true; then :; fi 2>&1; while false; do :; done 3>/dev/null; { :; } 2>/dev/null; } + declare -f f + g() { [[ -n x ]] |& cat; } + declare -f g + + - name: "Displaying a function with case patterns" + stdin: | + f() { + case "$1" in + a|b|c) echo abc;; + (d|e) echo de;; + single) echo one;; + *) ;; + esac + } + declare -f f + + - name: "Displaying non-existent functions" + stdin: | + declare -f not_a_function + echo "Result (-f): $?" + + declare -F not_a_function + echo "Result (-F): $?" + + - name: "Valid conversions" + stdin: | + declare -a arr1=(a b c) + declare -a arr1 + echo "Conversion result: $?" + declare -p arr1 + + declare -A arr2=(["x1"]=1 ["x2"]=2) + declare -A arr2 + echo "Conversion result: $?" + declare -p arr2 + + declare scalar1="value" + declare -a scalar1 + echo "Conversion result: $?" + declare -p scalar1 + + declare scalar2="value" + declare -A scalar2 + echo "Conversion result: $?" + declare -p scalar2 + + - name: "Bad conversions" + ignore_stderr: true + stdin: | + declare -a arr1=(a b c) + declare -A arr1 + echo "Conversion result: $?" + declare -p arr1 + + declare -A arr2=(["x1"]=1 ["x2"]=2) + declare -a arr2 + echo "Conversion result: $?" + declare -p arr2 + + - name: "Declare -p using invalid forms" + ignore_stderr: true + stdin: | + declare arr=(a b c) + declare -p arr[0] + echo "Result: $?" + declare -p arr[0]=1 + echo "Result: $?" + + declare scalar=x + echo "Result: $?" + declare -p scalar=y + echo "Result: $?" + + - name: "Declare -p error shows expanded assignment" + stdin: | + value=world + output=$(declare -p missing=$value 2>&1) + status=$? + output=${output##*declare: } + echo "status: $status" + echo "error: $output" + + - name: "Updating value" + stdin: | + declare var="value" + declare -p var + declare var="changed" + declare -p var + + - name: "Updating value attributes" + stdin: | + declare -ix var=10 + declare -p var + declare +ix var + declare -p var + + - name: "Updating array" + stdin: | + declare arr=(a b c) + declare -p arr + declare arr=(d e) + declare -p arr + declare arr=10 + declare -p arr + + declare arr2=a + declare -p arr2 + declare -A arr2=(["key"]="value") + declare -p arr2 + + - name: "Updating causing conversion" + stdin: | + source helpers.sh + + declare assoc_array="scalar-value" + declare -p assoc_array + + declare -A assoc_array["key"]="key-value" + stable_print_assoc_array assoc_array + + - name: "Uppercase attribute" + stdin: | + declare var=value + + declare -u var + declare -p var + + var="abcd" + declare -p var + + declare another="abcd" + declare -u another + another+=efg + declare -p another + + - name: "Lowercase attribute" + stdin: | + declare var=value + + declare -l var + declare -p var + + var="AbCd" + declare -p var + + declare another="ABCD" + declare -l another + another+=EFG + declare -p another + + - name: "Capitalize attribute" + stdin: | + declare var=value + + declare -c var + declare -p var + + var="aBcD eFg" + declare -p var + + declare another="aBcD" + declare -c another + another+=eFg + declare -p another + + - name: "Declare invalid identifiers" + ignore_stderr: true + stdin: | + declare 1=x && echo "Set 1" + declare @=10 && echo "Set @" + + - name: "Nameref declare -p shows nameref attribute" + stdin: | + target="hello" + declare -n ref=target + declare -p ref + declare -p target + + - name: "Nameref declare -n with initial value" + known_failure: true + stdin: | + target="original" + declare -n ref=target + echo "ref: $ref" + echo "target: $target" + + - name: "Nameref remove attribute with +n" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "as nameref: $ref" + declare +n ref + echo "after +n: ref=$ref" + declare -p ref + + - name: "Nameref to array via declare" + known_failure: true + stdin: | + arr=(one two three) + declare -n ref=arr + echo "ref[0]: ${ref[0]}" + echo "ref[1]: ${ref[1]}" + echo "ref[@]: ${ref[@]}" + echo "ref[*]: ${ref[*]}" + + - name: "Nameref array modification via declare" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + ref[1]="modified" + echo "arr[1]: ${arr[1]}" + echo "ref[1]: ${ref[1]}" + + - name: "Nameref to associative array via declare" + known_failure: true + stdin: | + declare -A assoc=(["key1"]="val1" ["key2"]="val2") + declare -n ref=assoc + echo "ref[key1]: ${ref[key1]}" + echo "ref[key2]: ${ref[key2]}" + + - name: "Nameref array iteration in function via local -n" + known_failure: true + stdin: | + print_array() { + local -n arr=$1 + echo "count: ${#arr[@]}" + for elem in "${arr[@]}"; do + echo "elem: $elem" + done + } + + myarr=(first second third) + print_array myarr + + - name: "Nameref get keys via declare" + known_failure: true + stdin: | + declare -A assoc=(["a"]=1 ["b"]=2 ["c"]=3) + declare -n ref=assoc + echo "keys (sorted): $(printf '%s\n' ${!ref[@]} | sort | tr '\n' ' ')" + echo "count: ${#ref[@]}" + + - name: "Nameref array length via declare" + known_failure: true + stdin: | + arr=(one two three four five) + declare -n ref=arr + echo "length: ${#ref[@]}" + echo "first element length: ${#ref[0]}" + + - name: "Nameref declare -n filter" + stdin: | + regular="plain" + declare -n ref1=regular + declare -n ref2=regular + not_a_ref="also plain" + declare -p -n 2>/dev/null | sort + + - name: "Single-quoted scalar assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'x=hello' + echo "$x" + + - name: "Single-quoted scalar with dollar sign preserved" + known_failure: true # TODO: declare improvements required + stdin: | + Y=world + declare 'x=$Y' + echo "$x" + + - name: "Double-quoted scalar assignment expands" + known_failure: true # TODO: declare improvements required + stdin: | + Y=world + declare "x=$Y" + echo "$x" + + - name: "Single-quoted array assignment with -a" + known_failure: true # TODO: declare improvements required + stdin: | + declare -a 'arr=(1 2 3)' + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Single-quoted array assignment with deferred expansion" + known_failure: true # TODO: declare improvements required + stdin: | + X="a b" + declare -a 'arr=(${X})' + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Single-quoted array assignment with IFS splitting" + known_failure: true # TODO: declare improvements required + stdin: | + X="a,b" + IFS=, declare -a 'arr=(${X})' + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Double-quoted compound assignment with escaped expansion" + known_failure: true # TODO: declare improvements required + stdin: | + X="a b" + declare -a "arr=(\${X})" + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Variable-generated compound assignment syntax" + known_failure: true # TODO: declare improvements required + stdin: | + X="a b" + value='(${X})' + declare -a "arr=$value" + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Command-substitution-generated compound assignment syntax" + known_failure: true # TODO: declare improvements required + stdin: | + X="a b" + declare -a "arr=$(printf '(%s)' '${X}')" + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Single-quoted compound value in parsed assignment is reparsed" + known_failure: true # TODO: declare improvements required + stdin: | + X="a b" + declare -a arr='(${X})' + echo "${arr[@]}" + echo "${#arr[@]}" + + - name: "Single-quoted array assignment without -a is scalar" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'arr=(1 2 3)' + declare -p arr + + - name: "Single-quoted array assignment Gentoo rpm.eclass pattern" + known_failure: true # TODO: declare improvements required + stdin: | + RPM_COMPRESS_TYPE="lzma zstd" + IFS=, declare -a 'types=(${RPM_COMPRESS_TYPE})' + echo "${types[@]}" + echo "${#types[@]}" + + - name: "local -a single-quoted array assignment" + known_failure: true # TODO: declare improvements required + stdin: | + f() { + X="a b" + local -a 'arr=(${X})' + echo "${arr[@]}" + } + f + + - name: "Single-quoted indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'arr[0]=hello' + declare -p arr + + - name: "Quoted array assignment does not execute value as shell code" + known_failure: true # TODO: declare improvements required + stdin: | + payload='(x); printf "INJECTED\n"; #' + declare -a "arr=$payload" + echo "status: $?" + declare -p arr + + - name: "Quoted array assignment propagates assignment failure" + stdin: | + declare -ar arr=(old) + declare -a 'arr=(new)' 2>/dev/null + echo "status: $?" + declare -p arr + + - name: "Quoted array assignment applies uppercase attribute" + known_failure: true # TODO: declare improvements required + stdin: | + declare -au 'arr=(ab Cd)' + declare -p arr + + - name: "Quoted array assignment applies integer attribute" + # Integer assignment expression evaluation is a broader core assignment issue. + known_failure: true + stdin: | + declare -ai 'arr=(1+2 3*4)' + declare -p arr + + - name: "Quoted assignment uses existing indexed array type" + known_failure: true # TODO: declare improvements required + stdin: | + arr=(old) + declare 'arr=(new values)' + declare -p arr + + - name: "Quoted assignment uses existing associative array type" + known_failure: true # TODO: declare improvements required + stdin: | + declare -A map=([old]=value) + declare 'map=([new]=value [other]=two)' + declare -p map + + - name: "Quoted assignment uses declaration target scope" + known_failure: true # TODO: declare improvements required + stdin: | + arr=(global) + f() { + local arr=scalar + declare -g 'arr=(new values)' + declare -p arr + } + f + declare -p arr + + - name: "Quoted global assignment ignores local array type" + known_failure: true # TODO: declare improvements required + stdin: | + arr=global + f() { + local -a arr=(local) + declare -g 'arr=(new values)' + declare -p arr + } + f + declare -p arr + + - name: "Quoted local declaration does not inherit global array type" + known_failure: true # TODO: declare improvements required + stdin: | + arr=(global) + f() { + declare 'arr=(local values)' + declare -p arr + } + f + declare -p arr + + - name: "Single-quoted nonzero indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'arr[5]=hello' + declare -p arr + + - name: "Single-quoted indexed element append assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare -a arr=([0]=one) + declare 'arr[0]+=two' + declare -p arr + + - name: "Single-quoted associative element append assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare -A map=([key]=one) + declare 'map[key]+=two' + declare -p map + + - name: "Single-quoted arithmetic indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'arr[2+2]=value' + declare -p arr + + - name: "Single-quoted variable arithmetic indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + index=3 + declare 'arr[index+1]=value' + declare -p arr + + - name: "Single-quoted parameter-expanded indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + index=3 + declare 'arr[$index+1]=value' + declare -p arr + + - name: "Single-quoted command-substitution indexed element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + index=3 + declare 'arr[$(echo 2)+index]=value' + declare -p arr + + - name: "Single-quoted parameter-expanded associative element assignment" + known_failure: true # TODO: declare improvements required + stdin: | + key="space key" + declare -A 'map[$key]=value' + declare -p map + + - name: "Single-quoted indexed subscript expands without re-expanding value" + known_failure: true # TODO: declare improvements required + stdin: | + index=2 + value=expanded + declare 'arr[$index]=$value' + declare -p arr + + - name: "Single-quoted associative subscript expands without re-expanding value" + known_failure: true # TODO: declare improvements required + stdin: | + key="space key" + value=expanded + declare -A 'map[$key]=$value' + declare -p map + + - name: "Single-quoted append assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'text=one' + declare 'text+=two' 2>/dev/null + echo "status: $?" + declare -p text + + - name: "Single-quoted array-like scalar assignment with +a" + known_failure: true # TODO: declare improvements required + stdin: | + declare +a 'arr=(1 2)' + echo "status: $?" + declare -p arr + + - name: "Cannot remove indexed array attribute with +a" + known_failure: true # TODO: declare improvements required + stdin: | + declare -a arr=(1 2) + declare +a arr 2>/dev/null + echo "status: $?" + declare -p arr + + - name: "Cannot update indexed array while removing attribute with +a" + stdin: | + declare -a arr=(1 2) + declare +a 'arr=(3 4)' 2>/dev/null + echo "status: $?" + declare -p arr + + - name: "Single-quoted associative array assignment" + known_failure: true # TODO: declare improvements required + stdin: | + declare -A 'map=([alpha]=one [beta]=two)' 2>/dev/null + echo "status: $?" + declare -p map 2>/dev/null + + # + # Regression coverage: a shell function may shadow a declaration builtin. The builtin's + # arguments must still reach the function intact. + # + - name: "Function shadowing declare receives its arguments" + stdin: | + declare() { echo "shadow declare: [$*] count=$#"; } + declare x=1 + declare a b c + unset -f declare + + - name: "Function shadowing local and readonly receives its arguments" + stdin: | + readonly() { echo "shadow readonly: [$*] count=$#"; } + readonly r=1 + unset -f readonly + f() { + local() { echo "shadow local: [$*] count=$#"; } + local l=2 + unset -f local + } + f + + - name: "Function shadowing declare is reached through command" + known_failure: true # TODO: declare improvements required + stdin: | + declare() { echo "shadow declare: [$*]"; } + command declare cx=1 + unset -f declare + + # + # Regression coverage: an assignment operand is expanded before the command runs, so the + # command's own temporary environment and redirections cannot affect that expansion. + # + - name: "Temporary environment is not visible to a declare operand" + stdin: | + unset FOO + FOO=fromprefix declare y=$FOO + declare -p y + + - name: "Temporary IFS does not split an unquoted compound declare operand" + stdin: | + X="a:b" + IFS=: declare -a arr=($X) + declare -p arr + + - name: "Temporary IFS does split a quoted compound declare operand" + known_failure: true # TODO: declare improvements required + stdin: | + X="a,b" + IFS=, declare -a 'arr=(${X})' + declare -p arr + + - name: "Redirections do not apply to a declare operand's command substitution" + stdin: | + declare w=$(echo to-stderr >&2; echo val) 2>/dev/null + declare -p w + + # + # Regression coverage: an expansion error in an operand is fatal, exactly as it is for an + # ordinary assignment statement. + # + - name: "Unset variable in a declare operand is fatal under set -u" + ignore_stderr: true + stdin: | + ( set -u; declare x=$undefined; echo "NOT REACHED" ) + echo "status: $?" + + - name: "Unset variable in a local operand is fatal under set -u" + ignore_stderr: true + stdin: | + f() { local x=$undefined; echo "NOT REACHED"; } + ( set -u; f; echo "NOT REACHED EITHER" ) + echo "status: $?" + + # + # Regression coverage: `$_` and command tracing are the interpreter's responsibility, so they + # apply to every form of these builtins, not only to the forms that assign. + # + - name: "$_ - declare display and query forms" + stdin: | + value=world + echo seed > /dev/null + declare -p PWD > /dev/null; printf 'declare -p: [%s]\n' "$_" + echo seed > /dev/null + declare > /dev/null; printf 'declare bare: [%s]\n' "$_" + echo seed > /dev/null + f() { :; } + declare -f f > /dev/null; printf 'declare -f: [%s]\n' "$_" + echo seed > /dev/null + declare declared=$value; printf 'declare assign: [%s]\n' "$_" + + - name: "$_ - export and readonly display forms" + stdin: | + echo seed > /dev/null + export > /dev/null; printf 'export bare: [%s]\n' "$_" + echo seed > /dev/null + readonly > /dev/null; printf 'readonly bare: [%s]\n' "$_" + echo seed > /dev/null + readonly -p > /dev/null; printf 'readonly -p: [%s]\n' "$_" + + # + # Regression coverage: `declare array[index]` versus an assignment whose value merely looks + # like a subscript. + # + - name: "Assignment value ending in a bracket is not a subscripted name" + known_failure: true # TODO: declare improvements required + stdin: | + v='[a]' + declare "x=$v" + declare -p x + + - name: "Assignment value containing brackets is preserved" + known_failure: true # TODO: declare improvements required + stdin: | + declare 'a[1]=v[2]' + declare -p a + + - name: "Bare subscripted name declares an array" + stdin: | + declare 'arr[5]' + declare -p arr + + # + # Regression coverage: `+a`/`+A` report why they refuse to remove an array attribute. + # + - name: "Removing array attribute reports an error" + known_failure: true # TODO: declare improvements required + ignore_stderr: true + stdin: | + declare -a arr=(1 2) + declare +a arr + echo "status: $?" + declare -p arr + + - name: "Removing array attribute from a declared but unset array fails" + known_failure: true # TODO: declare improvements required + ignore_stderr: true + stdin: | + declare -a arr + declare +a arr + echo "status: $?" + declare -p arr + + - name: "Removing associative array attribute fails" + known_failure: true # TODO: declare improvements required + ignore_stderr: true + stdin: | + declare -A map=([k]=v) + declare +A map + echo "status: $?" + declare -p map + + # + # Regression coverage: subscript expansion for operands that arrive already expanded. + # + - name: "Parsed associative subscript is expanded" + stdin: | + declare -A m + key="a b" + declare m[$key]=v + declare -p m + + # These two pin the asymmetry in how subscripts are resolved: a `name[sub]=` subscript is + # expanded again by the builtin, while a compound element's key is already final. The key + # deliberately expands to command-substitution syntax, so a second expansion is visible in the + # result and the two cases cannot be collapsed without failing. + - name: "Subscript of a parsed element assignment is expanded a second time" + known_failure: true # TODO: declare improvements required + stdin: | + key='$(echo SECOND)' + declare -A m + declare m[$key]=v + declare -p m + + - name: "Compound element key is not expanded a second time" + stdin: | + key='$(echo SECOND)' + declare -A m=([$key]=v) + declare -p m + + - name: "Quoted element subscript is expanded exactly once" + known_failure: true # TODO: declare improvements required + stdin: | + key='$(echo SECOND)' + declare -A m + declare 'm[$key]=v' + declare -p m + + - name: "Parsed indexed subscript is arithmetically evaluated" + known_failure: true # TODO: declare improvements required + stdin: | + index=3 + declare arr[index+1]=v + declare -p arr + + - name: "Compound associative key is expanded exactly once" + stdin: | + key="a b" + declare -A m=([$key]=v) + declare -p m + + - name: "Compound indexed key is arithmetically evaluated" + known_failure: true # TODO: declare improvements required + stdin: | + declare -a a=([2+2]=v) + declare -p a + + - name: "Compound value assigned to an array element targets the whole array" + # A shell drops the subscript and applies the compound assignment to the whole array; brush + # stores the compound text as the element's literal value. + known_failure: true + stdin: | + declare -a n=(1) + declare -a 'n[0]=(9 8)' + echo "status: $?" + declare -p n + + - name: "Empty subscript is rejected" + # brush's bare-name array form accepts an empty subscript; a shell rejects it. + known_failure: true + ignore_stderr: true + stdin: | + declare 'b[]' + echo "status: $?" + declare -p b 2>&1 + + # + # An attribute option given as both `-` and `+`. + # + - name: "An attribute given as both - and + is not set" + stdin: | + declare +x -x zzx=1; declare -p zzx + declare -x +x zzy=1; declare -p zzy + declare +i -i zzi=1+1; declare -p zzi + declare zzo=1; declare +x -x zzo; declare -p zzo + + - name: "An attribute given as both - and + is removed" + stdin: | + declare -x zzx=1; declare -x +x zzx; declare -p zzx + declare -i zzi=1; declare +i -i zzi; declare -p zzi + + # + # Attribute-filtered display. + # + - name: "Display filtered by readonly and trace attributes" + stdin: | + declare -r zzro=1 + declare -t zztr=2 + declare -rt zzboth=3 + zzplain=4 + echo "-r:"; declare -pr | grep zz | sort + echo "-t:"; declare -pt | grep zz | sort + + - name: "Display filtered by several attributes lists any that match" + # A shell combines attribute filters as a union: `declare -rt` lists variables that are + # readonly or traced. brush intersects them, listing only variables that are both. + known_failure: true + stdin: | + declare -r zzro=1 + declare -t zztr=2 + declare -rt zzboth=3 + declare -prt | grep zz | sort + + - name: "Display is not filtered by a plus attribute option" + stdin: | + declare -x zzex=1 + zzplain=2 + declare +x | grep zz | sort + + - name: "Display is filtered by -X, not +X, when both are given" + stdin: | + declare -x zzex=1 + declare -i zzint=2 + zzplain=3 + declare -p -x +x | grep zz + declare -p +x -x | grep zz + declare -p -i +x | grep zz + + - name: "Combining -a and -A is rejected" + # A shell treats `-a` and `-A` together as an invalid option combination. brush accepts it and + # lets `-A` win, which lands on the same variable type but without the diagnostic or status. + known_failure: true + ignore_stderr: true + stdin: | + declare -aA both=(x) + echo "status: $?" + declare -p both 2>&1 + + - name: "Combining -a and -A with a compound operand still creates the variable" + # bash reports the invalid option combination and still creates the variable from the + # compound operand, as an associative array. brush creates nothing. + known_failure: true + ignore_stderr: true + stdin: | + declare -aA both=(x) + echo "status: $?" + declare -p both 2>/dev/null + echo "declare_status: $?" + + - name: "Display diagnostics name the builtin as invoked" + known_failure: true # TODO: declare improvements required + stdin: | + check() { + case $2 in + *"$1") echo "$1: ok";; + *) echo "$1: unexpected: $2";; + esac + } + check "declare: nosuch: not found" "$(declare -p nosuch 2>&1)" + check "typeset: nosuch: not found" "$(typeset -p nosuch 2>&1)" + f() { check "local: nosuch: not found" "$(local -p nosuch 2>&1)"; } + f + + - name: "Display quotes an associative key of @" + # bash quotes both `*` and `@` when displaying an associative key; brush quotes only `*`. + known_failure: true + stdin: | + declare -A zzat=(['@']=at ['*']=star [k]=v) + declare -p zzat + + - name: "Display filtered by array attributes includes unassigned arrays" + known_failure: true # TODO: declare improvements required + stdin: | + declare -a zzempty + declare -A zzemptyA + declare -a zzfull=(1) + declare -A zzfullA=([k]=v) + echo "-a:"; declare -pa | grep zz | sort + echo "-A:"; declare -pA | grep zz | sort + + - name: "Declaration diagnostics name the builtin and quote the operand" + known_failure: true # TODO: declare improvements required + # Covers every diagnostic the declaration builtins emit, so a new one cannot quietly omit the + # builtin's name. The leading "file: line N:" a shell prefixes is stripped. + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + local x=1 2>&1 | strip + v=w; declare -p missing=$v 2>&1 | strip + declare -p nosuchvar 2>&1 | strip + declare -a da=(1); declare +a da 2>&1 | strip + declare 'bad-name=1' 2>&1 | strip + f() { local 'bad-name2=1' 2>&1 | strip; }; f + readonly 'bad-name3=1' 2>&1 | strip + export -f nosuchfn 2>&1 | strip + export 'e[0]=1' 2>&1 | strip + + - name: "declare -fx exports a function" + stdin: | + f() { :; } + count_exported() { env | grep -c "^BASH_FUNC_f"; } + count_exported + declare -fx f; echo "rc=$?" + count_exported + declare +x -f f; echo "rc=$?" + count_exported + + - name: "declare -f with both -x and +x leaves a function unexported" + stdin: | + f() { :; } + count_exported() { env | grep -c "^BASH_FUNC_f"; } + declare -f -x +x f; echo "rc=$?" + count_exported + declare -fx f; declare -f +x -x f; echo "rc=$?" + count_exported + + - name: "declare -F -x lists only exported functions" + known_failure: true # TODO(declare): brush lists every function + stdin: | + f() { :; } + g() { :; } + declare -fx g + declare -F -x + + - name: "declare -f with attribute flags doesn't display the function" + stdin: | + f() { echo body; } + echo "[$(declare -ft f)]"; echo "rc=$?" + echo "[$(declare -fx f)]"; echo "rc=$?" + + - name: "declare -f with attribute flags on a missing function" + stdin: | + declare -ft nosuchfn 2>&1; echo "rc=$?" + declare -fx nosuchfn 2>&1; echo "rc=$?" + + - name: "declare -ft records the trace attribute" + known_failure: true # TODO(declare): brush accepts -t on functions but doesn't track it + stdin: | + f() { :; } + declare -ft f + declare -F + + - name: "Declaration builtins quote an invalid operand in full" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + declare 1x=2 2>&1 | strip + typeset 1x 2>&1 | strip + readonly 'a b=1' 2>&1 | strip + f() { local 1x=2 2>&1 | strip; }; f + + - name: "Quoted declaration with = in its subscript" + known_failure: true # TODO(declare): a quoted declaration whose subscript contains `=` isn't assigned + ignore_stderr: true + stdin: | + declare -A m + declare 'm[a=b]=1' + echo "declare: $?" + declare -p m + + - name: "Quoted declaration with text after its subscript" + ignore_stderr: true + stdin: | + declare 'x[1]]=2' + echo "declare: $?" + declare 'y[1]z=2' + echo "declare: $?" + declare -p x y + + - name: "Declaration expansion depends on the command name as written" + known_failure: true # TODO(declare): declaration expansion is decided by the expanded command name, not the name as written + stdin: | + v="a b" + declare x=$v; declare -p x + "declare" y=$v; declare -p y + \declare z=$v; declare -p z + cmd=declare + $cmd w=$v; declare -p w + + - name: "A quoted declaration's subscript is expanded and evaluated" + known_failure: true # TODO(declare): a word operand's subscript is used as written + stdin: | + i=3 + declare 'a[$i]=x' + declare -p a + k=foo + declare -A m + declare "m[\$k]=x" + declare -p m + + - name: "A command word that expands to nothing doesn't make the next word a declaration utility" + known_failure: true # TODO(declare): brush decides from the first word after expansion + stdin: | + v="a b" + e= + $e declare x=$v + declare -p x + + - name: "A function shadowing declare receives a compound assignment's name" + known_failure: true # TODO(declare): bash's parser hands the function `a` for `a=(1 2)`; brush hands it the whole word + stdin: | + declare() { echo "shadow declare: [$*]"; } + declare a=(1 2) + unset -f declare + + - name: "Declaration builtins take dash-prefixed operands after -- as names" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + { declare -- -x; echo "declare -- -x: $?"; } 2>&1 | strip + { typeset -- -x; echo "typeset -- -x: $?"; } 2>&1 | strip + { readonly -- -x; echo "readonly -- -x: $?"; } 2>&1 | strip + f() { { local -- -x; echo "local -- -x: $?"; } 2>&1 | strip; } + f + declare -- y=-z + declare -p y + + - name: "Declaration builtins take dash-prefixed words after the first operand as names" + stdin: | + declare a=1 -x 2>/dev/null; echo "declare: $?" + declare -p a + typeset t=1 -r 2>/dev/null; echo "typeset: $?" + declare -p t + readonly c=1 +x 2>/dev/null; echo "readonly: $?" + declare -p c + f() { local x=1 -r 2>/dev/null; echo "local: $?"; x=2; declare -p x; } + f + + - name: "the integer attribute evaluates an arithmetic value on assignment" + known_failure: true # TODO: integer-attributed assignments are not evaluated arithmetically + stdin: | + declare -i x=1+1 + declare -p x + declare -i y + y=2*3 + declare -p y + declare -ai a=(1+1 2*3) + declare -p a + + # + # `declare -g` names a global, but a shell resolves a subscripted operand's kind against the + # local that shadows it and then assigns there -- so the global is left untouched. + # + - name: "declare -g with a subscripted operand under a shadowing local" + stdin: | + declare -A g=([x]=1) + f() { + local -a g=(9) + declare -g g[y]=2 + declare -p g + } + f + declare -p g + + # + # A declaration naming a nameref acts on the variable it references. + # + - name: "declare assigns through a nameref" + known_failure: true # TODO: declaration builtins do not follow namerefs + stdin: | + declare -n nr=tgt + declare nr=hello + declare -p tgt nr + + - name: "a subscripted assignment through a nameref targets the referenced array" + known_failure: true # TODO: subscripted assignments do not follow namerefs + stdin: | + declare -A m + declare -n r=m + r[key]=v + declare -p m + declare r[other]=w + declare -p m + + # + # Every one of `declare`'s options has a `+X` form, including the four that select a mode rather + # than an attribute. For those four `+X` means "off", which is already the default, so they are + # accepted and (except for `+p`) do nothing: `+f` and `+F` drop the function restriction, `+g` + # drops global creation, and `+p` prints just as `-p` does. brush models those four as plain + # boolean flags with no plus form, so the word is rejected outright and the command exits 2. + # This also takes down any combined form that includes one of them, such as `+ft`. + # + - name: "declare +f drops the function restriction" + known_failure: true # TODO(declare): -f/-F/-g/-p have no `+X` form + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + f() { :; } + { declare +fp f; echo "status: $?"; } 2>&1 | strip + + - name: "declare +F drops names-only display and declares a variable instead" + known_failure: true # TODO(declare): -f/-F/-g/-p have no `+X` form + stdin: | + f() { :; } + declare -F f + declare +F f + echo "status: $?" + declare -p f + + - name: "declare +g declares locally inside a function" + known_failure: true # TODO(declare): -f/-F/-g/-p have no `+X` form + stdin: | + g=outer + f() { declare +g g=inner; echo "inside: $g"; } + f + echo "outside: $g" + + - name: "declare +p displays just as -p does" + known_failure: true # TODO(declare): -f/-F/-g/-p have no `+X` form + stdin: | + x=1 + declare -p x + declare +p x + echo "status: $?" + + - name: "declare +ft combines a mode option and an attribute option" + known_failure: true # TODO(declare): -f/-F/-g/-p have no `+X` form + stdin: | + f() { :; } + declare -ft f + declare +ft f + echo "status: $?" + declare -pF f + declare -p f diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml new file mode 100644 index 0000000..edc9607 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml @@ -0,0 +1,176 @@ +name: "Builtins: dot" +cases: + - name: "Basic dot usage" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + var="updated" + stdin: | + var="orig" + . script.sh + echo "var: ${var}" + + - name: "Basic source usage" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + for f in ${FUNCNAME[@]}; do + echo "FUNCNAME: '${f}'" + done + for s in ${BASH_SOURCE[@]}; do + echo "BASH_SOURCE: '${s}'" + done + var="updated" + + function script_func() { + echo "In script func" + for f in ${FUNCNAME[@]}; do + echo "FUNCNAME: '${f}'" + done + for s in ${BASH_SOURCE[@]}; do + echo "BASH_SOURCE: '${s}'" + done + } + stdin: | + var="orig" + source ./script.sh + echo "var: ${var}" + script_func + + - name: "Source non-existent file path" + ignore_stderr: true + stdin: | + source script.sh + echo "Result: $?" + + - name: "Source dir" + ignore_stderr: true + stdin: | + source . + echo "Result: $?" + + - name: "Source script without args" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + echo "Args: $@" + stdin: | + set -- outer1 outer2 outer3 + source script.sh + + - name: "Source script with args" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + echo "Args: $@" + stdin: | + set -- outer1 outer2 outer3 + source script.sh inner1 inner2 + + - name: "Source with redirection" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + stdin: | + source script.sh arg1 arg2 > out.txt + echo "Sourced script; dumping..." + cat out.txt + + - name: "Source /dev/stdin with heredoc redirection" + stdin: | + VAR=outer + VAR=scoped . /dev/stdin <<'EOF' + echo "VAR in source: $VAR" + INNER=from_source + EOF + echo "VAR after source: $VAR" + echo "INNER after source: $INNER" + + - name: "Source with return" + test_files: + - path: "script.sh" + contents: | + echo "Before return" + return 42 + echo "After return" + stdin: | + echo "Before source" + source script.sh + echo "After source, exit code: $?" + + - name: "Source with exit" + test_files: + - path: "script.sh" + contents: | + echo "Before exit" + exit 42 + echo "After exit" + stdin: | + echo "Before source" + source script.sh + echo "After source" + + - name: "Source with break in caller's loop" + test_files: + - path: "script.sh" + contents: | + echo "In script, breaking" + break + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + source script.sh + echo "After source" + done + echo "After loop" + + - name: "Source with continue in caller's loop" + test_files: + - path: "script.sh" + contents: | + echo "In script, continuing" + continue + echo "After continue" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + source script.sh + echo "After source" + done + echo "After loop" + + - name: "Sourcing a missing file fails and goes on" + ignore_stderr: true + stdin: | + . /nonexistent-file + echo "after: $?" + + - name: "Sourcing a file with an unset variable under set -u ends the shell" + ignore_stderr: true + test_files: + - path: "script.sh" + contents: | + echo "sourced $nope" + stdin: | + set -u + . ./script.sh; echo "rc=$?" + echo next + + - name: "source passes -- and options to the script" + test_files: + - path: "script.sh" + contents: | + echo "$#: $*" + stdin: | + . ./script.sh -- a + . ./script.sh -x + . -- ./script.sh b + + - name: ". --help prints what help . prints" + stdin: | + [[ "$(. --help; echo .)" == "$(help .; echo .)" ]] && echo "same" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml new file mode 100644 index 0000000..d4d7537 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml @@ -0,0 +1,65 @@ +name: "Builtins: echo" +cases: + - name: "echo with only --" + stdin: echo -- + + - name: "echo with -- and args" + stdin: echo -- -1 --"aaa" ?^1as- + + - name: "echo -e" + stdin: | + echo -e "1. \x65" | hexdump -C + echo -e "2. \x{65}" | hexdump -C + + - name: "echo options after an operand are operands" + stdin: | + echo hi -n + echo a -e 'x\ty' + echo -e a -n + f() { echo "$@"; } + f a -n + + - name: "echo with repeated options" + stdin: | + echo -n -n a; echo " rc=$?" + echo -nn b; echo " rc=$?" + echo -e -e 'c\td' + echo -n -e -n 'e\tf'; echo + + - name: "echo uses the last of -e and -E" + stdin: | + echo -e -E 'a\tb' + echo -E -e 'c\td' + echo -eE 'e\tf' + echo -Ee 'g\th' + + - name: "echo words that are not options" + stdin: | + echo - a + echo -nx b + echo -n -x c + echo -e -- 'd\te' + echo -n + + - name: "echo interprets escapes with xpg_echo" + known_failure: true # TODO(echo): xpg_echo is not implemented + stdin: | + shopt -s xpg_echo + echo 'a\tb' + echo -E 'c\td' + + - name: "echo takes no options in POSIX mode with xpg_echo" + known_failure: true # TODO(echo): xpg_echo is not implemented + stdin: | + set -o posix + shopt -s xpg_echo + echo -n 'a\tb' + echo -e 'c' + + - name: "echo takes --help, -h and a word of unknown letters as operands" + stdin: | + echo --help + echo -h + echo -enx 'a\tb' + echo -n -- a; echo + echo -- -n diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml new file mode 100644 index 0000000..9e5a9e8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml @@ -0,0 +1,54 @@ +name: "Builtins: enable" +cases: + - name: "List special builtins" + stdin: enable -s + + - name: "List default-disabled builtins" + stdin: enable -n + + - name: "List all builtins" + stdin: | + # List builtins but ignore any brush specific ones. + enable | grep -v brush + + - name: "Disable builtins" + ignore_stderr: true + stdin: | + type printf + + # Disable the builtin + PATH= + enable -n printf + + # Check + type printf + print "Gone\n" + + # Re-enable + enable printf + + # Re-check + type printf + printf "Back\n" + + - name: "enable -p lists only enabled builtins" + stdin: | + enable -n echo + enable -p | grep -c echo + enable -n -p | grep echo + + - name: "enable lists only enabled builtins, -a lists all of them" + stdin: | + enable -n echo + enable | grep -w echo; echo "default: $?" + enable -s | grep -w -e echo -e set + enable -a | grep -w -e echo -e printf + enable -n -a | grep -w -e echo -e printf + enable -n | grep -w -e echo -e printf + + - name: "enable takes --" + stdin: | + enable -n -- echo; echo "a=$?" + type -t echo + enable -- echo; echo "b=$?" + type -t echo diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml new file mode 100644 index 0000000..3b70f34 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml @@ -0,0 +1,99 @@ +name: "Builtins: eval" +cases: + - name: "Basic eval usage" + stdin: | + eval 'echo 1 + 1 == $((1 + 1))' + + - name: "eval with return in function" + stdin: | + f() { + eval 'return 42' + echo "Should not reach here" + } + f + echo "Exit code: $?" + + - name: "eval with exit" + stdin: | + eval 'exit 42' + echo "After eval'd exit" + + - name: "eval with break in loop" + stdin: | + for i in 1 2 3; do + eval 'break' + echo "After eval'd break" + done + echo "After loop" + + - name: "eval with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + eval 'continue' + echo "After eval'd continue" + done + echo "After loop" + + - name: "eval with nested break levels" + stdin: | + for i in 1 2; do + for j in a b c; do + eval 'break 2' + echo "After eval'd break (inner loop)" + done + echo "After eval'd break (outer loop)" + done + echo "After outer loop" + + - name: "Eval of code with syntax error" + known_failure: true # TODO(eval): a tokenizer error in eval'd code aborts the script instead of returning 2 + ignore_stderr: true + stdin: | + eval 'echo "a' + echo "rc=$?" + + - name: "eval with a syntax error returns 2" + known_failure: true # TODO(eval): a syntax error in eval's input ends a brush script + ignore_stderr: true + stdin: | + eval 'if' + echo "after eval: $?" + + - name: "eval with an unset variable under set -u ends the shell" + ignore_stderr: true + stdin: | + set -u + eval 'echo $nope'; echo "rc=$?" + echo next + + - name: "eval with ${x?} on an unset variable ends the shell" + ignore_stderr: true + stdin: | + eval 'echo ${nope?boom}'; echo "rc=$?" + echo next + + - name: "command eval with an unset variable under set -u ends the shell" + ignore_stderr: true + stdin: | + set -u + command eval 'echo $nope'; echo "rc=$?" + echo next + + - name: "builtin eval with an unset variable under set -u ends the shell" + ignore_stderr: true + stdin: | + set -u + builtin eval 'echo $nope'; echo "rc=$?" + echo next + + - name: "eval passes the evaluated command's options through" + stdin: | + eval -- echo a + eval echo -- b + eval echo -n c; echo + eval 'echo --help' + + - name: "eval rejects an unknown option with status 2" + stdin: | + eval -x 2>/dev/null; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml new file mode 100644 index 0000000..8adfb24 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml @@ -0,0 +1,133 @@ +name: "Builtins: exec" +cases: + - name: "Exec an invalid path" + ignore_stderr: true + stdin: | + exec /some/nonexistent/path + + - name: "Exec with no arguments" + stdin: | + pid=$$ + exec + [[ "${pid}" == "$$" ]] || echo "PID changed" + + - name: "Exec with no arguments and redirection for new fd" + stdin: | + exec 3>some-file.txt + echo "Hello, fd 3!" >&3 + + - name: "Exec with no arguments and redirection of stderr" + stdin: | + exec 2>/dev/null + + # Do something that should display an error to stderr + source /non-existent + + - name: "Exec with no arguments and redirection in a script" + test_files: + - path: "script.sh" + contents: | + exec 2>output-file.txt + echo hi >&2 + args: ["./script.sh"] + + - name: "Exec a command" + stdin: | + exec $0 -c 'echo "In child shell"' + echo "This is never reached" + + - name: "exec without -c" + stdin: | + export myvar=value + exec $0 -c 'echo "myvar: ${myvar}"' + + - name: "exec -c" + stdin: | + export myvar=value + exec -c $0 -c 'echo "myvar: ${myvar}"' + + - name: "exec -a" + stdin: | + exec -a shellname $0 -c 'echo "0: $0"' + + - name: "exec -c of a command found in the shell's PATH" + test_files: + - path: "bin/myscript" + executable: true + contents: | + #!/bin/sh + echo "in myscript" + stdin: | + PATH="${PWD}/bin:${PATH}" + exec -c myscript + echo "not reached" + + - name: "exec of a missing command exits the shell" + known_failure: true # TODO(exec): a failed exec doesn't end a non-interactive shell + ignore_stderr: true + stdin: | + (exec nonexistent-command; echo "subshell still running: $?") + echo "subshell: $?" + exec /nonexistent-command + echo "still running: $?" + + - name: "exec in a subshell ends the subshell" + known_failure: true # TODO(exec): exec in a subshell doesn't end the subshell + stdin: | + (exec true; echo "still running") + echo "true: $?" + (exec sh -c 'exit 3'; echo "still running") + echo "exit 3: $?" + (exec -a renamed sh -c 'echo "argv0: $0"') + (exec -l sh -c 'echo "login argv0: $0"') + echo hello | (exec cat; echo "still running") + + - name: "exec in a subshell runs the program, not a builtin" + known_failure: true # TODO(exec): exec in a subshell runs a builtin of the same name instead + ignore_stderr: true + stdin: | + # The printf builtin would print nothing; the program prints "-v". + (exec printf -v out hi) + + - name: "exec of a missing program reports it" + known_failure: true # TODO(exec): exec of a program that can't be found reports nothing at the top level + stdin: | + "$0" -c 'exec ./no-such-program' 2> err.txt + echo "status: $?" + [[ -s err.txt ]] && echo "reported" || echo "silent" + # (What it says has the test's directory in it, which differs between shells.) + rm err.txt + + - name: "exec starts a program by its full path" + known_failure: true # TODO(exec): exec starts a program by the path it was found by, not its full path, so a script sees that as its $0 + test_files: + - path: "sub/prog" + executable: true + contents: | + #!/bin/sh + echo "$0" + stdin: | + cd sub + started_by=$("$0" -c 'exec ./prog') + [[ ${started_by} == "${PWD}/prog" ]] && echo "full path" || echo "other: ${started_by}" + + - name: "exec -a takes a name that starts with -" + stdin: | + exec -a -shellname $0 --noprofile -c 'echo "0: $0"' + + - name: "exec passes the command's options through" + stdin: | + (exec -- echo a) + (exec echo -a b) + (exec echo -- c) + # A top-level exec replaces the shell with the program, so its arguments + # take the launch path rather than the builtin echo. + printf '#!/bin/sh\nprintf "[%%s]" "$@"; echo\n' > args; chmod +x args + "$0" --noprofile --norc -c 'exec -- ./args a' + "$0" --noprofile --norc -c 'exec ./args -a b' + "$0" --noprofile --norc -c 'exec ./args -- c' + rm args + + - name: "exec rejects an unknown option with status 2" + stdin: | + (exec -x echo hi) 2>/dev/null; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml new file mode 100644 index 0000000..adf82ae --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml @@ -0,0 +1,120 @@ +name: "Builtins: exit" +cases: + - name: "Exit without code" + stdin: | + exit + + - name: "Exit with code" + stdin: | + exit 10 + + - name: "Exit with i64 max" + stdin: | + exit 9223372036854775807 + + - name: "Exit with i64 min" + stdin: | + exit -9223372036854775808 + + - name: "Exit in for loop" + stdin: | + for i in 1 2 3; do + exit 42 + echo "Got past exit in loop" + done + echo "Got past loop" + + - name: "Exit in arithmetic for loop body" + stdin: | + for ((i = 0; i < 10; i++)); do + exit 42 + echo "Got past exit in loop" + done + echo "Got past loop" + + - name: "Exit in while loop condition" + stdin: | + while exit 42; do + echo "In loop" + done + echo "Got past loop" + + - name: "Exit in while loop body" + stdin: | + while true; do + exit 42 + echo "Got past exit in body" + break + done + echo "Got past loop" + + - name: "Exit in sequence" + stdin: | + exit 42; echo "Should not be printed" + + - name: "Exit in function" + stdin: | + myfunc() { + exit 42 + echo "Got past exit in function" + } + + myfunc + echo "Got past function call" + + - name: "Exit in subshell" + stdin: | + (exit 42) + echo "Got past subshell" + + - name: "Exit in command substitution" + stdin: | + output=$(echo hi; exit 42; echo there) + echo "Got past command substitution" + + - name: "Exit in and/or" + stdin: | + exit 42 || echo "Got past exit" + + - name: "Exit in brace group" + stdin: | + { + exit 42 + echo "Got past exit" + } + echo "Got past brace group" + + - name: "exit with too many arguments abandons the rest of the line" + known_failure: true # TODO(builtins): a usage error only skips exit + ignore_stderr: true + stdin: | + exit 1 2; echo "same line: $?" + echo "next line: $?" + + - name: "logout outside a login shell fails" + known_failure: true # TODO(logout): brush reports logout as unimplemented (status 99) + ignore_stderr: true + stdin: | + logout + echo "logout: $?" + logout -Z + echo "logout -Z: $?" + + - name: "exit in an rc file ends the shell" + known_failure: true # TODO(startup): an exit in a startup file doesn't end the shell + removed_default_args: ["--norc"] + ignore_stderr: true # an interactive shell prompts on stderr + args: ["--rcfile", "exitrc", "-i"] + test_files: + - path: "exitrc" + contents: | + echo "in rc" + exit 3 + echo "after exit" + stdin: | + echo "not run" + + - name: "exit takes -- and a negative status" + stdin: | + (exit -- 4); echo "a=$?" + (exit -1); echo "b=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml new file mode 100644 index 0000000..fd88936 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml @@ -0,0 +1,335 @@ +name: "Builtins: export" +cases: + - name: "Basic export usage" + stdin: | + MY_TEST_VAR="value" + echo "Looking for unexported variable..." + env | grep MY_TEST_VAR + + echo "Exporting and looking for it again..." + export MY_TEST_VAR + env | grep MY_TEST_VAR + + echo "Exporting with new value..." + export MY_TEST_VAR="changed value" + env | grep MY_TEST_VAR + + - name: "Exporting array" + stdin: | + export arr=(a 1 2) + declare -p arr + + - name: "Export scalar assignment with parameter expansion" + stdin: | + value=hello + export result="$value world" + echo "$result" + + - name: "Export scalar assignment with command substitution" + stdin: | + export result=$(printf hello) + echo "$result" + + - name: "Export structured assignment does not recursively expand its value" + stdin: | + source='$(printf expanded)' + export result=$source + declare -p result + + - name: "Export generated assignment does not expand its value again" + stdin: | + assignment='result=$(printf expanded)' + export "$assignment" + declare -p result + + - name: "Export indexed array with arithmetic key" + known_failure: true # TODO: export improvements required + stdin: | + export arr=([2+2]=value) + declare -p arr + + - name: "Export associative array with expanded key" + stdin: | + declare -A map + key="space key" + export map=([$key]=value) + declare -p map + + - name: "Unexporting variable" + stdin: | + MY_TEST_VAR="value" + export MY_TEST_VAR="value" + export -n MY_TEST_VAR + + env | grep MY_TEST_VAR + + - name: "Export with assignment through nameref" + known_failure: true + stdin: | + declare -n ref=MY_EXPORT_VAR + export ref="exported_value" + echo "MY_EXPORT_VAR: $MY_EXPORT_VAR" + env | grep MY_EXPORT_VAR + + - name: "Export append to existing variable" + stdin: | + MYVAR="-O3 -pipe" + export MYVAR+=" -Wno-foo" + echo "MYVAR: $MYVAR" + + - name: "Export append to already-exported variable" + stdin: | + export MYVAR="-O3 -pipe" + export MYVAR+=" -Wno-baz" + echo "MYVAR: $MYVAR" + + - name: "Export append to unset variable" + stdin: | + export MYVAR+="first" + echo "MYVAR: $MYVAR" + + - name: "Multiple export operands expand before assignments are applied" + stdin: | + unset first second + export first=one second=$first + declare -p first second + + - name: "Export command substitutions expand left to right exactly once" + stdin: | + marker=marker.txt + : > "$marker" + export first=$(printf one; printf x >> "$marker") second=$(read -r contents < "$marker"; printf %s "$contents") + declare -p first second + read -r contents < "$marker" + echo "marker: $contents" + + - name: "Export expansion error occurs before any operand is applied" + known_failure: true # Requires EXIT traps to fire at subshell exit; brush only runs them + # when the top-level shell exits, so the trap below never observes the state. + stdin: | + state=state.txt + : > "$state" + (trap 'declare -p first > "$state" 2>/dev/null || printf unset > "$state"' EXIT; set -u; export first=one second=$missing) 2>/dev/null + echo "status: $?" + read -r contents < "$state" + echo "state: ${contents:-empty}" + + - name: "Disabled export uses ordinary argument expansion" + stdin: | + enable -n export + value=world + export disabled=$value 2>/dev/null + echo "status: $?" + enable export + declare -p disabled 2>/dev/null || echo unset + + # + # Regression coverage: expansion, tracing, and `$_` for operands that arrive already expanded. + # + - name: "Temporary environment is not visible to an export operand" + stdin: | + unset FOO + FOO=fromprefix export y=$FOO + declare -p y + + - name: "Redirections do not apply to an export operand's command substitution" + stdin: | + export w=$(echo to-stderr >&2; echo val) 2>/dev/null + declare -p w + + - name: "Unset variable in an export operand is fatal under set -u" + ignore_stderr: true + stdin: | + ( set -u; export x=$undefined; echo "NOT REACHED" ) + echo "status: $?" + + - name: "Function shadowing export receives its arguments" + stdin: | + export() { echo "shadow export: [$*] count=$#"; } + export y=2 + unset -f export + + - name: "Export rejects an array element target" + ignore_stderr: true + stdin: | + export 'ea[0]=1' + echo "status: $?" + + - name: "Export identifier failure inside a pipeline exits with bash's status" + # A shell's declaration builtins report an invalid identifier with status 4 when run + # directly as a pipeline element; only at top level is it status 1. + known_failure: true # TODO: pipeline-element status for an invalid identifier is 1, not 4 + stdin: | + export "e[0]=1" 2>/dev/null | cat + echo "pipeline: ${PIPESTATUS[0]}" + export "e[0]=1" 2>/dev/null + echo "direct: $?" + + - name: "Export recognizes an assignment produced through command" + stdin: | + value=world + command export wrapped=$value + declare -p wrapped + + - name: "export rejects dash-prefixed operands after a name" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + { export v -x; echo "export v -x: $?"; } 2>&1 | strip + { export -- -x; echo "export -- -x: $?"; } 2>&1 | strip + { export w=1 +r; echo "export w=1 +r: $?"; } 2>&1 | strip + + - name: "Usage error in a special builtin is fatal in POSIX mode" + known_failure: true # TODO(posix): a special builtin's usage error doesn't end a non-interactive POSIX shell + ignore_stderr: true + stdin: | + set -o posix + export --bogus + echo "not reached" + + - name: "Usage error in a special builtin is not fatal outside POSIX mode" + ignore_stderr: true + stdin: | + export --bogus + echo "still running: $?" + + - name: "export --help prints help on stdout" + stdin: | + export --help >/dev/null 2>&1; echo "rc=$?" + [[ -n $(export --help 2>/dev/null) ]] && echo "help on stdout" + + - name: "export --help is fatal in POSIX mode" + known_failure: true # TODO(posix): a special builtin's --help doesn't end a non-interactive POSIX shell + stdin: | + set -o posix + export --help >/dev/null + echo "still running: $?" + + - name: "export -f of a missing function" + known_failure: true # TODO(export): fails with status 2, without attribution, where bash fails with 1 + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + export -f nofunc 2>&1 | strip + echo "export: ${PIPESTATUS[0]}" + + - name: "export reports a readonly variable and goes on" + known_failure: true # TODO(export): stops at a readonly variable instead of going on to the next operand + ignore_stderr: true + stdin: | + readonly r=1 + export r=2 y=3 + echo "export: $? r=$r y=$y" + readonly r=4 z=5 + echo "readonly: $? r=$r z=$z" + unset r w + echo "unset: $? r=$r" + + - name: "export rejects a name that isn't an identifier" + ignore_stderr: true + stdin: | + export "a b=1"; echo "a b=1: $?" + export 1x=2; echo "1x=2: $?" + export "a-b"; echo "a-b: $?" + + - name: "export rejects an invalid name and goes on with the rest" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + { export ok 1x also=2; echo "rc=$?"; } 2>&1 | strip + { export -n a-b=1 =x; echo "rc=$?"; } 2>&1 | strip + { export arr[1]=x; echo "rc=$?"; } 2>&1 | strip + export ok also=2 + env | grep -E '^(ok|also)=' + + - name: "export takes a word that expanded into an assignment" + stdin: | + v=A + export ${v}=x + env | grep '^A=' + export ${v}+=y + echo "A=$A" + w="B=" + export $w + env | grep -c '^B=' + + - name: "export takes -- and -n" + stdin: | + export -- B=2 + env | grep '^B=' + export -n B + env | grep -c '^B=' + echo "B=$B" + + - name: "export -f with no names lists the exported functions" + known_failure: true # TODO(export): lists the exported variables + stdin: | + f() { echo F; } + export -f f + export -f | grep -c 'declare -fx f' + export -f | grep -c 'declare -x ' + + - name: "export -nf of a name that isn't a function fails with status 1" + known_failure: true # TODO(export): fails with status 2 + ignore_stderr: true + stdin: | + export -nf nosuchfunction; echo "rc=$?" + + - name: "export rejects attribute options other than -a and -A" + stdin: | + for opt in -i -r -u -x -t -g; do + export $opt v=1 2>/dev/null + echo "$opt: $?" + done + declare -p v 2>/dev/null + echo "declare_status: $?" + + - name: "export -n on a missing variable creates nothing" + stdin: | + export -n nosuch_n + echo "status: $?" + declare -p nosuch_n 2>/dev/null + echo "declare_status: $?" + + - name: "export -n with an assignment creates the variable unexported" + stdin: | + export -n zz_n=5 + echo "status: $?" + declare -p zz_n + export -n zz_n2+=x + declare -p zz_n2 + + - name: "set -a does not export what export -n assigns" + # `set -a` outranks `declare +x`, but not `export -n`. + stdin: | + set -a + export -n zz_na=1 + declare +x zz_nb=2 + set +a + declare -p zz_na zz_nb + + - name: "export with no operands lists in declare -p form" + stdin: | + export zz_v=1 + export | grep zz_ + + - name: "export -n outranks set -a" + stdin: | + set -a + v=1 + export -n v=2 + declare -p v + w=1 + export -n w + declare -p w + export -n nv=3 + declare -p nv + + - name: "export -h is an invalid option and prints nothing on stdout" + stdin: | + x=$(export -h 2>/dev/null); echo "rc=$? len=${#x}" + + - name: "export treats a plus option as an invalid identifier operand" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + { export +x zz_plus=1; } 2>&1 | strip + export +x zz_plus=1 2>/dev/null + echo "status=$?" + declare -p zz_plus diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml new file mode 100644 index 0000000..3b2bfd4 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml @@ -0,0 +1,328 @@ +name: "Builtins: fc" +cases: + - name: "fc -l lists recent commands" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l + + - name: "fc -l with range (positive indices)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -l 2 4 + + - name: "fc -l with single argument" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l 2 + + - name: "fc -l with negative indices" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -l -3 -1 + + - name: "fc -l with string search" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + : cmd1 + : other + : cmd2 + : something + fc -l cmd + + - name: "fc -l with -n (no line numbers)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -ln + + - name: "fc -l with -r (reverse order)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -lr + + - name: "fc -l with -n and -r combined" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -lnr + + - name: "fc -l with range and -r" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -lr 2 4 + + - name: "fc -l 0 means -1" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l 0 + + - name: "fc -l defaults to last 16 commands" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + : cmd6 + : cmd7 + : cmd8 + : cmd9 + : cmd10 + : cmd11 + : cmd12 + : cmd13 + : cmd14 + : cmd15 + : cmd16 + : cmd17 + : cmd18 + fc -l + + - name: "fc -s re-executes last command" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s + + - name: "fc -s with command argument" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + echo world + fc -s echo + + - name: "fc -s with pattern replacement" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s hello=goodbye + + - name: "fc -s with command number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo third + fc -s 1 + + - name: "fc -s with negative offset" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo third + fc -s -2 + + - name: "fc -s with string prefix match" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + echo aaa + echo bbb + echo aac + fc -s aa + + - name: "fc -s with pattern replacement and command number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo hello + fc -s hello=goodbye 3 + + - name: "fc -s with pattern replacement and string prefix" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo aaa + : somecommand + echo hello world + fc -s hello=goodbye echo + + - name: "fc -s with pattern replacement and negative index" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + echo two + echo hello world + fc -s hello=goodbye -1 + + - name: "fc -s pattern replacement when pattern doesn't match" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s xyz=abc + + - name: "fc -s pattern replacement replaces all occurrences" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo test test test + fc -s test=foo + + - name: "fc -s with empty replacement" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello world + fc -s hello= + + - name: "fc with invalid range" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + fc -l 100 200 + expect_status: 1 + + - name: "fc -s with nonexistent command" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + echo hello + fc -s xyz + expect_status: 1 + + - name: "fc -l with both string and number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + : cmd1 + : cmd2 + : cmd3 + : other + fc -l cmd 3 + + - name: "fc -l numbering after history -d" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + history -d 1 + fc -l 1 3 + + - name: "history after fc -s" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + echo b + fc -s echo + history + + - name: "fc -s when HISTCONTROL leaves out the fc line" + known_failure: true # brush's fc assumes the newest entry is its own line + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignorespace" + stdin: | + echo first + echo second + fc -s + history + + - name: "fc -l when HISTCONTROL leaves out the fc line" + known_failure: true # brush's fc assumes the newest entry is its own line + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignoredups" + stdin: | + : a + : b + fc -l + fc -l + + - name: "fc rejects -h and an unknown option with status 2" + ignore_stderr: true + stdin: | + (fc -h); echo "rc=$?" + (fc -z); echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml new file mode 100644 index 0000000..3abd81b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml @@ -0,0 +1,527 @@ +name: "Builtins: getopts" +cases: + - name: "Basic getopts" + stdin: | + func() { + echo "Beginning of args" + while getopts "ab:" option "$@"; do + case ${option} in + a) echo "Option a; OPTARG=${OPTARG}";; + b) echo "Option b; OPTARG=${OPTARG}";; + *) echo "Unknown option: ${option}";; + esac + echo "OPTIND is now ${OPTIND}" + echo "OPTERR is now ${OPTERR}" + echo "----------------" + done + echo "Result: $?" + echo "option: ${option}" + echo "End of args" + } + + func -a -b my_b_arg + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: no args" + stdin: | + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: toplevel positional parameters" + stdin: | + set -- -a value + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: function positional parameters" + stdin: | + my_function() { + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + } + + my_function -b value + + - name: "getopts: function script parameters" + test_files: + - path: script.sh + contents: | + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + stdin: | + source ./script.sh -a value + + - name: "getopts: no options given" + stdin: | + while getopts "a:b:" myvar notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: options and non-options" + stdin: | + while getopts "ab:" myvar -a -b value notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: --" + stdin: | + while getopts "ab:" myvar -a -b value -- -c notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: -- as first arg" + stdin: | + while getopts "ab:" myvar -- -a; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: invalid option when optstr starts with colon" + stdin: | + getopts ":a:" myvar -b value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: invalid option when optstr does not start with colon" + ignore_stderr: true + stdin: | + getopts "a:" myvar -b value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: multiple options in one token" + stdin: | + while getopts "a:bcdefg" myvar -fedcba something -g; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: short option and value in single arg" + stdin: | + getopts "p:" myvar -p- + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + while getopts "abep:" myvar -abpsomething-else; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "-----------------" + done + + - name: "getopts: OPTERR=0 suppresses error for unknown option" + stdin: | + OPTERR=0 + getopts "a:" myvar -x 2>/dev/null + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTERR=1 shows error for unknown option" + ignore_stderr: true + stdin: | + OPTERR=1 + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTERR nonzero shows error" + ignore_stderr: true + stdin: | + OPTERR=42 + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + + - name: "getopts: missing required argument without leading colon" + ignore_stderr: true + stdin: | + getopts "a:" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing required argument with leading colon" + stdin: | + getopts ":a:" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing required argument with OPTERR=0" + stdin: | + OPTERR=0 + getopts "a:" myvar -a 2>/dev/null + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: combined unknown and missing arg errors" + ignore_stderr: true + stdin: | + func() { + while getopts "a:b:" opt "$@"; do + case "$opt" in + a) echo "a=$OPTARG" ;; + b) echo "b=$OPTARG" ;; + \?) echo "unknown" ;; + :) echo "missing arg for $OPTARG" ;; + esac + done + } + func -a foo -x -b + echo "Done: $?" + + - name: "getopts: bare dash as argument" + stdin: | + getopts "ab:" myvar - + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: leading colon with dash-colon option" + stdin: | + getopts ":a:" myvar -: + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: unknown option in middle of combined flags" + ignore_stderr: true + stdin: | + while getopts "ac" myvar -axc; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + + - name: "getopts: missing arg in combined flags" + ignore_stderr: true + stdin: | + while getopts "a:b" myvar -ba; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Final: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: combined flags with arg-taking option consumes next arg" + stdin: | + while getopts "a:b" myvar -ba nextarg; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND reset between calls" + stdin: | + while getopts "a:b" myvar -b -a foo; do + echo "myvar: ${myvar}; OPTARG: ${OPTARG-UNSET}; OPTIND: ${OPTIND}" + done + echo "--- reset ---" + OPTIND=1 + while getopts "a:b" myvar -b -a bar; do + echo "myvar: ${myvar}; OPTARG: ${OPTARG-UNSET}; OPTIND: ${OPTIND}" + done + + - name: "getopts: empty optstring" + ignore_stderr: true + stdin: | + getopts "" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND=0" + stdin: | + OPTIND=0 + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: negative OPTIND treated as reset" + stdin: | + OPTIND=-1 + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: bare dash among other args" + stdin: | + while getopts "ab" myvar -a - -b; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND past end of args" + stdin: | + OPTIND=100 + getopts "a:b:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing arg with leading colon in combined flags" + stdin: | + while getopts ":a:b" myvar -ba; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + + - name: "getopts: non-numeric OPTIND treated as reset" + stdin: | + OPTIND=abc + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: option value is empty string" + stdin: | + getopts "a:" myvar -a "" + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: '${OPTARG}'" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: option value looks like another option" + stdin: | + getopts "a:b" myvar -a -b + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND=0 resets mid-parse of combined options" + stdin: | + getopts "abc" myvar -abc + echo "first: ${myvar}" + OPTIND=0 + getopts "abc" myvar -abc + echo "after reset: ${myvar}" + + - name: "getopts: OPTERR non-numeric treated as enabled" + ignore_stderr: true + stdin: | + OPTERR=abc + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: invalid variable name rejected" + ignore_stderr: true + stdin: | + getopts "a" "123abc" -a + echo "Result: $?" + + - name: "getopts: duplicate option char in optstring uses first definition" + stdin: | + getopts "aa:" myvar -a val + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: optstring is only a colon" + ignore_stderr: true + stdin: | + getopts ":" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: empty string argument is not an option" + stdin: | + getopts "a" myvar "" + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts writes through nameref" + known_failure: true + stdin: | + declare -n ref=result + getopts "a:b" ref -a value + echo "Result: $?" + echo "result: ${result}" + echo "ref: ${ref}" + echo "OPTARG: ${OPTARG}" + + - name: "getopts writes through subscripted nameref" + known_failure: true + stdin: | + arr=(x x x) + declare -n ref='arr[1]' + getopts "a:b" ref -a value + echo "Result: $?" + echo "arr[1]: ${arr[1]}" + echo "ref: ${ref}" + + - name: "getopts reads a leading -- as the end of its own options" + known_failure: true # TODO(getopts): its arguments are split at the first `--`, so a leading one isn't the end of getopts' own options + stdin: | + getopts -- ab opt -a + echo "status=$? opt=$opt" + + - name: "getopts --help prints help on stdout" + stdin: | + getopts --help >/dev/null 2>&1; echo "rc=$?" + [[ -n $(getopts --help 2>/dev/null) ]] && echo "help on stdout" + + - name: "getopts takes --help after its optstring as an argument" + stdin: | + set -- --help + getopts h opt "$@" 2>/dev/null; echo "rc=$? opt=$opt" + OPTIND=1 + getopts ab opt -b --help; echo "rc=$? opt=$opt" + + - name: "getopts rejects an option before its optstring" + ignore_stderr: true + stdin: | + getopts -x opt; echo "rc=$?" + getopts -x opt -x; echo "rc=$?" + + - name: "getopts rejects a name that isn't an identifier" + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + getopts a 1x -a 2>err.txt; echo "getopts: $?"; strip /dev/null 2>&1 + hash -t ls + echo "2. Result: $?" + + - name: "Display hashed program" + stdin: | + hash -p /some/path somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + + - name: "Display multiple hashed programs" + stdin: | + hash -p /some/path somecmd1 && echo "1. Result: $?" + hash -p /some/path somecmd2 && echo "2. Result: $?" + hash -t somecmd1 somecmd2 && echo "3. Result: $?" + + - name: "Display -l path" + stdin: | + hash -p "/some/spaces path" somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + hash -t -l somecmd && echo "3. Result: $?" + + - name: "Remove" + ignore_stderr: true + stdin: | + hash -p /some/path somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + hash -d somecmd && echo "3. Result: $?" + hash -t somecmd && echo "4. Result: $?" + + - name: "Remove all" + ignore_stderr: true + stdin: | + hash -p /some/path somecmd1 && echo "1. Result: $?" + hash -p /some/path somecmd2 && echo "2. Result: $?" + hash -r && echo "3. Result: $?" + hash -t somecmd1 && echo "4. Result: $?" + hash -t somecmd2 && echo "5. Result: $?" + + - name: "Hash file path" + ignore_stderr: true + stdin: | + hash /dev/null && echo "1. Result: $?" + hash /dev/nulll && echo "2. Result: $?" + + - name: "hash -p refuses a directory" + stdin: | + # Normalize away the leading "shell: line N:" that the oracle prefixes onto diagnostics. + strip() { sed 's/^.*hash:/hash:/'; } + + mkdir -p adir + touch afile + + echo "[a directory is refused]" + hash -p adir z1 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + # The pipeline above ran in a subshell, so re-run it here to check the table itself. + hash -p adir z1 2>/dev/null; echo "rc=$?" + hash -t z1 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + echo "[any other path is taken at face value]" + hash -p afile z2; echo "rc=$?" + hash -p /nonexistent/x z3; echo "rc=$?" + hash -t z2 z3; echo "rc=$?" + + echo "[refused once per name]" + hash -p adir bad1 bad2 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + echo "[a relative path resolves against the shell's working directory]" + mkdir -p sub/reldir sub2 + cd sub + hash -p reldir z4 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + cd ../sub2 + # ../sub/reldir exists, but 'reldir' does not from here, so this is just a path. + hash -p reldir z5 2>/dev/null; echo "rc=$?" + hash -t z5; echo "rc=$?" + cd .. + + echo "[hash NAME stays strict about executables]" + mkdir -p d1; touch d1/only + PATH="d1:$PATH" + hash only 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + # + # `hash` with no names lists the table. brush prints nothing for any such listing, + # whether the table is empty or populated, so these are marked `known_failure`. Hit + # counts get their own case: they can only be observed through the listing, but they + # are a separate gap and a fix for one need not be a fix for the other. + # + - name: "Display the whole table" + known_failure: true # TODO(hash): `hash` with no names should list the table + stdin: | + echo "[empty]" + hash; echo "rc=$?" + + echo "[populated, in insertion order]" + hash -p /some/path sc1 + hash -p /other/path sc2 + hash; echo "rc=$?" + + echo "[emptied again]" + hash -r + hash; echo "rc=$?" + + - name: "Display the whole table in reusable form" + known_failure: true # TODO(hash): `hash -l` with no names should list the table + stdin: | + hash -p /some/path sc1 + hash -p "/some/spaces path" sc2 + + hash -l; echo "rc=$?" + + - name: "Count hits per hashed command" + known_failure: true # TODO(hash): hit counts are not tracked + stdin: | + # Normalize away the temporary directory the test runs in. + scrub() { sed -E "s|$PWD||g"; } + + printf '#!/bin/sh\n:\n' > prog + chmod +x prog + PATH="$PWD:$PATH" + + prog + prog + hash | scrub; echo "rc=${PIPESTATUS[0]}" + + - name: "Assigning PATH empties the table" + known_failure: true # TODO(hash): assigning PATH doesn't empty the table, so a command keeps being run from a directory no longer in PATH + ignore_stderr: true # How shells word what's not found differs + test_files: + - path: "x/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in x" + - path: "y/.keep" + stdin: | + PATH="${PWD}/x" + prog + PATH="${PWD}/y" + prog; echo "status: $?" + + - name: "Hash a path starting with a dot" + stdin: | + hash -p .nosuch foo + hash -t foo; echo "status: $?" + + - name: "Hash paths starting with a dot, as given" + test_files: + - path: "sub/prog" + executable: true + contents: | + #!/bin/sh + echo "0=$0" + stdin: | + cd sub + for path in ./././prog .//prog; do + hash -p "${path}" p + p + hash -t p + done + for path in ./nosuch/ ./nosuch//; do + hash -p "${path}" foo + hash -t foo; echo "status: $?" + done + + - name: "Hash a builtin or function" + known_failure: true # TODO(hash): hashing a builtin's name hashes a program of that name, and a function's fails, where bash does neither + ignore_stderr: true # How shells word what's not found differs + stdin: | + f() { :; } + hash echo; echo "echo: $?" + hash f; echo "f: $?" + hash -t echo; echo "hashed: $?" + + - name: "command -p hashes what it finds" + known_failure: true # TODO(hash): `command -p` doesn't hash the program it finds + stdin: | + command -p cat &1 | grep -q 'No such file' && echo "says no such file" || echo "says something else" + + - name: "hash -t with no names fails with status 1" + known_failure: true # TODO(hash): succeeds + ignore_stderr: true + stdin: | + hash -t; echo "rc=$?" + + - name: "hash -r -p empties the table, then adds the path" + known_failure: true # TODO(hash): the table is emptied after the path is added + stdin: | + hash -p /bin/true seeded + hash -r -p /bin/sh foo + hash -t foo + hash -t seeded 2>/dev/null; echo "seeded: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml new file mode 100644 index 0000000..779a8bf --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml @@ -0,0 +1,29 @@ +name: "Builtins: help" +cases: + - name: "Help" + ignore_stdout: true + ignore_stderr: true + stdin: | + help + + - name: "Topic-specific help" + ignore_stdout: true + ignore_stderr: true + stdin: | + help echo + + - name: "Help for unknown topic returns non-zero" + ignore_stdout: true + ignore_stderr: true + stdin: | + help this-is-not-a-real-builtin + + - name: "Help for known and unknown topics returns zero" + ignore_stdout: true + ignore_stderr: true + stdin: | + help echo this-is-not-a-real-builtin + + - name: "help -s frames each synopsis on its own line" + stdin: | + help -s true false : diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml new file mode 100644 index 0000000..af16cbe --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml @@ -0,0 +1,847 @@ +name: "Builtins: history" +cases: + - name: "basic history saving" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo something + echo else + + - name: "existing history file" + pty: true + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + history >history-output.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "existing history file with timestamps" + pty: true + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + #1750000000 + a b c + #1750000001 + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + history >history-output.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "existing history file with timestamps and HISTTIMEFORMAT" + pty: true + env: + HISTFILE: "history-file.txt" + HISTTIMEFORMAT: "%C " + test_files: + - path: "history-file.txt" + contents: | + #1750000000 + a b c + #1750000001 + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + #send:Ctrl+D + + - name: "history N" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + history 2 + + - name: "history with duplicates" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd1 + : cmd1 + : cmd2 + : cmd1 + history + + - name: "history -c" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -c + history + + - name: "history -d" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -d 1 + history + + - name: "history -d: negative offset" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + history -d -2 + history + + - name: "history -a" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo "[histfile]" + cat $HISTFILE + + history -a + + echo "[histfile after -a]" + cat $HISTFILE + + history -a + + echo "[histfile after second -a]" + cat $HISTFILE + + - name: "history -w" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo "[histfile]" + cat $HISTFILE + + history -w + + echo "[histfile after -w]" + cat $HISTFILE + + history -w + + echo "[histfile after second -w]" + cat $HISTFILE + + - name: "history -w with explicit file" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -w some-file.txt + + - name: "history -s" + known_failure: true # NOTE: bash excludes the history command itself from history + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -s a b c + history + + - name: "HISTCMD" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + echo $HISTCMD + : cmd2 + echo $HISTCMD + : cmd3 + echo $HISTCMD + + - name: "HISTTIMEFORMAT" + known_failure: true # TODO(history): needs triage + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + HISTTIMEFORMAT: "century=%C " + stdin: | + : cmd1 + : cmd2 + history + + - name: "history file with unreadable lines" + ignore_stderr: true # Ignore brush's warnings about unreadable lines + stdin: | + bash -c 'printf "\xff\xfe\xfd" >history-file.txt' + HISTFILE=history-file.txt $0 -o history -c 'echo hi' + + # NOTE: These cases run interactively with the harness's --norc removed so that ~/.bashrc is + # honored. The system rc file also runs on some distros and may set HISTTIMEFORMAT, so each + # .bashrc unsets it. + - name: "HISTFILE loaded from environment" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + stdin: | + history + + - name: "HISTFILE changed by .bashrc" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: "other-history-file.txt" + contents: | + x y z + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + HISTFILE=other-history-file.txt + stdin: | + history + + - name: "HISTFILE emptied by .bashrc" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + HISTFILE= + stdin: | + history + + - name: "empty HISTFILE in environment" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "" + test_files: + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + stdin: | + history + + - name: "history -w with empty HISTFILE" + min_oracle_version: "5.3" # bash 5.2 fails with status 1; 5.3 warns but succeeds + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "" + stdin: | + history -w; echo "status: $?" + + - name: "history -s in .bashrc suppresses loading HISTFILE" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + history -s marker + stdin: | + history + + - name: "history -s then -c in .bashrc still loads HISTFILE" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + history -s marker + history -c + stdin: | + history + + - name: "history -s then -d in .bashrc still loads HISTFILE" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + history -s marker + history -d 1 + stdin: | + history + + - name: "loaded history entries without timestamps are stamped with load time" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTTIMEFORMAT: "[stamped] " + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + stdin: | + history + # Unset HISTFILE before exit so no wall-clock timestamp gets written to the file. + unset HISTFILE + + # Numbering: an entry's number is its position, so removing one renumbers those after + # it, and new entries are numbered after what's left. + - name: "history -d from the middle renumbers later entries" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + history -d 2 + : d + history + + - name: "history -d of the last entry" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + history -d -1 + history + + - name: "history -d repeatedly" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + : d + history -d 1 + history -d 1 + history -d 2 + history + + - name: "history -d range" + known_failure: true # brush's history -d takes only a single offset + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + : d + history -d 2-3 + history + + - name: "history numbering after history -c" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + history -c + : c + : d + history + + - name: "HISTCMD after history -d" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + history -d 1 + echo $HISTCMD + : d + echo $HISTCMD + + - name: "history -a after history -d" + # Deletes an entry not yet appended. (See the next case for one already appended.) + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + history -a + : b + : c + history -d -2 + history -a + echo "[histfile]" + cat $HISTFILE + + - name: "history -a after history -d of an entry already appended" + # bash counts the entries to append rather than tracking which aren't saved, so + # deleting one already appended shifts what its next -a appends: it skips the + # history -d, which was never saved. brush appends it. (Arguably a bash quirk.) + known_failure: true + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + history -a + history -d 1 + : c + history -a + echo "[histfile]" + cat $HISTFILE + + - name: "history -w after history -d" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + : b + : c + history -d 2 + history -w + echo "[histfile]" + cat $HISTFILE + + - name: "history -d in history loaded from HISTFILE" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + removed_default_args: ["--norc"] + args: ["-i"] + home_dir: "." + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + one + two + three + - path: ".bashrc" + contents: | + unset HISTTIMEFORMAT + stdin: | + history -d 2 + : four + history + # Unset HISTFILE before exit so the shell doesn't write it back. + unset HISTFILE + + # HISTCONTROL applies only to interactive shells. + - name: "HISTCONTROL=ignoredups" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignoredups" + stdin: | + : a + : b + : b + : a + history + + - name: "HISTCONTROL=ignorespace" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignorespace" + stdin: | + : shown + : hidden + : shown + history + + - name: "HISTCONTROL=ignoreboth" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignoreboth" + stdin: | + : a + : hidden + : a + : b + history + + - name: "HISTCONTROL=erasedups" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "erasedups" + stdin: | + : a + : b + : a + : c + : a + history + + - name: "HISTCONTROL with unknown values in its list" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "bogus:ignoredups" + stdin: | + : a + : a + history + + # N.B. Quoted so the trailing whitespace these depend on survives editors and hooks. + - name: "History keeps a command's surrounding whitespace" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + stdin: ": first\n : indented\n: trailing \nhistory\n" + + - name: "HISTCONTROL=ignoredups tells apart lines differing in whitespace" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "ignoredups" + stdin: ": same\n: same \n : same\nhistory\n" + + - name: "HISTCONTROL=erasedups tells apart lines differing in whitespace" + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTCONTROL: "erasedups" + stdin: ": same\n: same \n : same\n: other\n: same\nhistory\n" + + - name: "set +o history stops recording" + known_failure: true # brush keeps recording after `set +o history` + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + stdin: | + : a + set +o history + : hidden + set -o history + : b + history + + - name: "HISTIGNORE" + known_failure: true # brush doesn't implement HISTIGNORE + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + HISTIGNORE: "ls*:&:echo skip" + stdin: | + : a + ls >/dev/null + : a + echo skip + : b + history + + - name: "cmdhist saves a multi-line command as one line" + known_failure: true # brush saves the command's newlines as typed + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + stdin: | + for x in 1; do + : inside + done + history + + - name: "History expansion" + known_failure: true # brush doesn't implement history expansion + ignore_stderr: true # Ignore job control warnings from interactive shell without a tty + args: ["-i"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + !! + history + + - name: "history rejects an unknown option with status 2" + ignore_stderr: true + stdin: | + history -z + echo "history -z: $?" + + - name: "history --help fails with status 2" + stdin: | + history --help >/dev/null 2>&1 + echo "history --help: $?" + + - name: "history rejects an unknown long option with status 2" + ignore_stderr: true + stdin: | + history --bogus + echo "history --bogus: $?" + + - name: "history -s takes words that look like options after --" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -s -- -x y; history | tail -n 1 | sed 's/^ *[0-9]* *//' + unset HISTFILE # brush also saves the history -s line + + - name: "history -s keeps a -- after its first word" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -s a -- b; history | tail -n 1 | sed 's/^ *[0-9]* *//' + unset HISTFILE # brush also saves the history -s line + + - name: "history -s rejects an unknown option with status 2" + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -s -x + echo "history -s -x: $?" + + - name: "history -d takes an attached negative offset" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -d-1 + echo "history -d-1: $?" + + - name: "history -w takes its file name after --" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -w -- written.txt + unset HISTFILE + + - name: "history -w takes its file name after other options" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -w -c written.txt + unset HISTFILE + + - name: "history -w in a bundle takes the next word as its file name" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -wc written.txt + unset HISTFILE + + - name: "history -w rejects an attached file name" + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -wwritten.txt + echo "history -wwritten.txt: $?" + unset HISTFILE + + - name: "history rejects more than one of -anrw with status 1" + known_failure: true # brush reports the conflict as a usage error, with status 2 + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -a -w + echo "history -a -w: $?" + history -aw + echo "history -aw: $?" + unset HISTFILE + + - name: "history -s with -p stores its arguments" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -sp a b; echo "history -sp: $?"; history | tail -n 1 | sed 's/^ *[0-9]* *//' + history -ps c d; echo "history -ps: $?"; history | tail -n 1 | sed 's/^ *[0-9]* *//' + unset HISTFILE # brush also saves the history -s lines + + - name: "history -s stops reading options at its first argument" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -s a -x; history | tail -n 1 | sed 's/^ *[0-9]* *//' + unset HISTFILE # brush also saves the history -s line + + - name: "history -s rejects a negative number as an option" + known_failure: true # TODO(history): brush stores -1 as the entry + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -s -1; echo "history -s -1: $?" + unset HISTFILE + + - name: "history -d -- -1 is an error" + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -d -- -1; echo "history -d -- -1: $?" + history + unset HISTFILE + + - name: "history -d requires an offset" + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -d; echo "history -d: $?" + unset HISTFILE + + - name: "history -d rejects a non-numeric offset with status 1" + known_failure: true # TODO(history): brush reports a usage error, with status 2 + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -d abc; echo "history -d abc: $?" + unset HISTFILE + + - name: "history takes its count after --" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -- 1 + unset HISTFILE + + - name: "history rejects a negative or non-numeric count with status 2" + known_failure: true # TODO(history): brush fails with status 1 + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history -1; echo "history -1: $?" + history abc; echo "history abc: $?" + unset HISTFILE + + - name: "history rejects more than one count" + known_failure: true # TODO(history): brush uses the first and ignores the rest + args: ["-o", "history"] + ignore_stderr: true + env: + HISTFILE: "history-file.txt" + stdin: | + history 1 2; echo "history 1 2: $?" + unset HISTFILE + + - name: "history -w ignores arguments after its file name" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -w written.txt -c + history | wc -l + unset HISTFILE + + - name: "history -c with -s only clears the history" + known_failure: true # TODO(history): brush clears, then also stores the entry + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + history -c -s z + history + unset HISTFILE diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml new file mode 100644 index 0000000..84294e4 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml @@ -0,0 +1,148 @@ +name: "Builtins: job-related builtins" +cases: + - name: "Basic async job" + stdin: | + echo hi & + wait + + - name: "fg resumes a stopped job without a terminal to give it" + ignore_stderr: true + stdin: | + set -m + sh -c 'kill -STOP $$; echo resumed' + fg >/dev/null + echo "fg: $?" + + - name: "fg resumes every process of a stopped job" + ignore_stderr: true + stdin: | + set -m + sh -c '(kill -STOP 0; echo child resumed); echo parent resumed' + fg >/dev/null + echo "fg: $?" + + - name: "bg on a job already in the background says so" + ignore_stderr: true + stdin: | + set -m + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + sleep 1 & + bg %1 2>err.txt; echo "bg: $?"; strip < err.txt + wait %1 + rm err.txt + + - name: "fg without a current job reports on stderr" + ignore_stderr: true + stdin: | + fg + echo "fg: $?" + + - name: "fg and bg report a job that doesn't exist on stderr, naming the builtin first" + # The leading "file: line N:" a shell prefixes is stripped. + stdin: | + set -m + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + fg %5 2>err.txt; echo "fg: $?"; strip < err.txt + bg %5 2>err.txt; echo "bg: $?"; strip < err.txt + rm err.txt + + - name: "fg without job control says so" + known_failure: true # TODO(jobs): brush doesn't check for job control + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + fg %5 2>err.txt; echo "fg: $?"; strip < err.txt + rm err.txt + + - name: "An interactive shell without a terminal has no job control" + known_failure: true # TODO(jobs): brush turns job control on for an interactive shell even when it can't take the terminal + ignore_stderr: true # bash says why it has no job control + args: + - "-i" + - "-c" + - 'case $- in *m*) echo "job control";; *) echo "no job control";; esac' + + - name: "Job builtins fail with status 1 for a job that doesn't exist" + known_failure: true # TODO(jobs): brush returns 99 (unimplemented) + ignore_stderr: true + stdin: | + jobs %9 + echo "jobs: $?" + disown %9 + echo "disown: $?" + + - name: "Job builtins check for job control before their options" + known_failure: true # TODO(jobs): brush reads options first + ignore_stderr: true + stdin: | + fg -Z + echo "fg: $?" + bg -Z + echo "bg: $?" + disown -Z + echo "disown: $?" + + - name: "wait returns once a job stops, and the job stays stopped" + known_failure: true # TODO(jobs): wait doesn't return for a job that stops, and fg can't continue it + ignore_stderr: true + stdin: | + set -m + sh -c 'kill -STOP $$; echo resumed' & + wait %1; echo "wait: $?" + jobs -s >stopped.txt; echo "stopped: $(( $(wc -l /dev/null; echo "fg: $?" + rm stopped.txt + + - name: "fg notices a job it continued stopping again" + known_failure: true # TODO(jobs): wait doesn't return for a job that stops, and fg can't continue it + ignore_stderr: true + stdin: | + set -m + sh -c 'kill -STOP $$; kill -STOP $$; echo resumed' & + wait %1; echo "wait: $?" + fg >/dev/null; echo "fg: $?" + fg >/dev/null; echo "fg: $?" + + # The group is read from /proc: the test environments don't all have `ps`. + - name: "set -m gives a job a process group of its own" + known_failure: true # TODO(jobs): job control turned on mid-run doesn't change the process groups of what runs + incompatible_platforms: ["macos"] + stdin: | + set -m; sh -c 'read -r _ _ _ _ pgid _ < /proc/$$/stat; [ "$pgid" -eq $$ ] && echo own-group' + + - name: "wait for every job reports none of them" + known_failure: true # TODO(wait): under job control, wait prints the jobs it waited for + stdin: | + set -m + true & + wait + echo done + + - name: "wait for a job that's stopped returns at once" + known_failure: true # TODO(jobs): wait doesn't return for a job that stops, and fg can't continue it + ignore_stderr: true + stdin: | + set -m + sh -c 'kill -STOP $$; echo resumed' & + wait %1; echo "first: $?" + wait %1; echo "again: $?" + kill -9 %1 + + - name: "wait for every job leaves out the stopped ones" + known_failure: true # TODO(jobs): wait doesn't return for a job that stops, and fg can't continue it + ignore_stderr: true + stdin: | + set -m + sh -c 'kill -STOP $$; echo resumed' & + wait %1 + sleep 0.1 & + wait >/dev/null; echo "all: $?" + jobs -s >stopped.txt; echo "stopped: $(( $(wc -l /dev/null + echo "status: $?" + + - name: "kill -sigspec" + stdin: | + kill -USR1 $$ + + - name: "kill -sigspec (numeric)" + stdin: | + kill -9 $$ + + - name: "kill -0 (numeric)" + stdin: | + kill -0000 $$ + echo OK + + - name: "kill -0 (numeric, non-existent)" + ignore_stderr: true + stdin: | + kill -0 99999999 + + - name: "kill continues a stopped job sent SIGTERM, so it can act on it" + ignore_stderr: true + stdin: | + set -m + sh -c 'trap "echo terminated >status.txt; exit 3" TERM; kill -STOP $$; echo resumed' + kill -TERM %1 + for i in $(seq 100); do [ -s status.txt ] && break; sleep 0.1; done + cat status.txt + rm -f status.txt + + - name: "kill continues a job sent SIGTERM that stopped again in the background" + ignore_stderr: true + stdin: | + set -m + sh -c 'echo $$ >pid.txt; trap "echo terminated >status.txt; exit 3" TERM; kill -STOP $$; kill -STOP $$' + bg >/dev/null + for i in $(seq 100); do case $(ps -o stat= -p "$(cat pid.txt)") in T*) break ;; esac; sleep 0.1; done + kill -TERM %1 + for i in $(seq 100); do [ -s status.txt ] && break; sleep 0.1; done + cat status.txt + rm -f pid.txt status.txt + + - name: "A stopped job that survives SIGTERM runs on" + ignore_stderr: true + stdin: | + set -m + sh -c 'trap "echo caught >status.txt" TERM; kill -STOP $$; while [ ! -e done.txt ]; do sleep 0.1; done' + kill -TERM %1 + for i in $(seq 100); do [ -s status.txt ] && break; sleep 0.1; done + cat status.txt + jobs -r >running.txt; echo "running: $(( $(wc -l stopped.txt; echo "stopped: $(( $(wc -l pidfile; while :; do sleep 0.05; done' & + while [ ! -s pidfile ]; do sleep 0.05; done + kill "$(cat pidfile)" + wait + # The process ID differs from run to run. + rm pidfile + + - name: "kill takes -- before a process group, and -s 0" + known_failure: true # TODO(signals): kill's default signal and option parsing + ignore_stderr: true + stdin: | + kill -0 -- $$; echo "a=$?" + kill -s 0 $$; echo "b=$?" + kill -0 -- -$(ps -o pgid= -p $$ | tr -d ' '); echo "c=$?" + # An exited process's ID names no process or group, so nothing else is signaled. + dead=$(sh -c 'echo $$') + kill -- -$dead; echo "d=$?" + kill -s TERM -- -$dead; echo "e=$?" + + - name: "kill takes -N after a signal as a process group" + known_failure: true # TODO(kill): -N after a signal is taken for an invalid signal + ignore_stderr: true + stdin: | + # Signal 0 checks this shell's own group without signaling it. + kill -0 -$(ps -o pgid= -p $$ | tr -d ' '); echo "a=$?" + # An exited process's ID names no process or group, so nothing else is + # signaled; only the reason kill gives for failing is compared. + dead=$(sh -c 'echo $$') + kill -TERM -$dead 2>&1 | grep -c 'invalid signal'; echo "b=${PIPESTATUS[0]}" + + - name: "kill takes -N alone as a signal number" + ignore_stderr: true + stdin: | + # -999999 is an invalid signal number here, not a target. + kill -999999 2>&1 | grep -c 'invalid signal'; echo "rc=${PIPESTATUS[0]}" + + - name: "kill takes the words after its first operand as operands" + ignore_stderr: true + stdin: | + kill -0 $$ -l || true + echo "done" + + - name: "kill takes its signal in each form" + ignore_stderr: true + stdin: | + kill -l -- 9 + kill -l HUP + # An exited process's ID names no process or group, so nothing else is + # signaled; only the reason kill gives for failing is compared. + dead=$(sh -c 'echo $$') + kill -sUSR1 $dead 2>&1 | grep -c 'invalid signal'; echo "a=${PIPESTATUS[0]}" + kill -n9 $dead 2>&1 | grep -c 'invalid signal'; echo "b=${PIPESTATUS[0]}" + kill -HUP $dead 2>&1 | grep -c 'invalid signal'; echo "c=${PIPESTATUS[0]}" + + - name: "kill takes options after a -SIG shorthand" + known_failure: true # TODO(kill): a word after -SIG is taken for an operand + ignore_stderr: true + stdin: | + # Each sends signal 0 to this shell, which only checks that it exists. + kill -0 -n 0 $$; echo "a=$?" + kill -TERM -s 0 $$; echo "b=$?" + + - name: "kill takes -- after a signal" + known_failure: true # TODO(kill): `-9 --` takes `--` for a pid + ignore_stderr: true + stdin: | + # An exited process's ID names no process or group, so nothing else is + # signaled; only the reason kill gives for failing is compared. + dead=$(sh -c 'echo $$') + kill -9 -- $dead 2>&1 | grep -c 'invalid signal'; echo "a=${PIPESTATUS[0]}" + kill -s HUP -- -$dead 2>&1 | grep -c 'invalid signal'; echo "b=${PIPESTATUS[0]}" + + - name: "kill -s takes a signal number" + known_failure: true # TODO(kill): -s takes only signal names + ignore_stderr: true + stdin: | + # The shell's termination status shows which signal it was sent. + kill -s 15 $$ + echo "survived: $?" + + - name: "kill -l with several signals prints each" + known_failure: true # TODO(kill): `kill -l` with more than one signal fails as too many jobs + stdin: | + kill -l 15 9 + + - name: "kill -l maps an exit status above 128 to its signal" + known_failure: true # TODO(kill): a status above 128 is an invalid signal specification + stdin: | + kill -l 129 + + - name: "kill -l lists the signals in columns" + known_failure: true # TODO(kill): one signal a line, not bash's table + stdin: | + kill -l | head -n 1 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml new file mode 100644 index 0000000..aefafcc --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml @@ -0,0 +1,58 @@ +name: "Builtins: let" +cases: + - name: "Basic let usage" + stdin: | + let 0; echo "0 => $?" + let 1; echo "1 => $?" + + let 0==0; echo "0==0 => $?" + let 0!=0; echo "0!=0 => $?" + + let 1 0; echo "1 0 => $?" + let 0 1; echo "0 1 => $?" + + - name: "let with assignment" + stdin: | + let x=10; echo "x=10 => $?; x==${x}" + + - name: "let assignment through nameref" + known_failure: true + stdin: | + target=0 + declare -n ref=target + let ref=42 + echo "target: $target" + let ref+=8 + echo "target: $target" + let ref++ + echo "target: $target" + + - name: "let with no arguments fails with status 1" + ignore_stderr: true + stdin: | + let + echo "let: $?" + + - name: "let with only -- says an expression is expected" + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + let -- 2>err.txt; echo "let --: $?" + strip /dev/null || echo "arr not set" + + - name: "mapfile empty input clears existing" + stdin: | + arr=(x y z) + mapfile -t arr < /dev/null + declare -p arr 2>/dev/null || echo "arr not set" + + - name: "mapfile -O empty input preserves" + stdin: | + arr=(x y z) + mapfile -t -O 0 arr < /dev/null + declare -p arr + + - name: "mapfile into nameref variable" + known_failure: true + stdin: | + declare -a target + declare -n ref=target + mapfile -t ref < <(echo -e "a\nb\nc") + declare -p target + + - name: "mapfile -O into nameref variable" + known_failure: true + stdin: | + target=(x y) + declare -n ref=target + mapfile -t -O 2 ref < <(echo -e "a\nb") + declare -p target + + # Sent by another program, so that it reaches the shell through the kernel; nothing is ever + # written to the FIFO, so the read ends only if the signal ends it. + - name: "A signal that ends the shell ends a mapfile waiting for input" + known_failure: true # TODO(signals): signals aren't delivered to the shell + timeout_in_seconds: 5 + stdin: | + mkfifo p; exec 3<>p + trap 'echo exit-trap' EXIT + ( sleep 0.2; /bin/kill -TERM $$ ) & + mapfile -t -n 1 a <&3 + echo "not reached" + + # + # Option parsing + # + - name: "mapfile options given more than once keep the last" + stdin: | + mapfile -t -n 1 -n 2 arr <<< $'a\nb\nc'; declare -p arr + mapfile -t -s 1 -s 0 arr <<< $'a\nb'; declare -p arr + arr=(x y z); mapfile -t -O 2 -O 1 arr <<< $'a\nb'; declare -p arr + mapfile -t -d , -d . arr <<< 'a.b,c'; declare -p arr + + - name: "mapfile with combined and attached short options" + stdin: | + mapfile -tn2 arr <<< $'a\nb\nc'; declare -p arr + mapfile -td, arr <<< 'a,b'; declare -p arr + mapfile -t -u3 arr 3<<< $'a\nb'; declare -p arr + arr=(x y z); mapfile -t -O+1 arr <<< $'a\nb'; declare -p arr + mapfile -t -n +2 arr <<< $'a\nb\nc'; declare -p arr + mapfile -t -d - arr <<< 'a-b'; declare -p arr + + - name: "mapfile with -- before the array name" + stdin: | + mapfile -t -- arr <<< $'a\nb'; declare -p arr + + - name: "mapfile with an unknown option or missing value fails with status 2" + ignore_stderr: true + stdin: | + mapfile -Z arr <<< a; echo "rc=$?" + mapfile -t -O; echo "rc=$?" + + - name: "mapfile with an invalid count fails with status 1" + known_failure: true # TODO(mapfile): fails with status 2 + ignore_stderr: true + stdin: | + mapfile -t -n abc arr <<< a; echo "rc=$?" + mapfile -t -n -1 arr <<< a; echo "rc=$?" + mapfile -t -s -1 arr <<< a; echo "rc=$?" + mapfile -t -s abc arr <<< a; echo "rc=$?" + mapfile -t -O abc arr <<< a; echo "rc=$?" + mapfile -t -c 0 arr <<< a; echo "rc=$?" + mapfile -t -c abc arr <<< a; echo "rc=$?" + mapfile -t -u -1 arr <<< a; echo "rc=$?" + + - name: "mapfile checks every count, origin and descriptor it is given" + ignore_stderr: true + stdin: | + # Only whether it fails is compared: the status itself is a known failure. + exec 99<&- # fd 99 must be closed, but the harness can leak one in + arr=(keep); mapfile -t -s-1 -s0 arr <<< data; echo "failed=$(($? != 0)) ${arr[*]}" + arr=(keep); mapfile -t -c0 -c5000 arr <<< data; echo "failed=$(($? != 0)) ${arr[*]}" + arr=(keep); mapfile -t -n-1 -n1 arr <<< data; echo "failed=$(($? != 0)) ${arr[*]}" + arr=(keep); mapfile -t -O-1 -O0 arr <<< data; echo "failed=$(($? != 0)) ${arr[*]}" + arr=(keep); mapfile -t -u 99 -u 0 arr <<< data; echo "failed=$(($? != 0)) ${arr[*]}" + arr=(keep); mapfile -t -s0 -s1 arr <<< $'a\nb'; echo "failed=$(($? != 0)) ${arr[*]}" + + - name: "mapfile -C calls back for each group of lines" + known_failure: true # TODO(mapfile): -C and -c are not implemented + stdin: | + cb() { echo "cb $*"; } + mapfile -t -C cb -c 1 arr <<< $'a\nb'; declare -p arr + + - name: "mapfile ignores words after the array name" + known_failure: true # TODO(mapfile): an extra word is an error, and later options are parsed + ignore_stderr: true + stdin: | + mapfile -t arr extra <<< a; echo "rc=$?" + mapfile -t arr -n 1 <<< $'a\nb'; declare -p arr + + - name: "mapfile into an invalid name fails" + known_failure: true # TODO(mapfile): an invalid name is accepted + ignore_stderr: true + stdin: | + mapfile -t 1bad <<< a; echo "rc=$?" + mapfile -t -- -x <<< a; echo "rc=$?" + + - name: "mapfile option values may begin with a hyphen" + known_failure: true # TODO(mapfile): such a value is taken for an option + stdin: | + mapfile -t -d -- arr <<< 'a--b'; declare -p arr + mapfile -t -s -0 arr <<< $'a\nb'; declare -p arr diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml new file mode 100644 index 0000000..49bfc52 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml @@ -0,0 +1,535 @@ +name: "Builtins: printf" + +cases: + # Basic functionality tests + - name: "One-variable printf" + stdin: | + printf "%s" "0" + + - name: "Basic printf" + stdin: | + printf "%s, %s" "Hello" "world" + + - name: "printf -v" + stdin: | + printf -v myvar "%s, %s" "Hello" "world" + echo "myvar: '${myvar}'" + + - name: "printf -v with array index" + stdin: | + declare -a myarray=() + printf -v 'myarray[5]' "%s, %s" "Hello" "world" + declare | grep myarray + + - name: "printf with -v as a format arg" + stdin: | + printf "%s\n" "-v" + + - name: "printf -v with attached value" + stdin: | + printf -va "" + echo "a: '${a}'" + printf -vmyvar "%s, %s" "Hello" "world" + echo "myvar: '${myvar}'" + + - name: "printf -vv with attached value" + stdin: | + printf -vva "value" + echo "va: '${va}'" + + - name: "printf with no-consuming format and extra arguments" + stdin: | + printf "foo" bar baz qux + echo + printf "no specifiers\n" 1 2 3 + + - name: "printf format string starting with hyphen via --" + stdin: | + printf -- "-5\n" + printf -- "%s\n" "-v" + + - name: "printf with hyphen-prefixed format arguments" + stdin: | + printf "%s\n" -5 + printf "%d\n" -5 + printf "%s %s\n" -a -b + printf "%s\n" -v + printf "%s\n" --foo + printf "%s\n" - + printf "x%sy\n" -abc + printf "%d %d\n" -1 -2 -3 -4 + + - name: "printf with option-like format arguments and attached values" + stdin: | + printf "%s\n" "-v" "-va" "-vva" + + - name: "printf with -- among format arguments" + stdin: | + printf "%s\n" -- -x + printf "%s|%s\n" a -- b + printf -- "%s\n" -- x + + - name: "printf with hyphen-prefixed format string (invalid option)" + ignore_stderr: true + stdin: | + printf -x + echo "Result: $?" + + # Bash-specific %q format (shell-safe quoting) + - name: "printf %q" + stdin: | + echo "[1]" + printf "%q" 'TEXT'; echo + + echo "[2]" + printf "%q" '$VAR'; echo + + echo "[3]" + printf "%q" '"'; echo + + - name: "printf ~%q" + stdin: | + echo "[1]" + printf "~%q" 'TEXT'; echo + + echo "[2]" + printf "~%q" '$VAR'; echo + + echo "[3]" + printf "~%q" '"'; echo + + # ================================================================= + # STANDARD C PRINTF FORMAT SPECIFIERS + # These should work with external printf and bash builtin printf + # ================================================================= + + # Integer formatting tests + - name: "printf %d with integers" + stdin: | + printf "%d\n" 42 + printf "%d\n" -17 + printf "%d\n" 0 + + - name: "printf %o (octal)" + stdin: | + printf "%o\n" 42 + printf "%o\n" 8 + printf "%o\n" 255 + + - name: "printf %x and %X (hex)" + stdin: | + printf "%x\n" 255 + printf "%X\n" 255 + printf "%x\n" 42 + + # Floating point tests + - name: "printf %f (float)" + stdin: | + printf "%f\n" 3.14159 + printf "%.2f\n" 3.14159 + printf "%6.2f\n" 3.14159 + + - name: "printf %e and %E (scientific)" + stdin: | + printf "%e\n" 1234.5 + printf "%E\n" 1234.5 + + - name: "printf %g and %G (general)" + stdin: | + printf "%g\n" 1234.5 + printf "%G\n" 0.00012345 + + # Character formatting + - name: "printf %c (character)" + stdin: | + printf "%c\n" 65 + printf "%c\n" 97 + printf "%c%c%c\n" 72 101 121 + + # Width and precision tests + - name: "printf width formatting" + stdin: | + printf "%5s|\n" "hi" + printf "%-5s|\n" "hi" + printf "%05d|\n" 42 + + - name: "printf precision with strings" + stdin: | + printf "%.3s\n" "hello" + printf "%10.3s\n" "hello" + + # Multiple arguments and reuse + - name: "printf argument reuse" + stdin: | + printf "%s %s %s\n" "one" "two" + printf "%d %d %d\n" 1 2 + + # Special characters and escapes + - name: "printf with escape sequences" + stdin: | + printf "hello\nworld\n" + printf "tab\there\n" + printf "quote: \"\n" + + - name: "printf with backslash escapes" + stdin: | + printf "\\n\\t\\r\\\\\n" + + # Missing arguments (bash handles gracefully) + - name: "printf missing arguments" + stdin: | + printf "%s %s\n" "only_one" + + # Empty and edge cases + - name: "printf with empty format" + stdin: | + printf "" + + - name: "printf with just format string" + stdin: | + printf "just text\n" + + # Type conversion tests (safe cases only) + - name: "printf valid string to number conversion" + stdin: | + printf "%d\n" "42" + printf "%d\n" "0" + + - name: "printf with percent literal" + stdin: | + printf "100%% complete\n" + printf '%%q\n' + + - name: "printf %b (backslash escape expansion)" + known_failure: true + stdin: | + printf "%b\n" "hello\\nworld" + printf "%b\n" "tab\\there" + printf "%b\n" "quote\\\"here" + + - name: "printf %b with field width" + known_failure: true + stdin: | + printf "%10b|\n" "hello\\nworld" + printf "%-10b|\n" "tab\\there" + + - name: "printf %T (date/time formatting)" + known_failure: true # Not supported by uucore + stdin: | + printf "%(%Y-%m-%d)T\n" 1234567890 + + # Test argument reuse with bash-specific formats + - name: "printf %q argument reuse" + stdin: | + printf "%q %q %q\n" "one with spaces" "two" + + - name: "printf %b argument reuse" + stdin: | + printf "%b %b %b\n" "one\\ntwo" "three" + + # Test C-style constant interpretation + - name: "printf with quoted character arguments" + known_failure: true + stdin: | + printf "%d\n" "'A" + printf "%d\n" '"' + + - name: "printf with plus/minus signs in numeric arguments" + stdin: | + printf "%d\n" "+42" + printf "%d\n" "-17" + + # ================================================================= + # ADDITIONAL STANDARD FORMAT SPECIFIERS + # More comprehensive coverage of printf(3) man page features + # ================================================================= + + # Additional format specifiers from man page + - name: "printf %i (signed integer)" + stdin: | + printf "%i\n" 42 + printf "%i\n" -17 + + - name: "printf %u (unsigned integer)" + stdin: | + printf "%u\n" 42 + printf "%u\n" 4294967295 + + # Advanced width and precision + - name: "printf with asterisk width and precision" + stdin: | + printf "%*s|\n" 10 "hello" + printf "%.*s|\n" 3 "hello world" + printf "%*.*s|\n" 10 3 "hello world" + + - name: "printf with zero precision and zero arg" + stdin: | + printf "%.0d\n" 0 + + - name: "printf with zero precision" + stdin: | + printf "%.0d\n" 42 + + # Flag characters + - name: "printf with flag characters" + stdin: | + printf "%+d\n" 42 + printf "% d\n" 42 + printf "%#x\n" 255 + printf "%#o\n" 64 + + # Left justification + - name: "printf left justification" + stdin: | + printf "%-10s|\n" "left" + printf "%-10d|\n" 42 + + # Zero padding + - name: "printf zero padding" + stdin: | + printf "%010d\n" 42 + printf "%010.2f\n" 3.14 + + # Combining flags + - name: "printf combining flags" + stdin: | + printf "%+010d\n" 42 + printf "%-+10d|\n" 42 + + # More precision tests + - name: "printf precision with integers" + stdin: | + printf "%.5d\n" 42 + printf "%.5o\n" 42 + printf "%.5x\n" 42 + + # Large numbers and edge cases + - name: "printf with large numbers" + stdin: | + printf "%d\n" 2147483647 + printf "%u\n" 4294967295 + printf "%x\n" 4294967295 + + # More character and escape testing + - name: "printf with special characters" + stdin: | + printf "%c\n" 0 + printf "%c\n" 32 + printf "%c\n" 127 + + # Negative numbers with different formats + - name: "printf negative numbers" + stdin: | + printf "%d\n" -1 + printf "%o\n" -1 + printf "%x\n" -1 + printf "%u\n" -1 + + # Empty string handling + - name: "printf with empty strings" + stdin: | + printf "%s|\n" "" + printf "%5s|\n" "" + printf "%.5s|\n" "" + + # More empty string handling + - name: "printf with empty strings for numbers" + known_failure: true + stdin: | + printf "%d" "" + printf "%x" "" + + # Scientific notation edge cases + - name: "printf scientific notation edge cases" + stdin: | + printf "%e\n" 0.0 + printf "%E\n" 0.0 + printf "%g\n" 0.0000001 + printf "%G\n" 1000000.0 + + # Mixed format strings + - name: "printf mixed formats in one string" + stdin: | + printf "Dec: %d, Hex: %x, Oct: %o, Char: %c\n" 65 65 65 65 + + # Field width larger than content + - name: "printf wide fields" + stdin: | + printf "%20s|\n" "short" + printf "%20d|\n" 42 + + # Return value testing (should always be 0 for successful printf) + - name: "printf return value" + stdin: | + printf "test" + echo " exit: $?" + + # Error handling tests - marked as known failures due to external printf differences + - name: "printf with invalid format" + ignore_stderr: true + stdin: | + printf "%z\n" "test" + + - name: "printf with no arguments" + ignore_stderr: true + stdin: | + printf + echo "Result: $?" + + - name: "printf string to number conversion with invalid input" + known_failure: true # Need to investigate + stdin: | + printf "%d\n" "abc" + echo "Result: $?" + + - name: "printf %q with empty string" + stdin: | + printf '%q\n' '' + printf '~%q\n' '' + + - name: "printf -v through nameref" + known_failure: true + stdin: | + declare -n ref=target + printf -v ref "formatted %d" 42 + echo "target: $target" + echo "ref: $ref" + + - name: "printf %q with multiple args and mixed specs" + stdin: | + printf '%q\n' 'foo bar' + printf '%q ' 'a b' '' "x'y" 'a,b' '/tmp/x-[1]/y' $'a\nb'; echo + printf '%s=%q\n' k 'v w' m 'n$o' + printf '~%q\n' 'a b' + + - name: "printf %q shell-sensitive characters" + stdin: | + printf '%q\n' '~' 'x:~' 'x=~' '#' 'x#y' + printf '%Q\n' '~' 'x:~' 'x=~' '#' 'x#y' + + - name: "printf %Q" + stdin: | + printf '%Q\n' 'foo bar' + + - name: "printf %q with ignored modifier" + stdin: | + printf '<%2q>\n' 'foo bar' + + - name: "printf %q width and precision semantics" + known_failure: true # Requires q/Q modifier metadata from uucore + stdin: | + printf '<%12q>\n' 'foo bar' + printf '<%.5q>\n' 'foo bar' + printf '<%.5Q>\n' 'foo bar' + + - name: "printf %q dynamic and positional modifiers" + known_failure: true # Dynamic modifiers are unsupported; positional forms follow uucore + ignore_stderr: true + stdin: | + printf '<%*q>\n' 12 'foo bar' + printf '<%.*Q>\n' 5 'foo bar' + printf '<%2$q>\n' first second + + - name: "printf %q alternate form" + min_oracle_version: "5.3" # Alternate form forces single quoting starting in bash 5.3 + stdin: | + printf '<%#q>\n' 'foo bar' + printf '<%#Q>\n' 'foo bar' + + - name: "printf %q alternate form edge cases" + min_oracle_version: "5.3" # Alternate form forces single quoting starting in bash 5.3 + known_failure: true # Requires alternate-form and width metadata from uucore + stdin: | + printf '<%#q>\n' 'foo' + printf '<%#12q>\n' 'foo bar' + + - name: "printf %q escapes commas" + stdin: | + printf '%q\n' 'a,b' ',' '{a,b}' + + - name: "printf -v followed by --" + stdin: | + printf -v x -- '%s' ho + echo "x=$x" + printf -vy -- '%s-' -- a + echo "y=$y" + + - name: "printf -v given more than once keeps the last" + stdin: | + printf -v x -v y '%s' z; echo "x=[$x] y=[$y] rc=$?" + printf -vp -vq '%s' r; echo "p=[$p] q=[$q] rc=$?" + + - name: "printf -v leaves later option-like words as data" + stdin: | + printf -v x '%s,' -v y -- -x; echo "x=[$x] y=[$y]" + printf -v x -- -- a; echo "x=[$x]" + + - name: "printf -v with no format fails" + ignore_stderr: true + stdin: | + printf -v x; echo "rc=$?" + printf -v x --; echo "rc=$?" + printf -v; echo "rc=$?" + + - name: "printf -v with an invalid name fails with status 2" + ignore_stderr: true + stdin: | + printf -v 1bad '%s' x; echo "rc=$?" + printf -v -x '%s' x; echo "rc=$?" + printf -v -- '%s' x; echo "rc=$?" + printf -v-x '%s' x; echo "rc=$?" + printf -v 'a b' '%s' x; echo "rc=$?" + printf -v '' '%s' x; echo "rc=$?" + printf -v 'a[' '%s' x; echo "rc=$?" + printf -v 'a[]' '%s' x; echo "rc=$?" + declare -p a 2>/dev/null || echo "a unset" + # The name is checked before the format. + printf -v 1bad '%d' abc; echo "rc=$?" + + - name: "printf -v rejects a subscript with text after its closing bracket" + ignore_stderr: true + stdin: | + printf -v 'a[0]]' '%s' x; echo "rc=$?" + printf -v 'a[0]junk]' '%s' x; echo "rc=$?" + declare -p a 2>/dev/null || echo "a unset" + printf -v 'a[x[1]]' '%s' x; echo "nested: rc=$?" + declare -A m; printf -v 'm["]"]' '%s' x; echo "quoted: rc=$? ${m["]"]}" + + - name: "printf -v checks every name it is given" + ignore_stderr: true + stdin: | + x=old; printf -v -x -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v 1bad -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v x -v 1bad '%s' new; echo "rc=$? x=$x" + printf -v y -v x '%s' last; echo "rc=$? x=$x y=${y-unset}" + + - name: "printf -v doesn't evaluate a subscript to check the name" + ignore_stderr: true + stdin: | + x=old; printf -v 'a[\]]' -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v "a['x]']" -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v 'a[$(echo ])]' -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v 'a["x]' -v x '%s' new; echo "rc=$? x=$x" + + - name: "printf -v checks a subscript's brackets as an assignment does" + known_failure: true # TODO(parser): an assignment's subscript must balance ( and may hold an unquoted [ + ignore_stderr: true + stdin: | + x=old; printf -v 'a[(]' -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v 'a[[]' -v x '%s' new; echo "rc=$? x=$x" + + - name: "printf -v takes a[@] and a[*] as names but can't assign to them" + ignore_stderr: true + stdin: | + x=old; printf -v 'a[@]' -v x '%s' new; echo "rc=$? x=$x" + x=old; printf -v 'a[*]' -v x '%s' new; echo "rc=$? x=$x" + printf -v 'a[@]' '%s' v; echo "rc=$? ${a[*]-unset}" + printf -v 'a[*]' '%s' v; echo "rc=$? ${a[*]-unset}" + + - name: "printf --help prints help on stdout" + known_failure: true # TODO(printf): --help is taken for an invalid option + stdin: | + printf --help >/dev/null 2>&1; echo "rc=$?" + [[ -n $(printf --help 2>/dev/null) ]] && echo "help on stdout" + printf -v x --help >/dev/null 2>&1; echo "after -v: rc=$?" + printf -- --help; echo " after --: rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml new file mode 100644 index 0000000..dac3159 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml @@ -0,0 +1,15 @@ +name: "Builtins: PS4" +cases: + - name: "PS4 expansion" + stdin: | + PS4='+$FUNCNAME ' + bar() { true; } + foo() { set -x; bar; set +x; } + foo + + - name: "PS4 does not perform brace expansion" + stdin: | + IFS=: + PS4='{a,b} ' + set -x + : diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml new file mode 100644 index 0000000..4258c8f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml @@ -0,0 +1,157 @@ +name: "Builtins: pushd/popd/dirs" +incompatible_configs: ["sh"] +cases: + - name: "Basic pushd usage" + stdin: | + cd / + pushd / + echo $? + echo $PWD + pushd /usr + dirs + echo $? + echo $PWD + popd + echo $? + echo $PWD + popd + echo $? + + - name: "pushd without dir change" + stdin: | + cd / + pushd -n /usr + dirs + + - name: "popd without dir change" + stdin: | + cd / + pushd / + pushd /usr + popd -n + dirs + + - name: "popd with empty stack" + ignore_stderr: true + stdin: popd + + - name: "pushd to non-existent dir" + ignore_stderr: true + stdin: pushd /non-existent-dir + + - name: "basic dirs usage" + stdin: | + cd / + dirs + + - name: "dirs with tilde replacement" + env: + HOME: /usr + stdin: | + echo "HOME: $HOME" + + cd ~ + echo "New PWD: $PWD" + + dirs + dirs -l + + - name: "dirs to clear" + stdin: | + cd /usr + pushd /usr + pushd / + dirs -c + dirs + + - name: "Directory stack errors fail with status 1" + known_failure: true # TODO(dirs): brush treats them as usage errors, status 2 + ignore_stderr: true + stdin: | + pushd + echo "pushd: $?" + dirs +9 + echo "dirs: $?" + + - name: "pushd and popd succeed when the stack listing can't be written" + known_failure: true # TODO(dirs): a failed write of the listing fails pushd and popd + incompatible_platforms: ["macos"] # no /dev/full + ignore_stderr: true # the write error's wording is tracked separately + stdin: | + cd / + pushd /tmp >/dev/full; echo "pushd rc=$?" + popd >/dev/full; echo "popd rc=$?" + + - name: "pushd -n and popd -n may be repeated" + stdin: | + cd /usr + pushd -n / -n >/dev/null; echo "pushd: $? $PWD" + dirs -l -p | tail -n 1 + popd -n -n >/dev/null; echo "popd: $? $PWD" + dirs -p | wc -l | tr -d ' ' + + - name: "pushd with no directory swaps the top two" + ignore_stderr: true + stdin: | + cd / + pushd /usr >/dev/null + pushd >/dev/null; echo "rc=$?" + pwd + + - name: "pushd with no directory swaps only the top two" + stdin: | + cd / + pushd -n /usr >/dev/null + pushd -n /tmp >/dev/null + pushd; echo "rc=$?" + pwd + pushd; echo "rc=$?" + pwd + dirs + + - name: "pushd with no directory and an empty stack" + ignore_stderr: true + stdin: | + cd / + pushd; echo "rc=$?" + pwd + dirs + + - name: "dirs takes +N and -N" + known_failure: true # TODO(dirs): +N and -N are not implemented + ignore_stderr: true + stdin: | + cd /; pushd -n /usr >/dev/null; pushd -n /tmp >/dev/null + dirs -l +1; echo "rc=$?" + dirs -l -0; echo "rc=$?" + dirs -l +5; echo "rc=$?" + + - name: "pushd takes +N and -N" + known_failure: true # TODO(pushd): +N and -N are not implemented + ignore_stderr: true + stdin: | + cd /; pushd -n /usr >/dev/null; pushd -n /tmp >/dev/null + pushd +1 >/dev/null; echo "rc=$? $(dirs -l)" + pushd -0 >/dev/null; echo "rc=$? $(dirs -l)" + pushd +9 >/dev/null; echo "rc=$?" + + - name: "popd takes +N and -N" + known_failure: true # TODO(popd): +N and -N are not implemented + ignore_stderr: true + stdin: | + cd /; pushd -n /usr >/dev/null; pushd -n /tmp >/dev/null + popd -n +1 >/dev/null; echo "rc=$? $(dirs -l)" + pushd -n /usr >/dev/null; popd -0 >/dev/null; echo "rc=$? $(dirs -l)" + popd +9 >/dev/null; echo "rc=$?" + + # + # DIRSTACK mirrors `dirs`: element 0 is the current directory, so one pushd yields two + # elements. brush's dynamic DIRSTACK omits the current directory. + # + - name: "DIRSTACK includes the current directory" + known_failure: true # TODO: the DIRSTACK getter omits the current directory + stdin: | + cd /usr + echo "empty=${#DIRSTACK[@]}" + pushd / > /dev/null + echo "after_pushd=${#DIRSTACK[@]}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml new file mode 100644 index 0000000..53d1e76 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml @@ -0,0 +1,94 @@ +name: "Builtins: pwd" +cases: + - name: "Basic pwd usage" + stdin: | + cd / + pwd + echo "Result: $?" + # + cd usr + pwd + echo "Result: $?" + + - name: "pwd -LP" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + ( + cd ./symlink + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd ./symlink + export PWD= + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd ./symlink + export PWD= + # start a shell without $PWD + ( + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ) + + cd ~ + pwd + pwd -L + pwd -P + + - name: "pwd with moved dir" + known_failure: true # Needs investigation + stdin: | + root=$(pwd) + + mkdir -p ${root}/subdir + cd ${root}/subdir + mv ${root}/subdir ${root}/renamed + + echo "pwd -L: $(basename $(pwd -L))" + echo "pwd -P: $(basename $(pwd -P))" + + - name: "pwd with removed dir" + known_failure: true # Needs investigation + stdin: | + root=$(pwd) + + mkdir -p ${root}/subdir + cd ${root}/subdir + rmdir ${root}/subdir + + echo "pwd -L: $(basename $(pwd -L))" + echo "pwd -P: $(basename $(pwd -P))" + + - name: "pwd takes -- and both of -L and -P" + stdin: | + cd / + pwd -- + + - name: "pwd takes the last of -L and -P" + stdin: | + mkdir real; ln -s real link; cd link + basename -- "$(pwd -LP)" + basename -- "$(pwd -PL)" + + - name: "pwd ignores operands" + stdin: | + cd / + pwd foo; echo "rc=$?" + + - name: "pwd takes options only before the first operand" + stdin: | + mkdir real; ln -s real link; cd link + basename -- "$(pwd -P foo)" + basename -- "$(pwd foo -P)" + basename -- "$(pwd -- -P)" + pwd foo bar >/dev/null; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml new file mode 100644 index 0000000..5a0c5c2 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml @@ -0,0 +1,567 @@ +name: "Builtins: read" +cases: + - name: "Basic read usage from file" + test_files: + - path: "data.txt" + contents: | + a 1 + b 2 + stdin: | + while read name num; do echo "Hello, $name => $num"; done < data.txt + echo "Exited loop; name='$name', num='$num'" + + - name: "Basic read usage from file and no var name" + test_files: + - path: "data.txt" + contents: | + a 1 + b 2 + stdin: | + while read; do echo "reply: '$REPLY'"; done < data.txt + echo "Exited loop; reply='$REPLY'" + + - name: "Basic read usage with empty file" + stdin: | + while read name num; do echo "Hello, $name => $num"; done < /dev/null + declare -p name num + + while read; do echo "reply: '$REPLY'"; done < /dev/null + declare -p REPLY + + while read -a arr; do declare -p arr; done < /dev/null + declare -p arr + + - name: "Basic read usage from pipe" + stdin: | + echo "1." + (echo a; echo b) | while read name; do echo "Hello, $name"; done + + echo "2." + (echo "a b") | while read name; do echo "Hello, 1:$name REPLY:$REPLY"; done + + echo "3." + (echo "a b") | while read -a arr; do declare -p arr; done + + - name: "read with closed stdin" + ignore_stderr: true + stdin: | + echo 1 | read <&- + echo "result: $?" + + - name: "read from here string" + stdin: | + read myvar <<< "hello" + echo "myvar: ${myvar}" + + - name: "read from process substitution" + stdin: | + read myvar < <(echo hello) + echo "myvar: ${myvar}" + + - name: "read with custom IFS" + stdin: | + content=" a b c " + while IFS= read line; do + echo "LINE: '$line'" + done <<<"${content}" + + - name: "read text with tabs and custom IFS" + stdin: | + while IFS="" read myvar; do + echo "myvar1: |${myvar}|" + done < <(printf "a\tb\nc d\te\n") + + - name: "read with empty entries" + stdin: | + (echo "x"; echo ""; echo "y"; echo ""; echo "") | while read line; do echo "LINE: '$line'"; done + echo 'x,,y,z' | (IFS=',' read READ1 READ2 READ3 READ4; declare -p READ1; declare -p READ2; declare -p READ3; declare -p READ4) + + - name: "read -a with empty lines" + stdin: | + (echo "hi"; echo ""; echo "there"; echo ""; echo "you") | (read -a READ; declare -p READ) + (echo -e "hi\t\tthere\t\tyou") | (read -a READ -d $'\t'; declare -p READ) + + - name: "read -a with empty interior field" + stdin: | + echo -e -n "hi||there||you" | (IFS='|' read -a READ; declare -p READ) + echo -e -n "hi there you " | (IFS=' ' read -a READ; declare -p READ) + echo -e -n "hi there you " | (read -a READ; declare -p READ) + + - name: "read -a with empty leading field" + stdin: | + echo -e -n "|hi|there|you" | (IFS='|' read -a READ; declare -p READ) + + - name: "read -a with empty trailing field" + stdin: | + echo -e -n "hi|there|you|" | (IFS='|' read -a READ; declare -p READ) + + - name: "read -a with empty entries + empty delimiter" + stdin: | + (echo "hi"; echo ""; echo "there"; echo ""; echo "you") | (read -a READ -d ''; declare -p READ) + echo -e -n "hi\n\nthere\n\nyou\n" | (read -a READ -d ''; declare -p READ) + + - name: "read -a with empty entries + empty delimiter + custom IFS" + stdin: | + (echo 'x,,y,z'; echo 'w,v'; echo ''; echo ''; echo 'm') | (IFS=',' read -d "" -a READ; declare -p READ) + + - name: "read with empty delimiter" + min_oracle_version: 5.2 # \n renders differently in older shell versions + stdin: | + echo x | (read -d ""; declare -p REPLY) + + # Timeout tests (read -t) + - name: "read -t 0 with data available in pipe" + # TODO(read): + # This test is racy - poll(0) may check the pipe before echo's data arrives. + # Bash consistently wins this race; our implementation sometimes doesn't. + # Worth investigating. + skip: true + stdin: | + echo "test" | { read -t 0; echo "exit: $?"; } + + - name: "read -t 0 with no data (empty pipe)" + skip: true # TODO(read): see above + stdin: | + # Closed pipe with no data + : | { read -t 0; echo "exit: $?"; } + + - name: "read -t with data immediately available" + stdin: | + echo "hello world" | { read -t 1 line; echo "exit: $?, line: '$line'"; } + + - name: "read -t with fractional timeout and data available" + stdin: | + echo "test data" | { read -t 0.5 var; echo "exit: $?, var: '$var'"; } + + - name: "read -t with regular file (always ready)" + test_files: + - path: "data.txt" + contents: | + line1 + line2 + stdin: | + read -t 0 < data.txt; echo "exit: $?" + read -t 1 line < data.txt; echo "exit: $?, line: '$line'" + + - name: "read -t negative timeout is invalid" + ignore_stderr: true + stdin: | + read -t -1 + echo "exit: $?" + + - name: "read rejects an infinite or NaN timeout" + ignore_stderr: true + stdin: | + for t in inf -inf +inf Infinity nan NaN 1e400; do + echo x | { read -t "$t" v; echo "$t: rc=$? v='$v'"; } + done + + - name: "read rejects a timeout that isn't a decimal number" + ignore_stderr: true + stdin: | + for t in 0x10 1:2 1.5.2 ++1 ' 1' '1 '; do + echo x | { read -t "$t" v; echo "[$t]: rc=$? v='$v'"; } + done + + - name: "read accepts a timeout too long to represent" + incompatible_platforms: ["macos"] # bash fails with EINVAL there; brush waits + stdin: | + for t in 9223372036854775807 99999999999999999999; do + echo x | { read -t "$t" v; echo "$t: rc=$? v='$v'"; } + done + + - name: "read treats an empty timeout as zero" + known_failure: true # TODO(read): an empty timeout is rejected as invalid + ignore_stderr: true + stdin: | + for t in '' .; do + read -t "$t" v <<< x; echo "[$t]: rc=$? v='$v'" + done + + - name: "read -t with -n option" + stdin: | + echo "abcdef" | { read -t 1 -n 3 var; echo "exit: $?, var: '$var'"; } + + - name: "read -t 0 with here string" + stdin: | + read -t 0 <<< "hello"; echo "exit: $?" + read -t 1 var <<< "hello"; echo "exit: $?, var: '$var'" + + # Raw mode tests (read -r) + - name: "read -r preserves backslashes" + stdin: | + echo 'hello\nworld' | { read -r line; echo "line: '$line'"; } + echo 'path\\to\\file' | { read -r line; echo "line: '$line'"; } + + - name: "read without -r processes escape sequences" + stdin: | + echo 'hello\nworld' | { read line; echo "line: '$line'"; } + echo 'a\\b' | { read line; echo "line: '$line'"; } + + - name: "read -r with trailing backslash" + stdin: | + printf 'line with trailing\\' | { read -r line; echo "line: '$line'"; } + + - name: "read without -r line continuation" + stdin: | + # Backslash-newline continues to next line + printf 'first\\\nsecond' | { read line; echo "line: '$line'"; } + + - name: "read -r does not do line continuation" + stdin: | + printf 'first\\\nsecond' | { read -r line; echo "line: '$line'"; } + + # -N vs -n option tests + - name: "read -n respects delimiter" + stdin: | + # -n stops at delimiter even before char count + echo "ab:cd" | { read -n 5 var; echo "var: '$var'"; } + + - name: "read -N ignores delimiter" + skip: true # TODO(read): test sometimes results in printf error + stdin: | + # -N reads exactly N chars, delimiter treated as regular char + printf "ab:cd" | { read -N 4 var; echo "var: '$var'"; } + + - name: "read -N does not split by IFS" + stdin: | + # -N should not split result by IFS + printf "a b c" | { read -N 5 var; echo "var: '$var'"; } + + - name: "read -N with array" + stdin: | + # With -a and -N, entire input goes to single array element + printf "a b c" | { read -N 5 -a arr; declare -p arr; } + + # Exit code tests + - name: "read exit code on success" + stdin: | + echo "data" | { read var; echo "exit: $?"; } + + - name: "read exit code on EOF" + stdin: | + # EOF returns non-zero + read var < /dev/null; echo "exit: $?" + + - name: "read exit code on EOF with partial data" + stdin: | + # EOF with partial data still returns non-zero but assigns + printf "partial" | { read var; echo "exit: $?, var: '$var'"; } + + # IFS edge cases + - name: "read with IFS containing both whitespace and non-whitespace" + stdin: | + echo "a:b c:d" | (IFS=': ' read v1 v2 v3 v4; echo "v1='$v1' v2='$v2' v3='$v3' v4='$v4'") + + - name: "read with consecutive non-whitespace delimiters" + stdin: | + echo "a::b" | (IFS=':' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + + - name: "read assignment to fewer variables than fields" + stdin: | + echo "a b c d e" | { read v1 v2; echo "v1='$v1' v2='$v2'"; } + + - name: "read assignment to more variables than fields" + stdin: | + echo "a b" | { read v1 v2 v3 v4; echo "v1='$v1' v2='$v2' v3='$v3' v4='$v4'"; } + + - name: "read last variable preserves original delimiters" + stdin: | + # Last variable should get remainder with original delimiters, not re-joined with space + echo "x:y:z:w" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "x:y::z:w" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "a:b c:d e" | (IFS=': ' read v1 v2; echo "v1='$v1' v2='$v2'") + + - name: "read with consecutive delimiters at field boundary" + stdin: | + # When field limit causes boundary at consecutive delimiters, + # extra delimiters should be preserved in remainder. + echo "x::y" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "x:::y" | (IFS=':' read a b; echo "a='$a' b='$b'") + # Also test with more than 2 variables + echo "a::b:c" | (IFS=':' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + + - name: "read with empty IFS preserves entire line" + stdin: | + # Empty IFS means no splitting - entire line goes to first variable + echo "a b c" | (IFS='' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + echo "a:b:c" | (IFS='' read v1 v2; echo "v1='$v1' v2='$v2'") + # With array + echo "a b c" | (IFS='' read -a arr; declare -p arr) + + - name: "read REPLY preserves leading and trailing whitespace" + stdin: | + # When no variable names given, REPLY should preserve whitespace + read <<< ' hello world ' + echo "REPLY='$REPLY'" + read <<< ' tab separated ' + echo "REPLY='$REPLY'" + + - name: "read -a clears pre-existing array elements" + stdin: | + # Array should be cleared before assignment, not merged + arr=(old1 old2 old3 old4 old5) + read -a arr <<< 'new1 new2' + declare -p arr + + - name: "read -N still processes backslashes without -r" + stdin: | + # -N ignores delimiter but still does backslash processing + printf 'a\\nb' | { read -N 4 var; echo "var='$var'"; } + printf 'a\\\\b' | { read -N 4 var; echo "var='$var'"; } + + - name: "read -r -N for truly raw fixed-length reads" + stdin: | + # -r -N should preserve backslashes exactly + printf 'a\\nb' | { read -r -N 4 var; echo "var='$var'"; } + printf 'a\\\\b' | { read -r -N 4 var; echo "var='$var'"; } + + - name: "read -u with invalid file descriptor" + ignore_stderr: true + stdin: | + exec 99<&- # fd 99 must be closed, but the harness can leak one in + read -u 99 var + echo "exit: $?" + + - name: "read into readonly variable" + ignore_stderr: true + stdin: | + readonly myvar="original" + echo "test" | read myvar + echo "exit: $?, myvar='$myvar'" + + - name: "read with mixed IFS into fewer variables" + stdin: | + # Complex IFS with fewer variables - verify remainder handling + echo 'x: y : z : w' | (IFS=': ' read a b; echo "a='$a' b='$b'") + + # Backslash escape counting tests (bash counts OUTPUT chars, not INPUT) + - name: "read -n counts output chars after escape processing" + stdin: | + # Bash counts OUTPUT characters toward -n limit (after escape processing). + # Input: a, \, b, c = 4 input bytes. With -n 3: + # 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) = "abc" + printf 'a\\bc' | { read -n 3 var; echo "var='$var'"; } + # Input: \, \, a, b = 4 input bytes. With -n 3: + # '\\' → '\' (output 1), 'a' (output 2), 'b' (output 3) = "\ab" + printf '\\\\ab' | { read -n 3 var; echo "var='$var'"; } + + - name: "read -n with backslash-newline continuation" + stdin: | + # Backslash-newline is line continuation (consumes both, outputs nothing). + # Input: a, b, \, \n, c = 5 input bytes. With -n 3: + # 'a' (output 1), 'b' (output 2), '\n' → continuation, 'c' (output 3) = "abc" + printf 'ab\\\nc' | { read -n 3 var; echo "var='$var'"; } + + - name: "read trailing backslash discarded on EOF" + stdin: | + # Bash discards a pending backslash when EOF is reached + printf 'ab\\' | { read var; echo "var='$var'"; } + printf 'ab\\' | { read -n 5 var; echo "var='$var'"; } + + # Prompt tests (read -p) + - name: "read -p reads input correctly" + stdin: | + # Test that -p option reads input correctly (prompt display behavior + # differs between bash and brush when stdin is not a terminal) + read -p "Enter: " var <<< "hello" + echo "var='$var'" + + # File descriptor tests (read -u) + - name: "read -u with valid file descriptor" + stdin: | + # Read from a specific file descriptor + exec 3<<< "from fd 3" + read -u 3 var + echo "var='$var'" + exec 3<&- + + - name: "read -u combined with other options" + stdin: | + exec 4<<< "hello world test" + read -u 4 -a arr + declare -p arr + exec 4<&- + + # Silent mode tests (read -s) + - name: "read -s with pipe input" + stdin: | + # Silent mode should still read input correctly (just not echo it) + echo "secret" | { read -s var; echo "var='$var'"; } + + - name: "read -s with multiple variables" + stdin: | + echo "a b c" | { read -s v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'"; } + + - name: "read into nameref variable" + known_failure: true + stdin: | + declare -n ref=result + read ref <<< "input_value" + echo "result: $result" + echo "ref: $ref" + + - name: "read -a into nameref array" + known_failure: true + stdin: | + declare -n ref=myarr + read -a ref <<< "one two three" + echo "myarr[0]: ${myarr[0]}" + echo "myarr[1]: ${myarr[1]}" + echo "myarr[2]: ${myarr[2]}" + echo "ref[1]: ${ref[1]}" + + - name: "read into subscripted nameref" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + read ref <<< "replaced" + echo "arr[0]: ${arr[0]}" + echo "arr[1]: ${arr[1]}" + echo "arr[2]: ${arr[2]}" + + - name: "read with multi-byte UTF-8 input" + stdin: | + printf '테스트 héllo\n' | { read -r x; echo "[$x]"; } + printf '\xed\x85\x8c b\n' | { read -r p q; echo "[$p][$q]"; } + + - name: "read -d with a multi-byte delimiter" + stdin: | + printf 'ab\xc3\xa9c\n' | { read -r -d 'é' x; echo "[$x]"; } + + # An invalid byte can't round-trip through the Rust String we read into, so these + # cases match on shape (`?` is one byte to the C-locale oracle, one character to us) + # rather than echoing the byte back. + - name: "read does not swallow bytes after an invalid UTF-8 byte" + stdin: | + # The 'Z' doesn't continue the sequence 0xC3 starts; it and 'b' must survive. + printf 'a\xc3Zb\n' | { read -r x; case "$x" in a?Zb) echo "match";; *) echo "NO: $x";; esac; } + # ...and when the byte that doesn't continue it is the delimiter, the line must + # still end there. + printf '\xc3\nSECOND\n' | { read -r a; read -r b; case "$a" in ?) echo "one";; *) echo "NO";; esac; echo "[$b]"; } + + - name: "read with a truncated multi-byte sequence at EOF" + stdin: | + printf '\xe4\xb8' | { + read -r x; echo "rc=$?" + case "$x" in ??) echo "two";; *) echo "NO";; esac + read -r y; echo "rc2=$?" + } + + - name: "read -n and -N count characters, not bytes" + # brush has no locale support and always decodes input as UTF-8, so the oracle + # needs a UTF-8 locale to agree; under the suite's default LC_ALL=C bash counts + # bytes instead. + env: + LC_ALL: "C.UTF-8" + stdin: | + printf '테스트\n' | { read -r -n 2 x; echo "[$x]"; } + printf '테스트\n' | { read -r -N 2 x; echo "[$x]"; } + printf 'héllo\n' | { read -r -n 3 x; echo "[$x]"; } + # 4-byte sequences. + printf '\xf0\x9f\x98\x80\xf0\x9f\x98\x80z\n' | { read -r -n 2 x; echo "[$x]"; } + printf '\xf0\x9f\x98\x80\xf0\x9f\x98\x80z\n' | { read -r -N 2 x; echo "[$x]"; } + # The limit is checked separately on the character after a backslash escape. + printf '\\\xc3\xa9\xed\x85\x8cx\n' | { read -n 2 x; echo "[$x]"; } + + - name: "read into an invalid variable name fails" + known_failure: true # TODO(read): an invalid variable name is accepted + ignore_stderr: true + stdin: | + read 1x <<< "hello" + echo "read: $?" + + - name: "read with an invalid timeout fails with status 1" + ignore_stderr: true + stdin: | + read -t abc x <<< "hello" + echo "read: $?" + + - name: "read takes control characters from a pipe as data" + known_failure: true # TODO(signals): control characters are dropped from any input + stdin: | + printf 'a\003b\004c\n' | { read -r x; echo "rc=$? len=${#x}"; } + + - name: "read options given more than once keep the last" + stdin: | + read -n 1 -n 2 x <<< "abc"; echo "x=[$x] rc=$?" + read -d , -d . x <<< "a.b,c"; echo "x=[$x] rc=$?" + read -a a1 -a a2 <<< "x y"; declare -p a2; declare -p a1 2>/dev/null || echo "a1 unset" + read -t 5 -t 0 <<< "a"; echo "rc=$?" + + - name: "read with combined and attached short options" + stdin: | + read -rn1 x <<< 'abc'; echo "x=[$x]" + read -n2 x <<< 'abc'; echo "x=[$x]" + read -rsn2 x <<< 'abc'; echo "x=[$x]" + read -rN2 x <<< $'a\nbc'; echo "x=[$x]" + read -d, x <<< 'a,b'; echo "x=[$x]" + read -dX x <<< 'aXb'; echo "x=[$x]" + read -ra arr <<< 'a\b c'; declare -p arr + read -aarr <<< 'a b'; declare -p arr + read -u0 x <<< 'fd0'; echo "x=[$x]" + read -n +3 x <<< 'abcdef'; echo "x=[$x]" + read -t .5 x <<< 'a'; echo "x=[$x] rc=$?" + + - name: "read with -- before the names" + stdin: | + read -- x y <<< 'a b c'; echo "x=[$x] y=[$y]" + read -r -- x <<< 'a\b'; echo "x=[$x]" + + - name: "read with an unknown option or missing value fails with status 2" + ignore_stderr: true + stdin: | + read -Z x <<< 'a'; echo "rc=$?" + read -a; echo "rc=$?" + read -n; echo "rc=$?" + + - name: "read with an invalid count or descriptor fails with status 1" + known_failure: true # TODO(read): fails with status 2 + ignore_stderr: true + stdin: | + read -n abc x <<< 'a'; echo "rc=$?" + read -n -1 x <<< 'a'; echo "rc=$?" + read -N 1r x <<< 'a'; echo "rc=$?" + read -u abc x <<< 'a'; echo "rc=$?" + read -u -1 x <<< 'a'; echo "rc=$?" + + - name: "read checks every -t and -u it is given" + ignore_stderr: true + stdin: | + exec 99<&- # fd 99 must be closed, but the harness can leak one in + x=keep; read -t abc -t 1 x <<< data; echo "rc=$? x=$x" + x=keep; read -t inf -t 1 x <<< data; echo "rc=$? x=$x" + x=keep; read -t 1 -t abc x <<< data; echo "rc=$? x=$x" + x=keep; read -u 99 -u 0 x <<< data; echo "rc=$? x=$x" + x=keep; read -t 5 -t 1 x <<< data; echo "rc=$? x=$x" + + - name: "read -t with an exponent fails with status 1" + known_failure: true # TODO(read): accepts 1e1 + ignore_stderr: true + stdin: | + read -t 1e1 x <<< 'a'; echo "rc=$? x=[$x]" + + - name: "read -n 0 and -N 0 read nothing" + known_failure: true # TODO(read): reads one character + stdin: | + read -n 0 x <<< 'abc'; echo "x=[$x] rc=$?" + read -N 0 x <<< 'abc'; echo "x=[$x] rc=$?" + + - name: "read option values may begin with a hyphen" + known_failure: true # TODO(read): such a value is taken for an option + stdin: | + read -d -x x <<< 'a-xb'; echo "x=[$x] rc=$?" + read -d -- x <<< 'a-b'; echo "x=[$x] rc=$?" + read -p -x x <<< 'a'; echo "x=[$x] rc=$?" + + - name: "read takes words after the first name as names" + known_failure: true # TODO(read): a later option-like word is taken for an option + ignore_stderr: true + stdin: | + read x -r <<< 'a b'; echo "x=[$x] rc=$?" + read x -- y <<< 'a b'; echo "x=[$x] y=[$y] rc=$?" + + - name: "read -h is an invalid option and prints nothing on stdout" + stdin: | + x=$(read -h 2>/dev/null); echo "rc=$? len=${#x}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml new file mode 100644 index 0000000..346744f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml @@ -0,0 +1,90 @@ +name: "Builtins: readonly" +cases: + - name: "making var readonly" + stdin: | + my_var="value" + readonly my_var + + echo "Invoking declare -p..." + declare -p my_var + + - name: "using readonly with value" + stdin: | + readonly my_var="my_value" + + echo "Invoking declare -p..." + declare -p my_var + + - name: "readonly array after append" + stdin: | + ARR=() + f() { + ARR+=("a" "b") + readonly ARR + echo "${#ARR[@]}" + } + f + + - name: "Multiple readonly operands expand before assignments are applied" + stdin: | + unset first second + readonly first=one second=$first + declare -p first second + + - name: "readonly on nameref makes nameref readonly" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + declare -n ref=target + readonly ref + declare -p ref + declare -p target + # Changing what ref points to should fail + declare -n ref=other 2>/dev/null + echo "exit: $?" + # But target should still be writable + target="new_value" + echo "target: $target" + + - name: "readonly target through nameref assignment" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + readonly target + declare -n ref=target + ref="new_value" 2>/dev/null + echo "exit: $?" + echo "target: $target" + + - name: "a failed readonly assignment inside a pipeline reports its status" + known_failure: true # TODO: a readonly assignment failing as a pipeline element reports status 0 + stdin: | + readonly r=1 + r=2 | cat + echo "pipeline: ${PIPESTATUS[0]}" + + - name: "readonly -a with an assignment converts and types the variable" + stdin: | + e5=x + readonly -a e5=y + echo "value_status=$?" + declare -p e5 + readonly -A na=([k]=v) + echo "assoc_status=$?" + declare -p na + + - name: "readonly -f marks a function readonly rather than displaying it" + # brush routes `readonly -f` through the same display path as `declare -f`, so it prints the + # function's definition and never makes it readonly. A shell prints nothing, rejects a later + # redefinition, and reports a missing name as "readonly: NAME: not a function". + known_failure: true + ignore_stderr: true + stdin: | + g() { echo original; } + readonly -f g + echo "status: $?" + g() { echo redefined; } + g + readonly -f nosuchfn diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml new file mode 100644 index 0000000..dd29f59 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml @@ -0,0 +1,181 @@ +name: "Builtins: return" +cases: + - name: "Return outside function or script" + ignore_stderr: true + stdin: | + return 42 + + - name: "Return outside function with stderr to /dev/full" + ignore_stderr: true + stdin: | + # /dev/full may not be available on all platforms. + [[ -e /dev/full ]] || exit 0 + return 2>/dev/full + echo "exit: $?" + + - name: "Return in directly invoked script" + ignore_stderr: true + test_files: + - path: "script.sh" + contents: | + return 42 + echo "Got past return" + args: ["./script.sh"] + + - name: "Return from sourced script" + test_files: + - path: "script.sh" + contents: | + return 42 + echo "Got past return" + stdin: | + source script.sh + + - name: "Return from subshell" + ignore_stderr: true + stdin: | + (return) + echo "Got past subshell" + exit 42 + + - name: "Return from nested sourced script" + test_files: + - path: "inner.sh" + contents: | + return 42 + echo "Got past inner return" + - path: "outer.sh" + contents: | + source inner.sh + echo "Got to end of outer script" + stdin: | + source outer.sh + + - name: "Return in function" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()" + return 5 + echo "Should not get here" + } + echo "Calling myfunc()..." + myfunc + echo "Returned: $?" + args: ["./script.sh"] + + - name: "Return in for loop in function" + stdin: | + myfunc() { + for i in 1 2 3; do + echo "In myfunc: $i" + return 5 + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in arithmetic for loop in function" + stdin: | + myfunc() { + for ((i=0; i < 5; i++)); do + echo "In myfunc: $i" + return 5 + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in while loop in function" + stdin: | + myfunc() { + i=0 + while [[ $i -lt 5 ]]; do + echo "In myfunc: $i" + return 33 + i=$((i+1)) + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in case" + stdin: | + myfunc() { + case 1 in + 1) + echo "In case" + return 5 + echo "Should not get here" + ;; + esac + } + + myfunc + echo "Returned: $?" + + - name: "Return in brace group" + stdin: | + myfunc() { + { + echo "In brace group" + return 5 + echo "Should not get here" + } + } + + myfunc + echo "Returned: $?" + + - name: "Return in and/or" + stdin: | + myfunc() { + echo "In and/or" && return 5 && echo "Should not get here" + } + + myfunc + echo "Returned: $?" + + - name: "Return from nested clauses" + stdin: | + myfunc() { + while (($#)); do + case $1 in + *) + shift 2 || { + echo "Returning" + return 5 + } + echo "Shifted and fell through" + esac + done + } + + myfunc a b c + + - name: "return with a non-numeric argument returns 2" + known_failure: true # TODO(return): a non-numeric argument is a usage error, and the function doesn't return + ignore_stderr: true + stdin: | + f() { return abc; echo "still in f"; } + f + echo "f returned $?" + + - name: "return with too many arguments abandons the rest of the line" + known_failure: true # TODO(builtins): a usage error skips return, so the function keeps running + ignore_stderr: true + stdin: | + f() { return 1 2; echo "still in f"; } + f; echo "same line: $?" + echo "next line: $?" + + - name: "return takes -- and a negative status" + stdin: | + f() { return -1; }; f; echo "a=$?" + f() { return -256; }; f; echo "b=$?" + f() { return -0; }; false; f; echo "c=$?" + f() { return -- 6; }; f; echo "d=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml new file mode 100644 index 0000000..4f3e23b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml @@ -0,0 +1,382 @@ +name: "Builtins: set" +cases: + - name: "set with no args" + stdin: | + MYVARIABLE=VALUE + set > set-output.txt + grep MYVARIABLE set-output.txt + + # Remove set-output.txt to avoid it being byte-for-byte compared. + rm set-output.txt + + - name: "Basic set usage" + stdin: | + set a b c d + echo ${*} + + - name: "set with options" + stdin: | + function dumpopts { + # Dump the options + echo "[Options: $1]" + echo "set options: " $- + shopt -p -o pipefail + } + + set -e -u -o pipefail + dumpopts enabled + echo '*: ' $* + + set +e +u +o pipefail + dumpopts disabled + echo '*: ' $* + + - name: "set with multiple combined options" + stdin: | + function dumpopts { + # Dump the options + echo "[Options: $1]" + echo "set options: " $- + shopt -p -o pipefail + } + + set -euo pipefail + dumpopts enabled + echo '$*: ' $* + + set +euo pipefail + dumpopts disabled + echo '$*: ' $* + + - name: "set clearing args" + stdin: | + set a b c + echo ${*} + set a + echo ${*} + + - name: "set with -" + stdin: | + set - a b c + echo "args: " ${*} + set - + echo "args: " ${*} + + - name: "set with --" + stdin: | + set -- a b c + echo "args: " ${*} + set -- + echo "args: " ${*} + + - name: "set with option-looking args" + stdin: | + set -- a -v + echo ${*} + + set - a -v + echo ${*} + + set a -v + echo ${*} + + set -- a +x + echo ${*} + + set - a +x + echo ${*} + + set a +x + echo ${*} + + - name: "function-local set" + known_failure: true + stdin: | + function f() { + local - + set -x + echo "In func" + } + + echo "Before func" + f + echo "After func" + + - name: "set output quotes values" + stdin: | + q1="a'b"; q2="a''b"; q3="'"; q4="''"; q5="'a"; q6="a'" + set > set-output.txt + grep -E '^q[0-9]=' set-output.txt + + # Remove set-output.txt to avoid it being byte-for-byte compared. + rm set-output.txt + + - name: "set output leaves an empty value unquoted" + known_failure: true # TODO(escape): brush renders this as q1='' instead of q1= + stdin: | + q1="" + set > set-output.txt + grep -E '^q[0-9]=' set-output.txt + + # Remove set-output.txt to avoid it being byte-for-byte compared. + rm set-output.txt + + - name: "set quotes values starting with # or ~" + stdin: | + x="~" y="#z" z="a=~" w="a~" + set | grep -E "^(w|x|y|z)=" + + - name: "set leaves commas unquoted, like bash" + stdin: | + x="a,b" y="{a,b}" z="," + set | grep -E "^(x|y|z)=" + + - name: "set and declare -p leave commas in associative array keys unquoted, like bash" + stdin: | + declare -A m=([a,b]=1 [c]=2) + set | grep "^m=" + declare -p m + + - name: "set applies -o and +o in the order given" + stdin: | + set -o noglob +o noglob; echo "1 $-" + set +o noglob -o noglob; echo "2 $-"; set +f + set -o noglob -o noclobber +o noglob; echo "3 $-"; set +fC + + - name: "set applies letters and -o names in the order given" + stdin: | + set -f +o noglob; echo "1 $-" + set -o noglob +f; echo "2 $-"; set +f + set +o noglob -f; echo "3 $-"; set +f + set -eo pipefail +e; echo "4 $-"; shopt -po pipefail; set +o pipefail + + - name: "set -o alone after a name lists the options it just set" + stdin: | + set -o noglob -o | grep noglob + set +o noglob +o | grep noglob + + - name: "set stops at an unknown option name" + ignore_stderr: true + stdin: | + set -- q + set -o nosuch -f a b; echo "$? $- [$*]" + + - name: "set - turns off -x and -v" + ignore_stderr: true + stdin: | + set -x -v; set -; echo "1 $-" + set -x; set -e -; echo "2 $-"; set +e + set -x -v; set -- a; set +xv; echo "3 $- [$*]" + + - name: "set -o with an unknown option name" + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + set -o bogus 2>&1 | strip + echo "set -o: ${PIPESTATUS[0]}" + set +o bogus 2>&1 | strip + echo "set +o: ${PIPESTATUS[0]}" + + - name: "set takes the letters after -o as more options" + stdin: | + set -oe | tail -1 + set -oe >/dev/null; echo "$-" + set +oe | tail -1 + set +oe >/dev/null; echo "$-" + + - name: "set rejects an option it doesn't have" + ignore_stderr: true + stdin: | + set -Z; echo "set -Z: $?" + set +Z; echo "set +Z: $?" + + - name: "set accepts -r and +r" + stdin: | + set +r; echo "+r: $?" + set -r; echo "-r: $?" + + - name: "set -r turns on restricted mode" + known_failure: true # TODO(set): brush accepts -r but doesn't restrict the shell or show r in $- + stdin: | + set -r; echo "status: $?" + [[ $- == *r* ]] && echo "restricted" + cd / 2>/dev/null; echo "cd: $?" + + - name: "set skips a lone +" + known_failure: true # TODO(set): a lone + is taken as the first operand; needs winnow-args to skip it among the options + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set a b; set +; echo "$# $*" + set + | wc -l + set + x y; echo "$# $*" + set + -e; opts; echo "$# $*"; set +e + set -e + z; opts; echo "$# $*"; set +e + set + + -u; opts; set +u + set + -- w; echo "$# $*" + set -- +; echo "$# $*" + set - +; echo "$# $*" + set v + -e; opts; echo "$# $*" + + - name: "set rejects an unknown letter after a lone + with status 1" + known_failure: true # TODO(set): brush fails with status 2 + ignore_stderr: true + stdin: | + set + -Z; echo "status: $?" + + - name: "set applies the options before a lone + when a later one is unknown" + known_failure: true # TODO(set): a lone + is taken as the first operand + ignore_stderr: true + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -f + -Z; echo "status: $?"; opts; set +f + set -f -u + -Z; echo "status: $?"; opts; set +fu + set a b; set -f + -Z c; echo "status: $?"; opts; echo "$# $*"; set +f + # Without a lone +, an unknown letter stops set before any option applies. + set -f -Z; echo "status: $?"; opts + + - name: "set applies the options after a lone + up to an unknown one" + known_failure: true # TODO(set): a lone + is taken as the first operand + ignore_stderr: true + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set + -u -Z; echo "status: $?"; opts; set +u + set + -uZ; echo "status: $?"; opts; set +u + set + -Zu; echo "status: $?"; opts + + - name: "set -o and +o list the options as they stand at that point" + stdin: | + set -eo | grep '^errexit' + set -oe | grep '^errexit' + set -o errexit -o | grep '^errexit' + set -o -o errexit | grep '^errexit' + set +o -e | grep ' errexit$' + set -e +o | grep ' errexit$' + set -u -o -u | grep '^nounset' + set -o '' | grep '^errexit' + set +o '' | grep ' errexit$' + set +o -o | grep -E '^(set [-+]o errexit|errexit)' + + - name: "set -o takes an empty word as its first operand" + known_failure: true # TODO(set): brush takes the empty word as -o's name and applies the options after it + stdin: | + set a; set -o '' -e >/dev/null; echo "$- $# [$*]" + set -o '' x >/dev/null; echo "$# [$*]" + + - name: "set applies -x and +x in command-line order" + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -e +e; opts + set +e -e; opts; set +e + set -u +eu -e; opts; set +e + set -eux +eux; opts + set -e -e +e +e -e; opts; set +e + set -B +B; opts; set +B -B; opts + set -C +C -C; opts; set +C + : > zzglob; set -f +f; echo zzg*; set +f -f; echo zzg*; set +f + set -e +u; opts; set +e + + - name: "set applies -o and +o in order with the letter options" + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -o errexit +e; opts + set +e -o errexit; opts; set +e + # shopt -po fails for an option that is off; || keeps errexit from ending the case. + set -eo pipefail +o pipefail; opts; shopt -po pipefail || :; set +e + set +o pipefail -o pipefail +o pipefail; shopt -po pipefail + set -o pipefail +o pipefail -o pipefail; shopt -po pipefail; set +o pipefail + set -o nounset -o errexit +o nounset; opts; set +e + set +o errexit +o nounset -eu; opts; set +eu + set -a -o allexport +a; opts + set -uo nounset +u; opts + set -x +o xtrace; opts + + - name: "set -o or +o with no name, then more options or operands" + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -o -e >/dev/null; opts; set +e + set +o -u >/dev/null; opts; set +u + set -oe >/dev/null; opts; set +e + set -o -- a b >/dev/null; echo "$# $*" + set -o >/dev/null -- c; echo "$# $*" + + - name: "set stops at an unknown option name among other options and operands" + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -o nosuch -u 2>/dev/null; echo "status: $?"; opts + set -u -o nosuch 2>/dev/null; echo "status: $?"; opts; set +u + # set fails here with errexit on; || keeps it from ending the case. + set -o errexit +o nosuch -o nounset 2>/dev/null || echo "status: $?"; opts; set +e + set a b; set -o nosuch c d 2>/dev/null; echo "$# $*" + set -o nosuch -- e f 2>/dev/null; echo "$# $*" + set -o nosuch -o 2>/dev/null; echo "listed nothing" + + - name: "set rejects an unknown letter before applying any option" + ignore_stderr: true + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -e -Z; echo "status: $?"; opts + set -eZ; echo "status: $?"; opts + set -Ze -u; echo "status: $?"; opts + set -o errexit -Z; echo "status: $?"; opts + set -o -Z | head -1; opts + set -ex+u; echo "status: $?"; opts + set a b; set -Z c; echo "$# $*" + + - name: "set - turns off -x and -v and ends the options" + stdin: | + opts() { local o r=; for o in v x; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + { set -x; set -; } 2>/dev/null; opts + { set -xv; set - a; } 2>/dev/null; opts; echo "$# $*" + { set -x -e - b; } 2>/dev/null; opts; echo "$# $*"; set +e + { set -x - -e; } 2>/dev/null; opts; echo "$# $*" + { set -x - -; } 2>/dev/null; opts; echo "$# $*" + + - name: "set takes options only before its operands" + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -- -x; opts; echo "$# $*" + set x -e; opts; echo "$# $*" + set -e a -u; opts; echo "$# $*"; set +e + set -e -- -a; opts; echo "$# $*"; set +e + set -o errexit x -o nounset; opts; echo "$# $*"; set +e + set +o errexit nosuch; echo "status: $?"; echo "$# $*" + set - -Z; echo "$# $*" + set -- -- a; echo "$# $*" + set - -- a; echo "$# $*" + set -- - a; echo "$# $*" + set x -- y; echo "$# $*" + set x - y; echo "$# $*" + set ''; echo "$#" + set -e ''; opts; echo "$#"; set +e + + - name: "set leaves the positional parameters alone without operands" + stdin: | + set a b c + set -e; echo "$#"; set +e + set -o errexit; echo "$#"; set +e + set -o >/dev/null; echo "$#" + set +o >/dev/null; echo "$#" + set -; echo "$#" + set -e -; echo "$#"; set +e + set --; echo "$#" + + - name: "set -o in the middle of a bundle takes the next word" + known_failure: true # TODO(set): brush lists the options and takes the next word as an operand + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -ou nounset; echo "status: $?"; opts; echo "$#"; set +u + set -oe nounset; echo "status: $?"; opts; echo "$#"; set +eu + + - name: "set -o takes no name that starts with + or is a lone - or +" + known_failure: true # TODO(set): brush takes the word as the option name and fails + stdin: | + opts() { local o r=; for o in a e f u x B C; do case $- in *"$o"*) r+=$o ;; esac; done; echo "[$r]"; } + set -e; set -o +e >/dev/null; echo "status: $?"; opts + set -o - >/dev/null; echo "status: $?" + set -o + a >/dev/null; echo "status: $?"; echo "$# $*" + set +o +o errexit >/dev/null; echo "status: $?"; opts; set +e + + - name: "set -o vi and -o emacs turn each other off" + known_failure: true # TODO(set): brush leaves both on + stdin: | + set -o vi -o emacs; set -o | grep -E '^(vi|emacs) ' + set -o emacs -o vi; set -o | grep -E '^(vi|emacs) ' diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/shift.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/shift.yaml new file mode 100644 index 0000000..cb0877d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/shift.yaml @@ -0,0 +1,40 @@ +name: "Builtins: shift" +cases: + - name: "shift past the end fails with status 1" + known_failure: true # TODO(shift): fails with status 2, where bash fails with 1 + ignore_stderr: true + stdin: | + set -- a + shift 2 + echo "shift: $?" + + - name: "A non-numeric shift count is misuse, in bash's words" + known_failure: true # TODO(shift): the error is clap's, not bash's `x: numeric argument required` + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + set -- a b + shift x 2>err.txt + echo "shift x: $?" + strip err.txt; echo "shift -1: $? $#" + [[ -s err.txt ]] && echo "reported" + shift -0; echo "shift -0: $? $#" + rm err.txt + + - name: "shift takes --" + stdin: | + set -- a b c + shift -- 2; echo "$? $*" + + - name: "shift with too many arguments abandons the rest of the line" + known_failure: true # TODO(shift): a usage error with status 2, and the line goes on + ignore_stderr: true + stdin: | + set -- a b + shift 1 2; echo "same line: $?" + echo "next line: $? $#" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml new file mode 100644 index 0000000..bd7b085 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml @@ -0,0 +1,91 @@ +name: "Builtins: shopt" +cases: + - name: "shopt defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + incompatible_os: ["fedora", "azurelinux"] # Fedora enables "syslog-history" option + stdin: | + shopt | sort | grep -v extglob + + - name: "shopt interactive defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + incompatible_os: ["fedora", "azurelinux"] # Fedora enables "syslog-history" option + pty: true + args: ["-i", "-c", "shopt | sort | grep -v extglob"] + + - name: "shopt -o defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + stdin: | + shopt -o | sort + + - name: "shopt -o interactive defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + pty: true + args: ["-i", "-c", "shopt -o | sort | grep -v monitor"] + + - name: "extglob defaults" + known_failure: true # TODO(patterns): we force this setting on in our shell + stdin: | + shopt extglob + + - name: "extglob interactive defaults" + pty: true + args: ["-i", "-c", "shopt extglob"] + known_failure: true + + - name: "shopt -o interactive monitor default" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + pty: true + args: ["-i", "-c", "shopt -o monitor"] + + - name: "shopt toggle" + ignore_whitespace: true # Spacing changes across versions + stdin: | + echo "Setting checkwinsize" + shopt -s checkwinsize + + echo "Displaying checkwinsize" + shopt checkwinsize + shopt -p checkwinsize + + echo "Unsetting checkwinsize" + shopt -u checkwinsize + + echo "Displaying checkwinsize" + shopt checkwinsize + shopt -p checkwinsize + + - name: "shopt -o usage" + ignore_whitespace: true # Spacing changes across versions + stdin: | + echo "Setting emacs" + shopt -o -s emacs + + echo "Displaying emacs" + shopt -o emacs + shopt -o -p emacs + + echo "Unsetting emacs" + shopt -o -u emacs + + echo "Displaying emacs" + shopt -o emacs + shopt -o -p emacs + + - name: "shopt refuses to set and unset at once" + known_failure: true # TODO(shopt): fails with status 2, where bash fails with 1 + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + shopt -s -u extglob 2>&1 | strip + echo "shopt: ${PIPESTATUS[0]}" + + - name: "shopt takes -- and -o with -s, -u, -p and -q" + stdin: | + shopt -s -- nullglob; shopt -p nullglob + shopt -so errexit; [[ $- == *e* ]] && echo "errexit on" + shopt -po errexit + shopt -uo errexit; shopt -qo errexit; echo "q=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/suspend.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/suspend.yaml new file mode 100644 index 0000000..35ab169 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/suspend.yaml @@ -0,0 +1,17 @@ +name: "Builtins: suspend" +cases: + - name: "suspend without job control fails" + known_failure: true # TODO(suspend): brush stops itself + # brush stops until the timeout kills it. + timeout_in_seconds: 2 + ignore_stderr: true + stdin: | + suspend + echo "suspend: $?" + + - name: "suspend with an operand abandons the rest of the line" + known_failure: true # TODO(suspend): a usage error with status 2, and the line goes on + ignore_stderr: true + stdin: | + suspend foo; echo "same line: $?" + echo "next line: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml new file mode 100644 index 0000000..775e2e0 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml @@ -0,0 +1,390 @@ +name: "Builtins: test" +cases: + - name: "test: = operator" + stdin: | + shopt -u nocasematch + test "ab" = "ab" && echo "ab = ab" + test "ab" = "AB" && echo "ab = AB" + test "ab" = "cd" && echo "ab = cd" + test "ab" = "a?" && echo "ab = a?" + + shopt -s nocasematch + test "ab" = "ab" && echo "ab = ab" + test "ab" = "AB" && echo "ab = AB" + test "ab" = "cd" && echo "ab = cd" + test "ab" = "a?" && echo "ab = a?" + + - name: "test: == operator" + stdin: | + shopt -u nocasematch + test "ab" == "ab" && echo "ab == ab" + test "ab" == "AB" && echo "ab == AB" + test "ab" == "cd" && echo "ab == cd" + test "ab" == "a?" && echo "ab == a?" + + shopt -s nocasematch + test "ab" == "ab" && echo "ab == ab" + test "ab" == "AB" && echo "ab == AB" + test "ab" == "cd" && echo "ab == cd" + test "ab" == "a?" && echo "ab == a?" + + - name: "test: files refer to same device and inode" + stdin: | + [ /bin/sh -ef /bin/sh ] && echo "-ef correctly identified device and inode numbers" + + [ ! /etc/os-release -ef /bin/sh ] && echo "-ef correctly identified device and inode numbers that do not match" + + - name: "test: file is newer" + stdin: | + touch -d "2 hours ago" bar + touch foo + + [ foo -nt bar ] && echo "-nt correctly identified newer file" + [ foo -nt foo ] && echo "-nt incorrectly identified file as newer than itself" + [ foo -nt file_no_exists ] && echo "-nt correctly identified when file2 does not exist" + + - name: "test: file is older" + stdin: | + touch -d "2 hours ago" foo + touch bar + + [ foo -ot bar ] && echo "-ot correctly identified older file" + [ foo -ot foo ] && echo "-ot incorrectly identified file as older than itself" + [ file_no_exists -ot foo ] && echo "-ot correctly identified when file1 does not exist" + + - name: "test: parens" + stdin: | + test \( "a" = "a" \) && echo "Parens work" + + - name: "test: -- string" + stdin: | + test -- + echo "Result(test): $?" + + [ -- ] + echo "Result([): $?" + + - name: "test: invalid operators" + ignore_stderr: true + stdin: | + test - - + echo "Result: $?" + + # String operators + - name: "test: != operator" + stdin: | + test "ab" != "cd" && echo "ab != cd" + test "ab" != "ab" && echo "ab != ab" + test "" != "x" && echo "empty != x" + + - name: "test: -z operator (zero length)" + stdin: | + test -z "" && echo "-z empty" + test -z "x" && echo "-z x" + unset VAR + test -z "$VAR" && echo "-z unset" + + - name: "test: -n operator (non-zero length)" + stdin: | + test -n "x" && echo "-n x" + test -n "" && echo "-n empty" + VAR="hello" + test -n "$VAR" && echo "-n set" + + - name: "test: < operator (string sort before)" + stdin: | + test "a" \< "b" && echo "a < b" + test "b" \< "a" && echo "b < a" + test "abc" \< "abd" && echo "abc < abd" + test "a" \< "a" && echo "a < a" + + - name: "test: > operator (string sort after)" + stdin: | + test "b" \> "a" && echo "b > a" + test "a" \> "b" && echo "a > b" + test "abd" \> "abc" && echo "abd > abc" + test "a" \> "a" && echo "a > a" + + # Arithmetic comparison operators + - name: "test: -eq operator" + stdin: | + test 5 -eq 5 && echo "5 -eq 5" + test 5 -eq 6 && echo "5 -eq 6" + test -1 -eq -1 && echo "-1 -eq -1" + test 0 -eq 0 && echo "0 -eq 0" + + - name: "test: -ne operator" + stdin: | + test 5 -ne 6 && echo "5 -ne 6" + test 5 -ne 5 && echo "5 -ne 5" + test -1 -ne 1 && echo "-1 -ne 1" + + - name: "test: -lt operator" + stdin: | + test 3 -lt 5 && echo "3 -lt 5" + test 5 -lt 3 && echo "5 -lt 3" + test 5 -lt 5 && echo "5 -lt 5" + test -2 -lt -1 && echo "-2 -lt -1" + + - name: "test: -le operator" + stdin: | + test 3 -le 5 && echo "3 -le 5" + test 5 -le 5 && echo "5 -le 5" + test 6 -le 5 && echo "6 -le 5" + test -2 -le -2 && echo "-2 -le -2" + + - name: "test: -gt operator" + stdin: | + test 5 -gt 3 && echo "5 -gt 3" + test 3 -gt 5 && echo "3 -gt 5" + test 5 -gt 5 && echo "5 -gt 5" + test -1 -gt -2 && echo "-1 -gt -2" + + - name: "test: -ge operator" + stdin: | + test 5 -ge 3 && echo "5 -ge 3" + test 5 -ge 5 && echo "5 -ge 5" + test 3 -ge 5 && echo "3 -ge 5" + test -1 -ge -1 && echo "-1 -ge -1" + + # File existence and type operators + - name: "test: -e operator (file exists)" + stdin: | + touch testfile + test -e testfile && echo "-e existing file" + test -e nonexistent && echo "-e nonexistent" + mkdir testdir + test -e testdir && echo "-e existing dir" + + - name: "test: -a operator (file exists, deprecated)" + stdin: | + touch testfile + test -a testfile && echo "-a existing file" + test -a nonexistent && echo "-a nonexistent" + + - name: "test: -f operator (regular file)" + stdin: | + touch testfile + test -f testfile && echo "-f regular file" + mkdir testdir + test -f testdir && echo "-f directory" + test -f nonexistent && echo "-f nonexistent" + + - name: "test: -d operator (directory)" + stdin: | + mkdir testdir + test -d testdir && echo "-d directory" + touch testfile + test -d testfile && echo "-d regular file" + test -d nonexistent && echo "-d nonexistent" + + - name: "test: -h and -L operators (symbolic link)" + stdin: | + touch target + ln -s target symlink + test -h symlink && echo "-h symlink" + test -L symlink && echo "-L symlink" + test -h target && echo "-h regular file" + test -L target && echo "-L regular file" + test -h nonexistent && echo "-h nonexistent" + + - name: "test: -p operator (named pipe/FIFO)" + stdin: | + touch testfile + test -p testfile && echo "-p regular file" + test -p nonexistent && echo "-p nonexistent" + + - name: "test: -S operator (socket)" + stdin: | + touch testfile + test -S testfile && echo "-S regular file" + test -S nonexistent && echo "-S nonexistent" + + - name: "test: -b operator (block device)" + stdin: | + touch testfile + test -b testfile && echo "-b regular file" + test -b nonexistent && echo "-b nonexistent" + # Block devices exist in /dev but may vary by system + + - name: "test: -c operator (character device)" + stdin: | + touch testfile + test -c testfile && echo "-c regular file" + test -c nonexistent && echo "-c nonexistent" + + # File permission operators + - name: "test: -r operator (readable)" + stdin: | + touch testfile + test -r testfile && echo "-r readable" + test -r nonexistent && echo "-r nonexistent" + + - name: "test: -w operator (writable)" + stdin: | + touch testfile + test -w testfile && echo "-w writable" + test -w nonexistent && echo "-w nonexistent" + + - name: "test: -x operator (executable)" + stdin: | + touch testfile + test -x testfile && echo "-x executable" + + - name: "test: -s operator (size greater than zero)" + stdin: | + echo "content" > nonempty + test -s nonempty && echo "-s nonempty" + touch empty + test -s empty && echo "-s empty" + test -s nonexistent && echo "-s nonexistent" + + # Variable and option operators + - name: "test: -v operator (variable is set)" + stdin: | + VAR="hello" + test -v VAR && echo "-v set variable" + unset VAR + test -v VAR && echo "-v unset variable" + VAR="" + test -v VAR && echo "-v empty variable" + + - name: "test: -R operator (nameref variable)" + stdin: | + VAR="value" + declare -n REF=VAR + test -R REF && echo "-R nameref" + test -R VAR && echo "-R regular var" + unset REF VAR + + - name: "test: -o operator (shell option)" + stdin: | + set -e + test -o errexit && echo "-o errexit enabled" + set +e + test -o errexit && echo "-o errexit disabled" + test -o nonexistentoption && echo "-o nonexistent" + + # Logical operators + - name: "test: ! operator (negation)" + stdin: | + test ! "a" = "b" && echo "! a=b" + test ! "a" = "a" && echo "! a=a" + test ! -e nonexistent && echo "! -e nonexistent" + test ! -e / && echo "! -e /" + + - name: "test: -a operator (logical AND)" + stdin: | + test "a" = "a" -a "b" = "b" && echo "true -a true" + test "a" = "a" -a "b" = "c" && echo "true -a false" + test "a" = "b" -a "c" = "c" && echo "false -a true" + test "a" = "b" -a "c" = "d" && echo "false -a false" + + - name: "test: -o operator (logical OR)" + stdin: | + test "a" = "a" -o "b" = "b" && echo "true -o true" + test "a" = "a" -o "b" = "c" && echo "true -o false" + test "a" = "b" -o "c" = "c" && echo "false -o true" + test "a" = "b" -o "c" = "d" && echo "false -o false" + + - name: "test: complex logical expressions" + stdin: | + test \( "a" = "a" -o "b" = "c" \) -a "d" = "d" && echo "complex 1" + test ! \( "a" = "b" \) && echo "complex 2" + test "a" = "a" -a \( "b" = "b" -o "c" = "d" \) && echo "complex 3" + + # Edge cases + - name: "test: single argument (non-empty string is true)" + stdin: | + test "hello" && echo "non-empty is true" + test "" && echo "empty is true" + test "0" && echo "zero string is true" + + - name: "test: no arguments" + stdin: | + test + echo "no args: $?" + + - name: "test: special characters in strings" + stdin: | + test "hello world" = "hello world" && echo "spaces match" + test "a b" = "a b" && echo "tabs match" + test 'a"b' = 'a"b' && echo "quotes match" + test "a'b" = "a'b" && echo "single quotes match" + + - name: "test: arithmetic comparison with spaces/tabs in operand" + stdin: | + # Leading/trailing spaces + test 0 -eq ' 0' + echo "leading space: $?" + + test 0 -eq '0 ' + echo "trailing space: $?" + + test 0 -eq ' 0 ' + echo "both spaces: $?" + + # Tabs + test 0 -eq ' 0' + echo "leading tab: $?" + + test 0 -eq '0 ' + echo "trailing tab: $?" + + - name: "test: arithmetic comparison with newline in operand" + min_oracle_version: "5.3" + stdin: | + # Newline trimming seems to have changed in or around bash 5.3 + test 0 -eq ' 0 + ' + echo "newline trailing: $?" + + - name: "test: -t operator with whitespace in fd" + stdin: | + # -t operator should trim whitespace before parsing fd number + # These should all behave identically (false since not a terminal) + test -t 0 + r1=$? + test -t ' 0' + r2=$? + test -t '0 ' + r3=$? + test -t ' 0 ' + r4=$? + + # All should have the same result + [ $r1 -eq $r2 ] && [ $r2 -eq $r3 ] && [ $r3 -eq $r4 ] && echo "All -t results match" + + + - name: "test with a non-integer operand fails with status 2" + ignore_stderr: true + stdin: | + test 1 -eq a + echo "test: $?" + [ 1 -lt b ] + echo "[: $?" + + - name: "test reports a non-integer operand anywhere in the expression" + ignore_stderr: true + stdin: | + [ "" -eq 0 ]; echo "empty: $?" + [ 1x -gt 0 ]; echo "trailing junk: $?" + [ "1 2" -ne 1 ]; echo "two numbers: $?" + [ 99999999999999999999 -ge 1 ]; echo "too large: $?" + [ ! abc -le 1 ]; echo "negated: $?" + [ 1 -eq 1 -o abc -eq 1 ]; echo "or: $?" + [ 1 -eq 2 -a abc -eq 1 ]; echo "and: $?" + [ abc -eq 1 ] || echo "or list: $?" + [ " 12 " -eq 12 ] && [ -3 -lt +3 ] && echo "valid operands still work" + + - name: "test and [ take words that look like options as operands" + stdin: | + test --; echo "a=$?" + test -n --; echo "b=$?" + test -- = --; echo "c=$?" + test --help; echo "d=$?" + test -h; echo "e=$?" + [ -- ]; echo "f=$?" + [ --help ]; echo "g=$?" + [ ! -- ]; echo "h=$?" + [ -1 -lt 0 ]; echo "i=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml new file mode 100644 index 0000000..4645ae7 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml @@ -0,0 +1,31 @@ +name: "Builtins: times" +cases: + - name: "Basic usage" + ignore_stdout: true + stdin: | + times + + - name: "times ignores its arguments" + ignore_stderr: true + stdin: | + times x >/dev/null + echo "times: $?" + times x -x >/dev/null + echo "times x -x: $?" + times -- -x >/dev/null + echo "times -- -x: $?" + + - name: "times rejects an option" + ignore_stderr: true + stdin: | + times -x >/dev/null + echo "times: $?" + + - name: "times operands in POSIX mode" + ignore_stderr: true + stdin: | + set -o posix + times x >/dev/null + echo "after times x: $?" + times x -x >/dev/null + echo "after times x -x: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml new file mode 100644 index 0000000..89cc0fb --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml @@ -0,0 +1,1002 @@ +name: "Builtins: trap" +cases: + # + # trap -l signal listing + # + - name: "trap -l - lists all signal names" + stdin: | + # Just verify it outputs something reasonable - check common signals exist + trap -l | grep -qE 'HUP|SIGHUP' && echo "has HUP" + trap -l | grep -qE 'INT|SIGINT' && echo "has INT" + trap -l | grep -qE 'TERM|SIGTERM' && echo "has TERM" + + # + # Basic trap registration + # + - name: "trap registration" + stdin: | + trap "echo 1" SIGINT + trap "echo 2" SIGINT + trap -p INT + + trap "echo 3" int + trap -p INT + + trap "echo 4" 2 + trap -p INT + + - name: "trap unregistering" + stdin: | + echo "[Case 1]" + trap "echo 1" SIGINT + trap SIGINT + trap -p INT + + echo "[Case 2]" + trap "echo 2" SIGINT + trap - SIGINT + trap -p INT + + - name: "trap EXIT" + stdin: | + trap 'echo "[exit]"' EXIT + trap -p EXIT + + - name: "trap EXIT: status code handling" + stdin: | + trap 'echo "[exit]: \$?: $?"' EXIT + trap -p EXIT + false + + - name: "trap DEBUG" + stdin: | + trap 'echo [command: ${BASH_COMMAND}]' DEBUG + trap -p DEBUG + + echo First + echo Second + false + + - name: "trap ERR - registration only" + stdin: | + trap "echo [err]" ERR + trap -p ERR + + - name: "trap ERR - basic invocation" + stdin: | + trap 'echo "ERR: $?"' ERR + false + echo "continues" + false + echo "continues again" + + - name: "trap ERR - not triggered in conditional" + stdin: | + trap 'echo "ERR trapped"' ERR + if false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "trap ERR - not triggered with && short-circuit" + stdin: | + trap 'echo "ERR trapped"' ERR + false && echo "not printed" + echo "after" + + - name: "trap ERR - not triggered with ||" + stdin: | + trap 'echo "ERR trapped"' ERR + false || echo "fallback" + echo "after" + + - name: "trap ERR - not triggered with negation" + stdin: | + trap 'echo "ERR trapped"' ERR + ! false + echo "after" + + - name: "trap ERR - not triggered in while condition" + stdin: | + trap 'echo "ERR"' ERR + while false; do + echo "loop body" + done + echo "after while" + + - name: "trap ERR - not triggered in until condition" + stdin: | + trap 'echo "ERR"' ERR + count=0 + until false; do + ((count++)) + if ((count > 2)); then break; fi + done + echo "after until" + + - name: "trap ERR - not triggered as part of || list" + stdin: | + trap 'echo "ERR"' ERR + false || true || false + echo "after" + + - name: "trap ERR - not triggered for negated command" + stdin: | + trap 'echo "ERR"' ERR + if ! true; then + echo "then" + fi + ! false + echo "after" + + - name: "trap ERR - triggered after && chain completes then fails" + stdin: | + trap 'echo "ERR trapped"' ERR + true && true + false + echo "after" + + - name: "trap ERR - execution doesn't affect control flow" + stdin: | + trap 'echo "ERR"' ERR + result=0 + false || result=$? + echo "result: $result" + + - name: "trap ERR - multiple failures trigger multiple traps" + stdin: | + count=0 + trap '((count++)); echo "ERR $count"' ERR + false + false + false + echo "total: $count" + + - name: "A trap leaves PIPESTATUS as it found it" + known_failure: true # TODO(signals): PIPESTATUS isn't kept across a trap + stdin: | + trap 'true | false | true' USR1 + true | false | false; kill -USR1 $$; echo "signal: ${PIPESTATUS[*]}" + trap 'true | true | true' ERR + true | false; echo "err: ${PIPESTATUS[*]}" + + - name: "trap ERR - has access to exit status" + stdin: | + trap 'echo "ERR with status: $?"' ERR + (exit 42) + echo "after" + + - name: "trap ERR - exit in the handler ends the shell" + stdin: | + trap 'echo "in ERR"; exit 3' ERR + false + echo "after" + + - name: "trap ERR - exit in the handler sets the status under errexit" + stdin: | + set -e + trap 'echo "in ERR"; exit 3' ERR + false + echo "after" + + - name: "trap ERR - exit in the handler ends the shell from a function" + stdin: | + set -E + trap 'echo "in ERR"; exit 3' ERR + f() { false; echo "in f"; } + f + echo "after" + + - name: "trap ERR - exit with no status in the handler exits with the failed status" + stdin: | + trap 'exit' ERR + (exit 4) + echo "after" + + - name: "trap ERR - return in the handler returns from the function" + stdin: | + set -E + trap 'return 7 2>/dev/null' ERR + f() { false; echo "in f"; } + f + echo "f: $?" + g() { (exit 5); echo "in g"; } + trap 'return 2>/dev/null' ERR + g + echo "g: $?" + + - name: "trap ERR - break in the handler ends the loop" + known_failure: true # TODO(traps): a break or continue in the ERR trap handler is dropped + stdin: | + for i in 1 2; do + trap 'break' ERR + false + echo "i: $i" + done + echo "after: $?" + + - name: "trap ERR - not run for exit" + stdin: | + trap 'echo "in ERR"' ERR + exit 3 + + - name: "trap ERR - with errtrace, runs once for a function that returns a failure" + stdin: | + set -E + trap 'echo "in ERR: $?"' ERR + f() { return 3; } + f + echo "f: $?" + + - name: "trap ERR - runs for a pipeline whose last stage exits" + stdin: | + trap 'echo "in ERR: $?"' ERR + : | exit 3 + : | { exit 4; } + f() { exit 5; } + : | f + echo "after: $?" + + - name: "trap ERR - exit in the handler sets the status for a failed pipeline stage under errexit" + stdin: | + set -e + trap 'echo "in ERR"; exit 7' ERR + : | { false; } + echo "after" + + - name: "trap ERR - exit in a pipeline stage's handler ends only the stage" + stdin: | + set -E + true | { trap 'exit 3' ERR; false; echo "not reached"; } + echo "after: $?" + + - name: "trap ERR - return in a pipeline stage's handler ends only the stage" + stdin: | + set -E + f() { + true | { trap 'return 3' ERR; false; echo "not reached"; } + echo "in f: $?" + } + f + echo "f: $?" + + - name: "trap ERR - exit in the last stage's handler ends the shell with lastpipe" + stdin: | + shopt -s lastpipe + set -E + true | { trap 'echo "in ERR"; exit 3' ERR; false; } + echo "after" + + - name: "trap ERR - has access to BASH_COMMAND" + known_failure: true # TODO(traps): ERR trap basic execution not implemented + stdin: | + trap 'echo "ERR for: $BASH_COMMAND"' ERR + nonexistent_command_xyz 2>/dev/null + echo "after" + + - name: "trap ERR - has access to LINENO" + known_failure: true # TODO(traps): ERR trap basic execution not implemented + stdin: | + trap 'echo "ERR at line: $LINENO"' ERR + false + echo "after" + + - name: "return in EXIT trap within function" + known_failure: true # TODO(traps): return in trap handler behavior differs + stdin: | + myfunc() { + trap 'return 0' EXIT + false + echo "should not print in func" + } + myfunc + echo "after func" + + - name: "subshell inherits signal traps (for trap -p display)" + stdin: | + trap 'echo "[parent int]"' INT + trap 'echo "[parent term]"' TERM + trap -p INT + echo "---" + (trap -p INT; trap -p TERM) + + - name: "subshell can override inherited trap" + stdin: | + trap 'echo "[parent int]"' INT + trap -p INT + echo "---" + (trap 'echo "[subshell int]"' INT; trap -p INT) + + - name: "subshell inherits EXIT trap" + stdin: | + trap 'echo "[exit]"' EXIT + trap -p EXIT + echo "---" + (trap -p EXIT) + + - name: "subshell inherits DEBUG trap" + stdin: | + trap 'echo "[debug]"' DEBUG + trap -p DEBUG + echo "---" + (trap -p DEBUG) + + - name: "subshell does not execute inherited DEBUG trap" + stdin: | + trap 'echo "[debug]"' DEBUG + echo "parent" + echo "---" + (echo "subshell") + echo "back in parent" + + - name: "subshell can set its own EXIT trap" + known_failure: true # TODO(traps): EXIT trap in subshell + stdin: | + trap 'echo "[parent exit]"' EXIT + echo "parent" + (trap 'echo "[subshell exit]"' EXIT; echo "subshell") + echo "back in parent" + + - name: "subshell inherited EXIT trap does not fire" + stdin: | + trap 'echo "[exit]"' EXIT + echo "parent" + (echo "subshell") + echo "back in parent" + + # + # Subshell trap inheritance in command substitution + # + - name: "command substitution inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + result=$(trap -p INT) + echo "$result" + + - name: "command substitution does not fire inherited signal trap" + known_failure: true # TODO(signals): signal handling in command substitution + stdin: | + trap 'echo "[int]" >&2' INT + result=$(kill -INT $$; echo "after kill") + echo "result: $result" + + - name: "command substitution can set its own trap" + stdin: | + trap 'echo "[parent int]"' INT + result=$(trap 'echo "[subshell int]"' INT; trap -p INT) + echo "$result" + + - name: "command substitution EXIT trap not inherited from parent" + stdin: | + trap 'echo "[parent exit]"' EXIT + result=$(trap -p EXIT; echo "inside") + echo "result: $result" + + - name: "command substitution can have its own EXIT trap" + known_failure: true # TODO: EXIT trap in command substitution subshell + stdin: | + trap 'echo "[parent exit]"' EXIT + result=$(trap 'echo "[sub exit]"' EXIT; echo "inside") + echo "result: $result" + + - name: "nested command substitution trap inheritance" + stdin: | + trap 'echo "[int]"' INT + outer=$(inner=$(trap -p INT); echo "inner: $inner") + echo "outer: $outer" + + # + # Subshell trap inheritance in pipelines + # + - name: "pipeline component inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + trap -p INT | cat + + - name: "pipeline component can override trap" + stdin: | + trap 'echo "[parent int]"' INT + { trap 'echo "[pipe int]"' INT; trap -p INT; } | cat + + - name: "pipeline EXIT trap behavior" + stdin: | + trap 'echo "[exit]"' EXIT + { trap -p EXIT; echo "in pipe"; } | cat + echo "after pipe" + + - name: "multiple pipeline stages trap inheritance" + stdin: | + trap 'echo "[int]"' INT + trap -p INT | cat | cat + + # + # Subshell trap inheritance in process substitution + # + - name: "process substitution inherits signal trap" + known_failure: true # TODO: process substitution trap inheritance + stdin: | + trap 'echo "[int]"' INT + cat <(trap -p INT) + + - name: "process substitution output trap inheritance" + skip: true # TODO: process substitution trap inheritance / inconsistent across distros + stdin: | + trap 'echo "[int]"' INT + echo "data" > >(trap -p INT; cat > /dev/null) + # Give time for the process substitution to complete + wait + + - name: "process substitution can set own EXIT trap" + known_failure: true # TODO: EXIT trap in process substitution + stdin: | + trap 'echo "[parent exit]"' EXIT + cat <(trap 'echo "[sub exit]"' EXIT; echo "inside") + echo "after" + + # + # Subshell trap inheritance in background jobs + # + - name: "background job inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + trap -p INT & + wait + + # The job writes before it signals the shell: the trapped signal interrupts bash's `wait`, + # so anything the job wrote after it would race the shell's own output. + - name: "background job does not fire inherited signal trap" + known_failure: true # TODO(signals): background job signal handling + stdin: | + trap 'echo "[int]" >&2' INT + { echo "before kill"; kill -INT $$; } & + wait + echo "main done" + + - name: "background job EXIT trap behavior" + stdin: | + trap 'echo "[exit]"' EXIT + { trap -p EXIT; echo "bg job"; } & + wait + echo "main continues" + + - name: "background job can set own traps" + stdin: | + trap 'echo "[parent int]"' INT + { trap 'echo "[bg int]"' INT; trap -p INT; } & + wait + trap -p INT + + # + # Coproc trap inheritance (all coproc tests consolidated here) + # + # Note: "coproc - inherits signal trap" test is skipped due to bash version + # differences. Older bash resets traps in coproc, newer bash (5.3.9) inherits them. + # This causes inconsistent test results across CI runners. + - name: "coproc - EXIT trap behavior" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + - arch + stdin: | + trap 'echo "[exit]"' EXIT + coproc { trap -p EXIT; echo "coproc"; } + cat <&${COPROC[0]} + wait + echo "main continues" + + - name: "coproc - DEBUG trap inheritance" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + stdin: | + trap 'echo "[debug]"' DEBUG + coproc { trap -p DEBUG; echo "coproc"; } + cat <&${COPROC[0]} + wait + + - name: "coproc - ERR trap with errtrace" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + - arch + stdin: | + set -E + trap 'echo "[err]"' ERR + coproc { trap -p ERR; false; echo "after false"; } + cat <&${COPROC[0]} + wait + + # + # RETURN trap - basic behavior + # + - name: "RETURN trap - fires on function return" + stdin: | + trap 'echo "[return]"' RETURN + myfunc() { + echo "in func" + } + myfunc + echo "after func" + + - name: "RETURN trap - fires with return value" + stdin: | + trap 'echo "[return: $?]"' RETURN + myfunc() { + echo "in func" + return 42 + } + myfunc + echo "after func" + + - name: "RETURN trap - fires on sourced script completion" + known_failure: true # TODO(traps): RETURN trap not firing for sourced scripts + test_files: + - path: "sourced.sh" + contents: | + echo "in sourced" + stdin: | + trap 'echo "[return]"' RETURN + . ./sourced.sh + echo "after source" + + - name: "RETURN trap - not inherited in function without functrace" + stdin: | + set +T + trap 'echo "[return]"' RETURN + outer() { + inner() { + echo "in inner" + } + inner + echo "after inner" + } + outer + echo "done" + + - name: "RETURN trap - inherited with functrace" + known_failure: true # TODO(traps): RETURN trap inheritance with functrace not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME[0]:-main}]"' RETURN + outer() { + inner() { + echo "in inner" + } + inner + echo "after inner" + } + outer + echo "done" + + - name: "RETURN trap - in explicit subshell" + stdin: | + trap 'echo "[return]"' RETURN + ( + myfunc() { echo "in func"; } + myfunc + ) + echo "after subshell" + + - name: "RETURN trap - does not fire for regular commands" + stdin: | + trap 'echo "[return]"' RETURN + echo "cmd1" + echo "cmd2" + true + echo "done" + + - name: "RETURN trap - does not fire for subshell completion" + stdin: | + trap 'echo "[return]"' RETURN + (echo "in subshell") + echo "after subshell" + + # + # Signal trap execution verification + # + - name: "trap SIGUSR1" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[usr1 received]"' USR1 + trap -p USR1 + kill -USR1 $$ + echo "after signal" + + - name: "trap SIGUSR2" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[usr2 received]"' SIGUSR2 + trap -p USR2 + kill -USR2 $$ + echo "after signal" + + - name: "trap multiple signals with same handler" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[signal received]"' USR1 USR2 + kill -USR1 $$ + kill -USR2 $$ + echo "done" + + - name: "trap handler can inspect signal" + known_failure: true # TODO(signals): signal handling + stdin: | + handler() { + echo "handler called" + } + trap handler USR1 + kill -USR1 $$ + echo "after" + + # + # Trap reset and unset + # + - name: "trap - clears trap" + stdin: | + trap 'echo "[int]"' INT + trap -p INT + trap - INT + trap -p INT + + - name: "trap '' ignores signal" + known_failure: true # TODO(signals): signal handling + stdin: | + trap '' INT + trap -p INT + kill -INT $$ + echo "survived" + + - name: "trap with empty string can be cleared" + stdin: | + trap '' INT + trap -p INT + trap - INT + trap -p INT + + # + # trap -p edge cases + # + - name: "trap -p - with no args shows all traps" + skip: true # TODO(signals): need to report on ignored signals + stdin: | + trap 'echo a' INT + trap 'echo b' TERM + trap 'echo c' EXIT + trap -p | sort + + - name: "trap -p - with specific signal" + stdin: | + trap 'echo "[int]"' INT + trap 'echo "[term]"' TERM + trap -p INT + trap -p TERM + + - name: "trap -p - with multiple signals" + stdin: | + trap 'echo "[int]"' INT + trap 'echo "[term]"' TERM + trap 'echo "[usr1]"' USR1 + trap -p INT TERM USR1 + + - name: "trap -p - for unset trap shows nothing" + stdin: | + trap -p INT + echo "done" + + - name: "trap -p - output is re-executable" + stdin: | + trap 'echo "hello world"' INT + eval "$(trap -p INT)" + trap -p INT + + # + # Trap handler quoting + # + - name: "trap handler - single quotes preserved" + known_failure: true # TODO(traps): quote escaping in trap -p output differs + stdin: | + trap 'echo '\''quoted'\''' INT + trap -p INT + + - name: "trap handler - double quotes in handler" + stdin: | + trap 'echo "double quoted"' INT + trap -p INT + + - name: "trap handler - special characters" + stdin: | + trap 'echo $HOME; echo "done"' INT + trap -p INT + + - name: "trap handler - newlines in handler" + stdin: | + trap 'echo line1 + echo line2' INT + trap -p INT + + # + # DEBUG trap - basic behavior + # + - name: "DEBUG trap - sees BASH_COMMAND" + known_failure: true # TODO(BASH_COMMAND): not showing assignment, quotes differ + stdin: | + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + x=1 + echo "x is $x" + + # + # A shell substitutes an alias in the tokenizer, before the words are ever a command, so the + # trap sees the replacement and not the word the script wrote. + # + - name: "DEBUG trap - BASH_COMMAND reports the command after alias substitution" + stdin: | + shopt -s expand_aliases + alias greet='echo hello' + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + greet there + + - name: "DEBUG trap - output is not captured by the command's own redirection" + known_failure: true # TODO(traps): the DEBUG trap runs after the command's redirections are applied + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo hidden >/dev/null + echo visible + + - name: "DEBUG trap - sees LINENO" + known_failure: true # TODO(traps): LINENO in DEBUG trap not accurate + stdin: | + trap 'echo "line: $LINENO"' DEBUG + echo first + echo second + + - name: "DEBUG trap - fires before each simple command" + known_failure: true # TODO(traps): DEBUG trap not firing for trap command itself + stdin: | + count=0 + trap '((count++))' DEBUG + echo a + echo b + echo c + echo "count: $count" + + - name: "DEBUG trap - in loop" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + for i in 1 2; do + echo "iter $i" + done + + # + # EXIT trap - advanced cases + # + - name: "EXIT trap - has access to exit status" + stdin: | + trap 'echo "exiting with: $?"' EXIT + exit 42 + + - name: "EXIT trap - runs even after errexit" + stdin: | + set -e + trap 'echo "[exit trap ran]"' EXIT + false + echo "not reached" + + - name: "EXIT trap - can modify exit status" + stdin: | + trap 'exit 0' EXIT + exit 99 + + - name: "EXIT trap - exit in the handler sets the status of a shell that ends normally" + stdin: | + trap 'echo "in EXIT"; exit 7' EXIT + echo "body" + + - name: "EXIT trap - exit in the handler sets the status of a -c command" + args: ["-c", "trap 'echo \"in EXIT\"; exit 7' EXIT; exit 1"] + + - name: "EXIT trap - exit in the handler sets the status of a script" + test_files: + - path: "script.sh" + contents: | + trap 'echo "in EXIT"; exit 7' EXIT + echo "body" + args: ["script.sh"] + + - name: "EXIT trap - set by the ERR trap, its exit wins over the ERR trap's" + stdin: | + trap 'trap "echo in EXIT; exit 9" EXIT; exit 2' ERR + false + echo "after" + + - name: "EXIT trap - multiple assignments last one wins" + stdin: | + trap 'echo "first"' EXIT + trap 'echo "second"' EXIT + echo "done" + + # + # EXIT trap in -c mode + # + - name: "EXIT trap fires in -c mode" + args: + - "-c" + - "trap 'echo exiting' EXIT; echo hello" + expected_stdout: | + hello + exiting + + - name: "EXIT trap in -c mode has access to exit status" + args: + - "-c" + - "trap 'echo \"status: $?\"' EXIT; exit 42" + expected_stdout: "status: 42\n" + + - name: "EXIT trap in -c mode runs after errexit" + args: + - "-c" + - "set -e; trap 'echo trapped' EXIT; false" + expected_stdout: "trapped\n" + + - name: "EXIT trap in -c mode with multiple assignments last one wins" + args: + - "-c" + - "trap 'echo first' EXIT; trap 'echo second' EXIT; echo done" + expected_stdout: | + done + second + + # + # Trap with multiple signals + # + - name: "trap - clear multiple signals at once" + stdin: | + trap 'echo "[handler]"' INT TERM USR1 + trap -p INT + trap -p TERM + trap -p USR1 + echo "---" + trap - INT TERM USR1 + trap -p INT + trap -p TERM + trap -p USR1 + echo "done" + + - name: "trap - ignore multiple signals" + stdin: | + trap '' INT TERM + trap -p INT + trap -p TERM + echo "done" + + - name: "DEBUG trap - BASH_COMMAND reports commands before expansion" + known_failure: true # TODO: BASH_COMMAND improvements required + stdin: | + value=world + trap 'case $BASH_COMMAND in echo*|declare*|printf*) echo "cmd: <$BASH_COMMAND>";; esac' DEBUG + echo $value + declare declared=$value + printf '%s\n' "$value" + trap - DEBUG + + - name: "DEBUG trap - before each stage of a pipeline" + known_failure: true # TODO(pipeline): the DEBUG trap doesn't run before the commands of a pipeline of more than one command + stdin: | + trap 'echo "debug: $BASH_COMMAND"' DEBUG + true | false + echo done + trap - DEBUG + + - name: "ERR trap with errtrace runs once for a subshell that exits" + stdin: | + set -E + trap 'echo "ERR $?"' ERR + f() { exit 3; } + (f) + echo "after: $?" + + - name: "trap -p lists traps in bash's order" + skip: true # TODO(trap): traps are listed in hash order, which now and then happens to be bash's + stdin: | + trap 'echo r' RETURN + trap 'echo u' USR1 + trap 'echo t' TERM + trap 'echo i' INT + trap 'echo x' EXIT + trap -p + trap - EXIT + + - name: "ERR trap runs once for a failing brace group" + known_failure: true # TODO(trap): the ERR trap runs for the group and again for its last command + stdin: | + trap 'echo err' ERR + { false; } + if true; then false; fi + echo done + + - name: "ERR trap runs twice for a failing subshell that ends a pipeline" + known_failure: true # TODO(trap): the ERR trap runs once, not twice, when a pipeline's last stage is a failing subshell + stdin: | + trap 'echo "err $?"' ERR + : | ( exit 3 ) + echo "status: $?" + set -E + : | ( exit 4 ) + echo "status: $?" + + - name: "ERR trap set inside a function runs there" + known_failure: true # TODO(trap): an ERR trap set in a function body doesn't fire in it + stdin: | + f() { trap 'echo err' ERR; false; echo in-f; } + f + + - name: "ERR trap doesn't run for the call that set it" + known_failure: true # TODO(trap): the ERR trap runs for the failing call of the function that set it + stdin: | + f() { trap 'echo err' ERR; return 3; } + f + echo "first call: $?" + f + echo "second call: $?" + + - name: "ERR trap set inside a subshell runs there" + known_failure: true # TODO(trap): an ERR trap set in a subshell doesn't fire in it + stdin: | + (trap 'echo err' ERR; false; echo in-sub) + echo after + + - name: "EXIT trap - exit in the handler sets a subshell's status" + known_failure: true # TODO(trap): a subshell ignores its EXIT trap's exit status + stdin: | + (trap 'exit 5' EXIT; true) + echo "rc=$?" + + - name: "DEBUG trap - exit in the handler ends the shell" + known_failure: true # TODO(trap): an exit in the DEBUG trap is ignored + stdin: | + trap 'exit 4' DEBUG + echo not-reached + + - name: "trap rejects -- after the action as a signal" + stdin: | + trap 'echo x' -- INT 2>/dev/null; echo "rc=$?" + trap -p INT + trap -- 'echo y' INT; echo "rc=$?" + trap -p INT + + - name: "trap sets and resets the valid signals around an invalid one" + ignore_stderr: true + stdin: | + trap 'echo x' INT BOGUS USR1; echo "set: $?" + trap -p INT USR1 + trap - BOGUS INT; echo "reset: $?" + trap -p INT USR1 + trap 'echo y' EXIT --; echo "exit: $?" + + - name: "trap takes -- before the action, and - to reset" + stdin: | + trap -- 'echo x' USR1 + trap -p USR1 + trap -- - USR1 + trap -p USR1 + trap 'echo y' USR1 + trap -- USR1 + trap -p USR1 + echo "done" + + - name: "trap -l lists the signals in columns" + known_failure: true # TODO(trap): one signal a line, starting with EXIT, not bash's table + stdin: | + trap -l | head -n 1 + + - name: "trap -P prints the action alone" + known_failure: true # TODO(trap): -P is an unknown flag + min_oracle_version: "5.3" + stdin: | + trap 'echo x' USR1 + trap -P USR1 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml new file mode 100644 index 0000000..eec5baa --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml @@ -0,0 +1,7 @@ +name: "true and false" +cases: + - name: "true and false ignore all args" + stdin: | + builtin false --help; echo "builtin false --help => $?" + builtin true --help; echo "builtin true --help => $?" + builtin true s87tien5; echo "builtin true s87tien5 => $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml new file mode 100644 index 0000000..e0a254e --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml @@ -0,0 +1,155 @@ +name: "Builtins: type" +cases: + - name: "Test type with no arguments" + stdin: type + + - name: "Test type with a valid command" + stdin: type ls + + - name: "Test type with an invalid command" + ignore_stderr: true + stdin: type invalid_command + + - name: "Test type with -t option and a builtin command" + stdin: type -t cd + + - name: "Test type with -t option and an external command" + stdin: type -t ls + + - name: "Test type with -t option and non-existent command" + ignore_stderr: true + stdin: type -t non-existent + + - name: "Test type with -a option and non-existent command" + ignore_stderr: true + stdin: type -a non-existent + + - name: "Test type with -a option and a command with multiple definitions" + stdin: type -a true + + - name: Test type with -p option and a builtin command + stdin: type -p cd + + - name: Test type with -p option and an external command + stdin: type -p ls + + - name: Test type with -p option and non-existent command + stdin: type -p non-existent + + - name: Test type with -P option and a builtin command + stdin: type -P cd + + - name: Test type with -P option and an external command + stdin: type -P ls + + - name: Test type with -P option and non-existent command + stdin: type -P non-existent + + - name: Test type with -f option and a function + ignore_stderr: true + stdin: | + function myfunc() { echo "Hello, world!"; } + type -f myfunc + + - name: Test type with -f option and a command + stdin: type -f ls + + - name: Test type with -a option and a function + stdin: | + function myfunc() { echo "Hello, world!"; } + type -a myfunc + + - name: Test type with hashed path + stdin: | + hash -p /some/ls ls + type ls + + - name: Test type -a with hashed path + stdin: | + hash -p /some/ls ls + type -a ls + + - name: Test type -P with hashed path + stdin: | + hash -p /some/ls ls + type -P ls + + - name: Test type -P -a with hashed path + min_oracle_version: 5.3 # Behavior changed in bash 5.3 + stdin: | + hash -p /some/ls ls + type -P -a ls + + - name: Test type -p -a with hashed path + stdin: | + hash -p /some/ls ls + type -p -a ls + + - name: Test type honors expand_aliases + ignore_stderr: true + stdin: | + alias za="ls" + + shopt -u expand_aliases + type za; echo "rc=$?" + type -t za; echo "rc=$?" + type -a za; echo "rc=$?" + + shopt -s expand_aliases + type za; echo "rc=$?" + type -t za; echo "rc=$?" + + - name: Test type and execution skip a directory found in PATH + ignore_stderr: true # brush's diagnostics don't match bash's text. + stdin: | + mkdir -p pdir/prog + PATH="$PWD/pdir:$PATH" + + type prog; echo "rc=$?" + type -a prog; echo "rc=$?" + prog; echo "rc=$?" + + - name: Test type reports a missing name + stdin: | + # Normalize away the leading "shell: line N:" that the oracle prefixes onto diagnostics. + strip() { sed 's/^.*type:/type:/'; } + + type nonexistent 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + type nonexistent echo 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + + - name: Test type reports a non-executable file found in PATH + stdin: | + # Normalize away the leading "shell: line N:" that the oracle prefixes onto diagnostics. + strip() { sed 's/^.*type:/type:/'; } + + mkdir -p d1 d2 + touch d1/nx + printf '#!/bin/sh\necho D2\n' > d2/nx + chmod +x d2/nx + touch d1/only + touch d2/only2 + PATH="d1:d2:$PATH" + + echo "[an executable later in PATH beats a non-executable earlier]" + type nx; echo "rc=$?" + type -a nx; echo "rc=$?" + nx; echo "run rc=$?" + + echo "[only a non-executable exists]" + type only; echo "rc=$?" + type -t only; echo "rc=$?" + type -p only; echo "rc=$?" + type -P only; echo "rc=$?" + # -a is a strict search for executables, so it reports nothing. + type -a only 2>&1 | strip; echo "rc=${PIPESTATUS[0]}" + # ...and the shell tries to run the file, rather than reporting it as not found. + only 2>/dev/null; echo "run rc=$?" + + echo "[the fallback keeps searching the rest of PATH]" + type only2; echo "rc=$?" + + - name: "type takes -- and combined options" + stdin: | + type -t -- echo + type -at true + type -t -- -x; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml new file mode 100644 index 0000000..794b46d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml @@ -0,0 +1,10 @@ +name: "Builtins: typeset" + +cases: + - name: "Display vars" + stdin: | + typeset myvar=something + typeset -p myvar + + typeset myarr=(a b c) + typeset -p myarr diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml new file mode 100644 index 0000000..440d2bd --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml @@ -0,0 +1,162 @@ +name: "Builtins: ulimit" +cases: + - name: "ulimit -c" + stdin: | + ulimit -c + + - name: "ulimit -d" + stdin: | + ulimit -d + + - name: "ulimit -f" + stdin: | + ulimit -f + + - name: "ulimit -l" + stdin: | + ulimit -l + + - name: "ulimit -m" + stdin: | + ulimit -m + + - name: "ulimit -n" + stdin: | + ulimit -n + + - name: "ulimit -s" + stdin: | + ulimit -s + + - name: "ulimit -t" + stdin: | + ulimit -t + + - name: "ulimit -u" + stdin: | + ulimit -u + + - name: "ulimit -v" + stdin: | + ulimit -v + + - name: "ulimit -p" + known_failure: true # TODO(ulimit): brush reports a pipe size of 5 blocks, where bash reports 8 (4096 bytes) + incompatible_platforms: ["macos"] # the pipe size differs there + stdin: | + ulimit -p + + - name: "ulimit -d unlimited" + stdin: | + ulimit -d unlimited + + - name: "ulimit -f unlimited" + stdin: | + ulimit -f unlimited + + - name: "ulimit -m unlimited" + stdin: | + ulimit -m unlimited + + - name: "ulimit with an invalid number fails with status 1" + known_failure: true # TODO(ulimit): fails with status 2, where bash fails with 1 + ignore_stderr: true + stdin: | + ulimit -n abc + echo "ulimit: $?" + + - name: "ulimit in a subshell stays there" + known_failure: true # TODO(ulimit): a subshell runs in the shell's own process, so its limits are the shell's + stdin: | + ulimit -S -n 512 + ( ulimit -S -n 256 ) + echo "after a subshell: $(ulimit -S -n)" + ulimit -S -n 256 | true + echo "after a pipeline stage: $(ulimit -S -n)" + + - name: "ulimit with no options reports the file size limit" + stdin: | + ulimit + + - name: "ulimit -S and -H report the soft and hard limits" + stdin: | + ulimit -Sn + ulimit -Hn + ulimit -S -n + + - name: "ulimit sets a limit given as a separate or attached value" + stdin: | + ulimit -n 100; ulimit -n + ulimit -n99; ulimit -n + ulimit -Sn 98; ulimit -Sn + ulimit -c 0; ulimit -c + + - name: "ulimit sets the file size limit from an operand" + # Large enough not to stop the shell writing, e.g., coverage data at exit. + stdin: | + ulimit 100000000; ulimit -f + ulimit -- 90000000; echo "ulimit -- 90000000: $?"; ulimit -f + + - name: "ulimit sets a limit to its soft limit" + stdin: | + ulimit -n soft; echo "ulimit -n soft: $?"; ulimit -n + + - name: "ulimit sets several limits at once" + stdin: | + ulimit -n 100 -c 0; ulimit -n; ulimit -c + + - name: "ulimit ignores operands after the first value" + stdin: | + ulimit -n 100 200; echo "ulimit -n 100 200: $?"; ulimit -n + + - name: "ulimit rejects an unknown option and a negative limit" + ignore_stderr: true + stdin: | + ulimit -Z; echo "ulimit -Z: $?" + ulimit -n -1; echo "ulimit -n -1: $?" + + - name: "ulimit lists several resources in the order given" + known_failure: true # TODO(ulimit): brush lists them in a fixed order, with its own labels and widths + stdin: | + ulimit -n -c + ulimit -s -t + + - name: "ulimit labels a resource it reports alongside one it sets" + known_failure: true # TODO(ulimit): brush prints the bare value + stdin: | + ulimit -c 0 -n + + - name: "ulimit takes a limit's value after --" + known_failure: true # TODO(ulimit): brush reports the limit and ignores the value + stdin: | + ulimit -n -- 100; echo "ulimit -n -- 100: $?"; ulimit -n + + - name: "ulimit rejects a resource this platform lacks with status 2" + known_failure: true # TODO(ulimit): brush takes each and fails with "unsupported resource", status 1 + incompatible_platforms: ["macos"] # which of these exist differs there + ignore_stderr: true + stdin: | + ulimit -b; echo "ulimit -b: $?" + ulimit -k; echo "ulimit -k: $?" + ulimit -P; echo "ulimit -P: $?" + ulimit -T; echo "ulimit -T: $?" + + - name: "ulimit --help fails with status 2" + stdin: | + ulimit --help >/dev/null 2>&1; echo "ulimit --help: $?" + + - name: "ulimit rejects an unknown long option with status 2" + ignore_stderr: true + stdin: | + ulimit --bogus; echo "ulimit --bogus: $?" + + - name: "ulimit -a lists every limit" + known_failure: true # TODO(ulimit): brush's labels, widths and order differ, e.g. `(block, -c)` for `(blocks, -c)` + stdin: | + ulimit -a + + - name: "ulimit -a with another resource lists each limit once" + known_failure: true # TODO(ulimit): brush lists the other resource a second time + stdin: | + a=$(ulimit -a); an=$(ulimit -a -n) + [[ $a == "$an" ]] && echo same || echo differs diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/umask.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/umask.yaml new file mode 100644 index 0000000..3ea5365 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/umask.yaml @@ -0,0 +1,39 @@ +name: "Builtins: umask" +cases: + - name: "umask with an out-of-range mode fails with status 1" + known_failure: true # TODO(umask): fails as unimplemented (99) or with status 2, where bash fails with 1 + ignore_stderr: true + stdin: | + umask 999 + echo "umask: $?" + + - name: "umask in a subshell stays there" + known_failure: true # TODO(umask): a subshell runs in the shell's own process, so its umask is the shell's + stdin: | + umask 022 + ( umask 077 ) + echo "after a subshell: $(umask)" + umask 077 | true + echo "after a pipeline stage: $(umask)" + + - name: "umask -S, -p and --" + stdin: | + umask 022 + umask -S + umask -p + umask -- 077 + umask + + - name: "umask -p -S prints a reusable symbolic command" + known_failure: true # TODO(umask): prints `umask u=...`, without `-S` + stdin: | + umask 022 + umask -p -S + + - name: "umask takes a symbolic mode" + known_failure: true # TODO(umask): a symbolic mode is not implemented + ignore_stderr: true + stdin: | + umask 022 + umask u=rwx,g=rx,o=; umask + umask g-r; umask diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml new file mode 100644 index 0000000..fe53ba9 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml @@ -0,0 +1,21 @@ +name: "Builtins: unalias" +cases: + - name: "Unalias basic usage" + stdin: | + shopt -s expand_aliases + alias echo='echo prefixed' + echo 'something' + unalias echo + echo 'something' + + - name: "Unalias non-existent alias" + ignore_stderr: true # Slightly different error messages + stdin: | + shopt -s expand_aliases + unalias not_an_alias + + - name: "unalias with no names fails with status 2" + known_failure: true # TODO(unalias): succeeds + ignore_stderr: true + stdin: | + unalias; echo "rc=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml b/userland/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml new file mode 100644 index 0000000..f8fd9e3 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml @@ -0,0 +1,301 @@ +name: "Builtins: unset" +cases: + - name: "Basic unset usage" + stdin: | + var=value + unset var + echo "var: ${var}" + + - name: "Unset multiple variables" + stdin: | + var1=value1 + var2=value2 + unset var1 var2 + echo "var1: ${var1}, var2: ${var2}" + + - name: "Unset function" + ignore_stderr: true + stdin: | + function myfunc() { + echo "Hello, world!" + } + unset myfunc + myfunc + + - name: "Unset -f function" + ignore_stderr: true + stdin: | + function myfunc() { + echo "Hello, world!" + } + unset -f myfunc + myfunc + + - name: "Unset indexed array element" + stdin: | + declare -a myarray=(a b c) + + unset myarray[0] + declare -p myarray + + unset myarray[3] + declare -p myarray + + unset myarray[2] + declare -p myarray + + unset myarray[1] + declare -p myarray + + - name: "Unset associative array element" + stdin: | + echo "[1]" + + declare -A myarray=([key]=value) + + unset myarray[key] + declare -p myarray + + echo "[2]" + + declare -A myarray=([key]=value) + + unset "myarray[key]" + declare -p myarray + + echo "[3]" + + declare -A myarray=([key]=value) + key="x:y:z" + + unset "myarray[key]" + declare -p myarray + + - name: "Unset array element with interesting expression" + stdin: | + declare -a myarray=(a b c d e) + + echo "Initial array:" + declare -p myarray + echo + + i=4 + unset myarray[i] + echo "After removing element at index 4:" + declare -p myarray + echo + + unset myarray[6/2] + echo "After removing element at index 3:" + declare -p myarray + echo + + - name: "Unset array element with negative index" + stdin: | + declare -a myarray=(a b c d e) + + echo "Initial array:" + declare -p myarray + echo + + unset "myarray[-1]" + echo "After removing last element (index -1):" + declare -p myarray + echo + + unset "myarray[-1]" + echo "After removing new last element (index -1 again):" + declare -p myarray + echo + + unset "myarray[-2]" + echo "After removing second-to-last element (index -2):" + declare -p myarray + echo + + - name: "Unset array element with negative index out of bounds" + ignore_stderr: true + stdin: | + declare -a myarray=(a b c) + + echo "Initial array:" + declare -p myarray + echo + + unset "myarray[-4]" + echo "After attempting to remove with index -4 (out of bounds):" + declare -p myarray + echo "Return code: $?" + + - name: "Unset array element with variable negative index" + stdin: | + declare -a myarray=(10 20 30 40 50) + + echo "Initial array:" + declare -p myarray + echo + + i=-1 + unset "myarray[i]" + echo "After removing with variable index i=-1:" + declare -p myarray + echo + + i=-2 + unset "myarray[i]" + echo "After removing with variable index i=-2:" + declare -p myarray + echo + + - name: "Unset local in same function" + stdin: | + var="global" + + myfunc() { + echo "In myfunc" + local -i var="10" + declare -p var + unset var + echo "After unset" + declare -p var + var="20" + } + + echo "Before call: var=${var}" + myfunc + echo "After call: var=${var}" + + - name: "Unset locals in callers" + stdin: | + firstfunc() { + local var="first" + echo "entered firstfunc: var=${var}" + secondfunc + echo "leaving firstfunc: var=${var}" + } + + secondfunc() { + local var="second" + echo "entered secondfunc: var=${var}" + thirdfunc + echo "leaving secondfunc: var=${var}" + } + + thirdfunc() { + echo "entered thirdfunc: var=${var}" + unset -v var + echo "after first unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + unset -v var + echo "after second unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + unset -v var + echo "after third unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + echo "leaving firstfunc: var=${var}" + } + + var="global" + echo "before calls: var=${var}" + firstfunc + echo "after calls: var=${var}" + + - name: "Unset with nameref" + known_failure: true + stdin: | + declare -n ref=var + var="value" + + echo "[Before unset]" + echo "ref: ${ref}" + echo "var: ${var}" + + unset ref + + echo "[After unset]" + echo "ref: ${ref}" + echo "var: ${var}" + + - name: "Unset -n removes nameref itself" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset -n ref + echo "after unset -n: target=$target" + declare -p ref 2>/dev/null || echo "ref is gone" + declare -p target 2>/dev/null && echo "target still declared" + + - name: "Unset array element through nameref" + known_failure: true + stdin: | + target=(a b c d e) + declare -n ref=target + unset 'ref[2]' + declare -p target + unset 'ref[0]' + declare -p target + + - name: "Unset odd function names" + stdin: | + echo "[Checking existing]" + type [ + + echo "[Defining function]" + function [() { + : + } + + echo "[Checking defined]" + type [ + + echo "[Unsetting function]" + unset [ + echo "unset result: $?" + + echo "[Checking after unset]" + type [ + + - name: "unset -f with -v is an error" + ignore_stderr: true + stdin: | + x=1 + unset -f -v x + echo "result: $?" + unset -fv x + echo "result: $?" + echo "x=${x-unset}" + + - name: "unset does not evaluate an associative key arithmetically" + stdin: | + declare -A zz_m + zz_m[1+1]=literal + zz_m[2]=numeric + unset 'zz_m[1+1]' + declare -p zz_m + + - name: "unset with a star or at subscript leaves an associative array alone" + stdin: | + declare -A zz_m=([k]=v) + unset 'zz_m[*]' + echo "status: $?" + declare -p zz_m + unset 'zz_m[@]' + echo "status: $?" + declare -p zz_m + + - name: "unset -v rejects a name that isn't a variable's" + known_failure: true # TODO(unset): an invalid name is accepted silently + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + a=(1 2) + unset -v 1x 'a[1' 'b c' 'a[0]' 2>errors.txt + echo "unset -v: $? ${#a[@]}" + strip ready; while :; do sleep 0.05; done' & + while [ ! -s ready ]; do sleep 0.05; done + kill $! + wait $! + echo "status: $?" + rm ready + + - name: "a job's program that handles the signal sent to its job spec is waited for" + skip: true # TODO(signals): hangs: a wait for a job's program never returns + stdin: | + sh -c 'trap "exit 0" TERM; echo ready > ready; while :; do sleep 0.05; done' & + while [ ! -s ready ]; do sleep 0.05; done + kill %1 + wait %1 + echo "status: $?" + rm ready + + # The program notes each signal in a file; it's told to finish once it has noted one. + - name: "a job's program gets a signal sent to its $! once, and the job goes on" + known_failure: true # TODO(signals): a job's shell code isn't a process to wait for or signal + stdin: | + sh -c 'trap "echo got >> got.txt" USR1; echo ready > ready; until [ -e go ]; do sleep 0.05; done' & + while [ ! -s ready ]; do sleep 0.05; done + kill -USR1 $! + n=0; until [ -s got.txt ] || [ $n -ge 60 ]; do sleep 0.05; n=$((n + 1)); done + touch go + wait $! + echo "status: $?" + cat got.txt + rm ready go got.txt + + # The child ends as soon as it has signaled, so its CHLD trap may run with the USR1 trap: + # the signal that interrupted the wait still gives the status. + - name: "wait interrupted by a trapped signal as a child ends returns the signal's status" + known_failure: true # TODO(signals): a job's shell code isn't a process to wait for or signal + stdin: | + trap ':' USR1 + trap ':' CHLD + { sleep 0.2; kill -USR1 $$; } & + wait + echo "wait: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/callstack.yaml b/userland/brush/source/brush-shell/tests/cases/compat/callstack.yaml new file mode 100644 index 0000000..e129725 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/callstack.yaml @@ -0,0 +1,649 @@ +name: "Call stack" +common_test_files: + - path: "callstack_helpers.sh" + contents: | + # Helper to dump frame-independent call stack state. + # Optionally takes a prefix string to use in all output. + dump_source_info() { + local prefix="${1:-}" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} caller: $(caller 2>&1 || echo '')" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + + echo "${prefix} LINENO: ${LINENO}" + echo "${prefix} FUNCNAME[@]: ${FUNCNAME[*]}" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} BASH_SOURCE[@]: ${BASH_SOURCE[*]}" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + + # TODO(BASH_LINENO): implement correct BASH_LINENO tracking + # echo "${prefix} BASH_LINENO[@]: ${BASH_LINENO[*]}" + } + + # Helper to dump frame-specific call stack state + dump_frame() { + local prefix="${1:-}" + local frame="${2:-0}" + + echo "${prefix}[Frame $frame]" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} caller N: $(caller $frame 2>&1 || echo '')" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + } + + # Helper to dump all stack info + dump_stack() { + local prefix="${1:-}" + + dump_source_info "$prefix" + + local max_frames="${2:-20}" + local i + for ((i=0; i/dev/null 2>&1; then + break + fi + dump_frame "$prefix" $i + done + } + +cases: + - name: "No calls - top level" + stdin: | + source callstack_helpers.sh + + echo "=== Top level ===" + dump_stack + + - name: "Simple function call" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + my_function + + - name: "Nested function calls" + stdin: | + source callstack_helpers.sh + + level3() { + echo "=== In level3 ===" + dump_stack " " + } + + level2() { + echo "=== In level2 ===" + dump_stack " " + level3 + } + + level1() { + echo "=== In level1 ===" + dump_stack " " + level2 + } + + level1 + + - name: "Function call from sourced script" + test_files: + - path: "sourced.sh" + contents: | + sourced_function() { + echo "=== In sourced_function ===" + dump_stack " " + } + stdin: | + source callstack_helpers.sh + + source sourced.sh + sourced_function + + - name: "Nested sourcing" + test_files: + - path: "outer.sh" + contents: | + echo "=== In outer.sh top level ===" + dump_stack " " + source inner.sh + - path: "inner.sh" + contents: | + echo "=== In inner.sh top level ===" + dump_stack " " + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + inner_func + stdin: | + source callstack_helpers.sh + + command source outer.sh + + - name: "Command substitution in top level" + stdin: | + source callstack_helpers.sh + + result=$(dump_stack " [CmdSub] ") + echo "$result" + + - name: "Command substitution in function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + result=$(dump_stack " [CmdSub] ") + echo "$result" + } + + my_function + + - name: "Command substitution invoking function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function via cmdSub ===" + dump_stack " " + } + + result=$(my_function) + echo "$result" + + - name: "Nested command substitutions" + stdin: | + source callstack_helpers.sh + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + outer_func() { + echo "=== In outer_func ===" + result=$(inner_func) + echo "$result" + } + + final=$(outer_func) + echo "$final" + + - name: "eval at top level" + stdin: | + source callstack_helpers.sh + + eval 'echo "=== In eval ==="; dump_stack " "' + + - name: "eval in function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + eval 'echo "=== In eval ==="; dump_stack " "' + } + + my_function + + - name: "eval calling function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function via eval ===" + dump_stack " " + } + + eval 'my_function' + + - name: "Nested evals" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + eval 'eval "my_function"' + + - name: "eval with command substitution" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + result=$(eval 'my_function') + echo "$result" + + - name: "Trap handler - DEBUG" + stdin: | + source callstack_helpers.sh + + trap_handler() { + echo "=== In DEBUG trap handler ===" + dump_stack " " + } + + trap 'trap_handler' DEBUG + + echo "Command 1" + echo "Command 2" + + - name: "Trap handler - DEBUG in function" + stdin: | + source callstack_helpers.sh + + trap_handler() { + echo "=== In DEBUG trap handler ===" + dump_stack " " + } + + trap 'trap_handler' DEBUG + + my_function() { + echo "In my_function" + echo "Still in my_function" + } + + my_function + + - name: "Trap handler - EXIT" + stdin: | + source callstack_helpers.sh + + trap 'echo "=== In EXIT trap ==="; dump_stack " "' EXIT + + echo "Main script" + + - name: "Trap handler calling function" + stdin: | + source callstack_helpers.sh + + trap_function() { + echo "=== In trap_function ===" + dump_stack " " + } + + trap 'trap_function' EXIT + + echo "Main script" + + - name: "LINENO tracking across contexts" + stdin: | + echo "Line 1: LINENO=${LINENO}" + echo "Line 2: LINENO=${LINENO}" + + my_function() { + echo "In function line 1: LINENO=${LINENO}" + echo "In function line 2: LINENO=${LINENO}" + } + + my_function + + echo "Line 3: LINENO=${LINENO}" + + - name: "LINENO in eval" + stdin: | + echo "Before eval: LINENO=${LINENO}" + eval 'echo "In eval: LINENO=${LINENO}"' + echo "After eval: LINENO=${LINENO}" + + - name: "LINENO in command substitution" + stdin: | + echo "Before cmdSub: LINENO=${LINENO}" + result=$(echo "In cmdSub: LINENO=${LINENO}") + echo "$result" + echo "After cmdSub: LINENO=${LINENO}" + + - name: "LINENO in sourced script" + test_files: + - path: "script.sh" + contents: | + echo "Sourced line 1: LINENO=${LINENO}" + echo "Sourced line 2: LINENO=${LINENO}" + + sourced_func() { + echo "In sourced_func: LINENO=${LINENO}" + } + + sourced_func + stdin: | + echo "Main line 1: LINENO=${LINENO}" + source script.sh + echo "Main line 2: LINENO=${LINENO}" + + - name: "different depths" + stdin: | + source callstack_helpers.sh + + level3() { + dump_stack + } + + level2() { + level3 + } + + level1() { + level2 + } + + level1 + + - name: "BASH_SOURCE and FUNCNAME arrays" + test_files: + - path: "outer.sh" + contents: | + outer_func() { + echo "=== In outer_func ===" + echo " FUNCNAME[@]: ${FUNCNAME[*]}" + echo " BASH_SOURCE[@]: ${BASH_SOURCE[*]}" + source inner.sh + } + - path: "inner.sh" + contents: | + inner_func() { + echo "=== In inner_func ===" + echo " FUNCNAME[@]: ${FUNCNAME[*]}" + echo " BASH_SOURCE[@]: ${BASH_SOURCE[*]}" + } + + inner_func + stdin: | + source outer.sh + outer_func + + - name: "Mixed: eval in sourced function with command substitution" + test_files: + - path: "script.sh" + contents: | + script_func() { + echo "=== In script_func ===" + result=$(eval 'dump_stack " [Eval->CmdSub] "') + echo "$result" + } + stdin: | + source callstack_helpers.sh + + source script.sh + script_func + + - name: "Complex nesting: function -> eval -> cmdSub -> function" + stdin: | + source callstack_helpers.sh + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + outer_func() { + echo "=== In outer_func ===" + eval 'result=$(inner_func); echo "$result"' + } + + outer_func + + # Tests with -c flag + - name: "Call stack with -c flag" + args: + ["-c", "source callstack_helpers.sh; echo '=== Via -c ==='; dump_stack"] + + - name: "Function call with -c flag" + args: + [ + "-c", + "my_func() { echo '=== In my_func via -c ==='; dump_stack; }; source callstack_helpers.sh; my_func", + ] + + - name: "Nested functions with -c flag" + args: + [ + "-c", + "f2() { echo 'In f2'; dump_stack; }; f1() { echo 'In f1'; f2; }; source callstack_helpers.sh; f1", + ] + + - name: "eval with -c flag" + args: + [ + "-c", + "eval 'source callstack_helpers.sh; echo In eval via -c; dump_stack'", + ] + + - name: "Command substitution with -c flag" + args: + [ + "-c", + "source callstack_helpers.sh; result=$(echo LINENO=${LINENO}; dump_stack); echo $result", + ] + + # Interactive mode tests + - name: "Interactive mode - simple function" + known_failure: true # TODO(LINENO): implement accurate interactive mode tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + f() { dump_stack; } + f + + - name: "Interactive mode - FUNCNAME and BASH_SOURCE" + args: ["-i"] + ignore_stderr: true + stdin: | + f() { echo "FUNCNAME: ${FUNCNAME[@]}"; echo "BASH_SOURCE: ${BASH_SOURCE[@]}"; } + f + + # PROMPT_COMMAND tests + - name: "PROMPT_COMMAND with call stack" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + PROMPT_COMMAND='dump_stack "[PC] "' + echo test + + - name: "PROMPT_COMMAND calling function" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + pc_func() { echo "[PC_FUNC] In function"; dump_stack; } + PROMPT_COMMAND='pc_func' + echo test + + - name: "PROMPT_COMMAND with nested calls" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + inner() { dump_stack; } + outer() { inner; } + PROMPT_COMMAND='outer' + echo test + + # Additional edge cases + - name: "Recursive function call stack" + stdin: | + source callstack_helpers.sh + + recursive_func() { + local depth=$1 + echo "=== Depth $depth ===" + + if [ $depth -gt 0 ]; then + recursive_func $((depth - 1)) + else + dump_stack " " + fi + } + + recursive_func 3 + + - name: "Call stack with process substitution" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + # Process substitution context + cat <(my_func) + + - name: "Call stack across pipe" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + my_func | cat + + - name: "Call stack in subshell" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func (subshell) ===" + dump_stack " " + } + + (my_func) + + - name: "Call stack with background job" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func (background) ===" + dump_stack " " + } + + my_func & + wait + + - name: "BASH_SOURCE indexing" + test_files: + - path: "script.sh" + contents: | + echo "In script.sh" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + echo " BASH_SOURCE[1]: ${BASH_SOURCE[1]}" + + script_func() { + echo "In script_func" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + echo " BASH_SOURCE[1]: ${BASH_SOURCE[1]}" + echo " BASH_SOURCE[2]: ${BASH_SOURCE[2]}" + } + + script_func + stdin: | + echo "Main script" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + source script.sh + + - name: "BASH_SOURCE includes the outer script when sourcing at top level" + test_files: + - path: "source.bash" + contents: | + source test.bash + - path: "test.bash" + contents: | + echo "${BASH_SOURCE[0]}" + echo "${BASH_SOURCE[1]}" + echo "${BASH_SOURCE[*]}" + args: ["./source.bash"] + + - name: "FUNCNAME at top level" + stdin: | + echo "Top level FUNCNAME: '${FUNCNAME}'" + echo "Top level FUNCNAME[@]: '${FUNCNAME[@]}'" + echo "Top level FUNCNAME[*]: '${FUNCNAME[*]}'" + + - name: "Empty BASH_LINENO at top level" + stdin: | + echo "Top level BASH_LINENO[@]: '${BASH_LINENO[@]}'" + echo "Top level BASH_LINENO[*]: '${BASH_LINENO[*]}'" + + - name: "Call stack in trap with function call" + stdin: | + source callstack_helpers.sh + + trap_func() { + echo "=== In trap_func ===" + dump_stack " " + } + + my_func() { + echo "=== In my_func ===" + echo "About to trigger trap" + } + + trap 'trap_func' DEBUG + my_func + + - name: "Call stack: source within eval within function" + test_files: + - path: "script.sh" + contents: | + echo "=== In sourced script ===" + dump_stack " " + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + eval 'source script.sh' + } + + my_func + + - name: "Multiple eval levels" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + eval 'eval "eval \"my_func\""' + + - name: "LINENO persistence in function" + stdin: | + my_func() { + local line1=${LINENO} + local line2=${LINENO} + local line3=${LINENO} + echo "line1: $line1" + echo "line2: $line2" + echo "line3: $line3" + } + + my_func diff --git a/userland/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml b/userland/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml new file mode 100644 index 0000000..1ec67ad --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml @@ -0,0 +1,22 @@ +name: "Complete commands" +cases: + - name: "Multi-command sequence" + stdin: | + echo 1; echo 2; echo 3 + + - name: "Semicolon-terminated sequence" + stdin: | + echo 1; echo 2; + + - name: "Operator followed by an escaped word" + stdin: | + echo pipe |\cat + false ||\echo or + echo one;\echo two + x=1 + cat <<\EOF + quoted $x + EOF + cat <<-\EOF + quoted $x + EOF diff --git a/userland/brush/source/brush-shell/tests/cases/compat/completion.yaml b/userland/brush/source/brush-shell/tests/cases/compat/completion.yaml new file mode 100644 index 0000000..733aeab --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/completion.yaml @@ -0,0 +1,67 @@ +name: "Interactive completion" +incompatible_configs: ["sh"] +cases: + # A completion function that returns 124 restarts completion, which uses the specs as + # the function left them, including the special ones (e.g. a `-D` loader replacing + # itself). + - name: "Restarted completion uses the specs the completion function registered" + min_oracle_version: 5.3 # bash 5.2 doesn't restart with a replaced -D spec + pty: true + ignore_stdout: true + stdin: | + #expect-prompt + _load() { complete -D -W loaded; return 124; }; complete -D -F _load; zz() { printf '<%s>\n' "$@"; } + #send:Enter + #expect-prompt + zz l + #send:Tab + #send:Enter + #expect: + #expect-prompt + #send:Ctrl+D + + # Like bash, a completion function that asks for a restart doesn't leave its `COMPREPLY` + # behind, even when the restart uses a spec that isn't a function. + - name: "Restarted completion discards the COMPREPLY the function set" + pty: true + ignore_stdout: true + stdin: | + #expect-prompt + _f() { COMPREPLY=(leak); complete -W word zz; return 124; }; complete -F _f zz; zz() { :; } + #send:Enter + #expect-prompt + zz w + #send:Tab + #send:Enter + #expect-prompt + [[ -v COMPREPLY ]] && echo "" || echo "" + #send:Enter + #expect: + #expect-prompt + #send:Ctrl+D + + # `compopt` with no names or options shows the options of the completion in progress, + # as `compopt` in the completion function left them, named by the spec in use: the one + # found by the command's file name, or the default spec. + - name: "compopt with no options in a completion function prints the completion's options" + min_oracle_version: "5.3" # the fullquote option was added in 5.3 + pty: true + ignore_stdout: true + stdin: | + #expect-prompt + _f() { { compopt; compopt -o nospace +o filenames; compopt; compopt zz; } >>out 2>&1; COMPREPLY=(xy); }; complete -o filenames -F _f zz; complete -D -F _f; zz() { :; }; yy() { :; } + #send:Enter + #expect-prompt + zz x + #send:Tab + #send:Enter + #expect-prompt + yy x + #send:Tab + #send:Enter + #expect-prompt + ./zz x + #send:Tab + #send:Enter + #expect-prompt + #send:Ctrl+D diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml new file mode 100644 index 0000000..455fb7b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml @@ -0,0 +1,16 @@ +name: "Compound commands: arithmetic" +cases: + - name: "Basic arithmetic statements" + stdin: | + ((0 == 0)) && echo "0 == 0" + ((0 != 0)) && echo "0 != 0" + + - name: "Arithmetic statements with parens" + stdin: | + (( (0) )) && echo "0" + (( (1) )) && echo "1" + + - name: "Arithmetic statements with parens and operators" + stdin: | + (( (0) == 0 )) && echo "0 == 0" + (( (1) != 0 )) && echo "1 != 0" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml new file mode 100644 index 0000000..ea4867a --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml @@ -0,0 +1,204 @@ +name: "Compound commands: arithmetic for" +cases: + - name: "Single-line arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do echo $i; done + echo "Result: $?" + + - name: "Arithmetic for loop without separator" + stdin: | + for ((i = 0; i < 5; i++)) do echo $i; done + echo "Result: $?" + + - name: "Arithmetic for loop with empty condition" + stdin: | + for (( ; ; )); do + echo "In loop; status: $?" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;;" + stdin: | + for ((;;)); do + echo "In loop; status: $?" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with initializer and ;;" + stdin: | + for ((i = 0;;)); do + echo "i=$i" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;; and updater" + stdin: | + for ((;;i++)); do + echo "i=$i" + if ((i >= 2)); then break; fi + done + echo "Result: $?" + + - name: "Arithmetic for loop with initializer, ;; and updater" + stdin: | + for ((i = 0;;i++)); do + echo "i=$i" + if ((i >= 2)); then break; fi + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;; and spaces around it" + stdin: | + for (( i = 0 ;; i++ )); do + echo "i=$i" + if ((i >= 2)); then break; fi + done + echo "Result: $?" + + - name: "Arithmetic for loop with parenthesized parts and ;;" + stdin: | + for (((i = 0);;(i++))); do + echo "i=$i" + if ((i >= 2)); then break; fi + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;; and do on the next line" + stdin: | + for ((;;)) + do + echo "In loop" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;; and alternate syntax" + stdin: | + for ((;;)) { echo "In loop"; break; } + echo "Result: $?" + + - name: "Arithmetic for loop with ;; and a newline before the closing parens" + stdin: | + for ((i = 0;; + )); do + echo "i=$i" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;; inside a case item" + stdin: | + case x in + x) for ((i = 0;;)); do echo "i=$i"; break; done;; + *) echo "no match";; + esac + echo "Result: $?" + + - name: "Arithmetic for loop with ;; right after the condition" + ignore_stderr: true + stdin: | + for ((i = 0; i < 3;;)); do echo "Should not run"; break; done + echo "After syntax error" + + - name: "Arithmetic for loop with three semicolons" + ignore_stderr: true + stdin: | + for ((;;;)); do echo "Should not run"; break; done + echo "After syntax error" + + - name: "Arithmetic for loop with four semicolons" + ignore_stderr: true + stdin: | + for ((;;;;)); do echo "Should not run"; break; done + echo "After syntax error" + + - name: "Arithmetic for loop with ;; in the updater position" + ignore_stderr: true + stdin: | + for ((i = 0; i < 3; ;;)); do echo "Should not run"; break; done + echo "After syntax error" + + - name: "Break in arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do + echo $i + break + done + echo "Result: $?" + + - name: "Continue in arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do + continue + echo "Should not print" + done + echo "Result: $?" + + - name: "Arithmetic for with break in condition inside outer loop" + stdin: | + for j in 1 2 3; do + echo "Outer iteration $j" + for ((i = 0; break; i++)); do + echo "Inner body" + done + echo "After inner for" + done + echo "After outer loop" + + - name: "Arithmetic for with continue in condition inside outer loop" + stdin: | + for j in 1 2 3; do + echo "Outer iteration $j" + for ((i = 0; continue; i++)); do + echo "Inner body" + done + echo "After inner for" + done + echo "After outer loop" + + - name: "Arithmetic for with return in body" + stdin: | + f() { + for ((i = 0; i < 5; i++)); do + echo "Iteration $i" + if [ $i -eq 2 ]; then + return 42 + fi + done + echo "After for" + } + f + echo "Exit code: $?" + + - name: "Arithmetic for with exit in body" + stdin: | + for ((i = 0; i < 5; i++)); do + echo "Iteration $i" + if [ $i -eq 2 ]; then + exit 42 + fi + done + echo "After for" + + - name: "Arithmetic for with alternate syntax" + stdin: | + for ((i = 0; i < 5; i++)) { + echo "Iteration $i" + } + + - name: "Arithmetic for with alternate syntax (one line)" + stdin: | + for ((i = 0; i < 5; i++)) { echo "Iteration $i"; } + + - name: "Arithmetic for loop with nameref" + known_failure: true + stdin: | + target=0 + declare -n ref=target + for ((ref=1; ref<=3; ref++)); do + echo "ref=$ref target=$target" + done + echo "final target=$target" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml new file mode 100644 index 0000000..6373560 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml @@ -0,0 +1,5 @@ +name: "Compound commands: brace" +cases: + - name: "Brace command" + stdin: | + { echo 'hi'; } diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml new file mode 100644 index 0000000..0857e4c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml @@ -0,0 +1,185 @@ +name: "Compound commands: case" +cases: + - name: "Basic case statement with double semi" + test_files: + - path: "script.sh" + contents: | + case x in + x) echo hi;; + esac + args: ["./script.sh"] + + - name: "Basic catch-all pattern against multi-line input" + stdin: | + case "$(echo hi; echo there)" in + "not-it") echo "did not work" ;; + *) echo "caught it" ;; + esac + + - name: "One-line case statement with double semi" + stdin: | + case x in x) echo "hi";; esac + + - name: "Interesting characters in cases" + stdin: | + case "{" in + {) echo "curly brace" ;; + *) echo "unhandled case" ;; + esac + + - name: "Case with case insensitive pattern" + stdin: | + shopt -s nocasematch + case "A" in + a) echo "matched" ;; + *) echo "did not match" ;; + esac + + - name: "Interesting patterns in cases" + stdin: | + for word in "-a" "!b" "*c" "(d" "{e" ":f" "'g"; do + case "${word}" in + \!*) echo "starts with exclamation" ;; + -*) echo "starts with hyphen" ;; + \**) echo "starts with asterisk" ;; + \(*) echo "starts with open parenthesis" ;; + \{*) echo "starts with open curly brace" ;; + :*) echo "starts with colon" ;; + \'*) echo "starts with single quote" ;; + *) echo "unhandled case" ;; + esac + done + + - name: "Empty case" + stdin: | + myfunc() { + case abc in + *b*) ;; + *) return 33;; + esac + + echo "Dropped out" + } + + myfunc + + - name: "Case with non-dsemi" + stdin: | + case "b" in + a) echo "a";; + b) echo "b" + esac + + - name: "Case with fall-through" + stdin: | + case "b" in + a) echo "a";; + b) echo "b";& + c) echo "c";; + d) echo "d";; + esac + + - name: "Case with resuming switch" + stdin: | + case "b" in + a) echo "a";; + b) echo "b";;& + c) echo "c";; + *) echo "*";; + d) echo "d";; + esac + + - name: "Case status values" + stdin: | + function yield() { + return $1 + } + + case "a" in + x) yield 10;; + a) yield 11;; + b) yield 12;; + esac + echo "1: $?" + + case "a" in + b) yield 10;; + c) yield 11;; + esac + echo "2: $?" + + case "a" in + a) yield 10;& + b) yield 11;; + c) yield 12;; + esac + echo "3: $?" + + case "a" in + a) yield 10;;& + *) yield 11;; + x) yield 12;; + esac + echo "4: $?" + + - name: "Case with reserved words" + stdin: | + case case in + case) echo "saw case";; + esac + + - name: "Case with return in function" + stdin: | + f() { + case "a" in + a) echo "matched a"; return 42;; + b) echo "matched b";; + esac + echo "After case" + } + f + echo "Exit code: $?" + + - name: "Case with exit" + stdin: | + case "a" in + a) echo "matched a"; exit 42;; + b) echo "matched b";; + esac + echo "After case" + + - name: "Case with break in loop" + stdin: | + for i in 1 2 3; do + case "$i" in + 1) echo "one";; + 2) echo "two"; break;; + 3) echo "three";; + esac + echo "After case iteration $i" + done + echo "After loop" + + - name: "Case with continue in loop" + stdin: | + for i in 1 2 3; do + case "$i" in + 1) echo "one";; + 2) echo "two"; continue;; + 3) echo "three";; + esac + echo "After case iteration $i" + done + echo "After loop" + + - name: "Case with return and fall-through" + stdin: | + f() { + case "a" in + a) echo "matched a"; return 42;& + b) echo "matched b";; + esac + echo "After case" + } + f + echo "Exit code: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml new file mode 100644 index 0000000..c7eb754 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml @@ -0,0 +1,865 @@ +name: "Compound commands: coproc" + +# NOTE: Coproc tests must defend against TWO race conditions caused by bash's +# SIGCHLD handler: +# +# 1. The handler clears the COPROC array when the coproc child exits, so +# ${COPROC[0]} can become empty before the parent captures it. +# 2. The handler ALSO closes the underlying pipe FDs, so even after capturing +# the integer FD, reading from it returns EOF immediately. +# +# We defend against both by: +# (a) Having the coproc body block on `read -r _ < _sync` first, so it can't +# exit before the parent dups the FD (no SIGCHLD can fire yet). +# (b) Using `exec 9<&${COPROC[0]}` to dup the pipe to a fresh FD that bash +# does NOT manage as part of the coproc, so it survives SIGCHLD cleanup. +# (c) Then `echo x > _sync` releases the coproc to do its real work. +# +# We use explicit FD 9 (rather than `{fd}<&...` auto-assignment) for portability. + +cases: + # + # Basic invocation + # + - name: "Unnamed coproc with brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "hello from coproc"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Unnamed coproc with subshell" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc ( read -r _ < _sync; echo "hello from subshell coproc" ) + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc with brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYCOPROC { read -r _ < _sync; echo "named coproc"; } + exec 9<&${MYCOPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc with subshell" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYSUB ( read -r _ < _sync; echo "named subshell coproc" ) + exec 9<&${MYSUB[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with simple command" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc cat + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "input line" >&$wfd + exec {wfd}>&- + read -r line <&$rfd + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Coproc with multi-statement brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "line1" + echo "line2" + echo "line3" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # COPROC and COPROC_PID variable validation via functional use + # + - name: "COPROC array read FD is functional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "via read fd"; } + exec 9<&${COPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Named coproc FDs are functional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYPROC { read -r _ < _sync; echo "named fd test"; } + exec 9<&${MYPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + # + # Reading from coproc stdout + # + - name: "Read coproc output with cat" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "output from coproc"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Read coproc output with read builtin" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "readline test"; } + exec 9<&${COPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Read multiple lines from coproc" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "first" + echo "second" + echo "third" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Read multiple lines with read builtin" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "line1" + echo "line2" + } + exec 9<&${COPROC[0]} + echo x > _sync + { + read -r first + read -r second + } <&9 + echo "first=$first" + echo "second=$second" + wait + args: ["./test.sh"] + + - name: "Named coproc read" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc READER { read -r _ < _sync; echo "from named"; } + exec 9<&${READER[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Writing to coproc stdin + # + - name: "Write to coproc stdin and read back" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc cat + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "hello coproc" >&$wfd + exec {wfd}>&- + read -r line <&$rfd + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Bidirectional communication" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc { + while read -r line; do + echo "processed: $line" + done + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "msg1" >&$wfd + echo "msg2" >&$wfd + exec {wfd}>&- + { + read -r out1 + read -r out2 + } <&$rfd + echo "$out1" + echo "$out2" + wait + args: ["./test.sh"] + + - name: "Named coproc bidirectional" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc BIDIR { + while read -r line; do + echo "echo: $line" + done + } + rfd=${BIDIR[0]} + wfd=${BIDIR[1]} + echo "test1" >&$wfd + echo "test2" >&$wfd + exec {wfd}>&- + { + read -r out1 + read -r out2 + } <&$rfd + echo "$out1" + echo "$out2" + wait + args: ["./test.sh"] + + - name: "Close write FD to signal EOF" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc { + count=0 + while read -r line; do + count=$((count + 1)) + done + echo "read $count lines" + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "a" >&$wfd + echo "b" >&$wfd + echo "c" >&$wfd + exec {wfd}>&- + read -r result <&$rfd + echo "$result" + wait + args: ["./test.sh"] + + - name: "Bidirectional with cat for EOF" + skip: true # TODO(coproc): hangs - stdout pipe not fully closed after coproc exits + test_files: + - path: "test.sh" + contents: | + coproc { + while read -r line; do + echo "processed: $line" + done + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "msg1" >&$wfd + echo "msg2" >&$wfd + exec {wfd}>&- + cat <&$rfd + wait + echo "DONE" + args: ["./test.sh"] + + # + # Coproc with compound command bodies + # + - name: "Coproc running a for loop" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + for i in 1 2 3; do + echo "iteration $i" + done + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc running a while loop" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + n=1 + while [ $n -le 3 ]; do + echo "count $n" + n=$((n + 1)) + done + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with conditionals" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + if true; then + echo "true branch" + else + echo "false branch" + fi + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with case statement" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + val="hello" + case $val in + hello) echo "matched hello";; + *) echo "no match";; + esac + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body with pipeline" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo -e "banana\napple\ncherry" | sort + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body with command substitution" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + result=$(echo "hello" | tr a-z A-Z) + echo "$result" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with arithmetic operations" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + a=5 + b=3 + echo $((a + b)) + echo $((a * b)) + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Exit status and lifecycle + # + - name: "Coproc statement returns 0" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo done; } + status=$? + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "exit status: $status" + args: ["./test.sh"] + + - name: "Coproc with failing body returns 0 immediately" + test_files: + - path: "test.sh" + contents: | + coproc { exit 42; } + echo "coproc start status: $?" + wait + args: ["./test.sh"] + + - name: "Wait with no args waits for coproc" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "coproc done"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "after wait" + args: ["./test.sh"] + + # + # Error cases + # + - name: "Invalid coproc name" + ignore_stderr: true + test_files: + - path: "test.sh" + contents: | + coproc 123bad { echo x; } + echo "exit: $?" + args: ["./test.sh"] + + # + # Coproc in different contexts + # + - name: "Coproc inside a function" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + start_coproc() { + coproc { read -r _ < _sync; echo "from function coproc"; } + } + start_coproc + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inside a conditional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + if true; then + coproc { read -r _ < _sync; echo "conditional coproc"; } + fi + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inside a loop body" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + for i in 1; do + coproc { read -r _ < _sync; echo "loop coproc $i"; } + done + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc inside a function" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + f() { + coproc FUNCPROC { read -r _ < _sync; echo "named in func"; } + } + f + exec 9<&${FUNCPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Multiple coprocs and variable lifecycle + # + - name: "Second unnamed coproc replaces first" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "first"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + exec 9<&- + wait + coproc { read -r _ < _sync; echo "second"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Sequential named coprocs with different names" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc PROC1 { read -r _ < _sync; echo "proc1"; } + exec 9<&${PROC1[0]} + echo x > _sync + cat <&9 + exec 9<&- + wait + coproc PROC2 { read -r _ < _sync; echo "proc2"; } + exec 9<&${PROC2[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Environment and variable scope + # + - name: "Coproc inherits parent environment" + test_files: + - path: "test.sh" + contents: | + export MY_VAR="inherited" + mkfifo _sync + coproc { read -r _ < _sync; echo "MY_VAR=$MY_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inherits non-exported variables" + test_files: + - path: "test.sh" + contents: | + LOCAL_VAR="visible" + mkfifo _sync + coproc { read -r _ < _sync; echo "LOCAL_VAR=$LOCAL_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc cannot modify parent variables" + test_files: + - path: "test.sh" + contents: | + PARENT_VAR="original" + mkfifo _sync + coproc { read -r _ < _sync; PARENT_VAR="modified"; echo "in coproc: $PARENT_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "in parent: $PARENT_VAR" + args: ["./test.sh"] + + - name: "Coproc inherits functions" + test_files: + - path: "test.sh" + contents: | + greet() { echo "hello $1"; } + export -f greet + mkfifo _sync + coproc { read -r _ < _sync; greet "world"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inherits aliases" + test_files: + - path: "test.sh" + contents: | + shopt -s expand_aliases + alias myecho='echo ALIASED:' + mkfifo _sync + coproc { read -r _ < _sync; myecho "test"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Coproc with redirections in body + # + - name: "Coproc body with stderr redirection" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "stdout msg" + echo "stderr msg" >&2 + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body writing to a file" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "file content" > /tmp/coproc_test_$$ + echo "wrote file" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + cat /tmp/coproc_test_$$ + rm -f /tmp/coproc_test_$$ + args: ["./test.sh"] + + # + # Coproc with various data patterns + # + - name: "Coproc producing empty output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; true; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "after empty coproc" + args: ["./test.sh"] + + - name: "Coproc producing multiple lines of output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + for i in 1 2 3 4 5 6 7 8 9 10; do + echo "line $i" + done + } + exec 9<&${COPROC[0]} + echo x > _sync + count=0 + while read -r line; do + count=$((count + 1)) + done <&9 + echo "read $count lines" + wait + args: ["./test.sh"] + + - name: "Coproc with multiline string output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + cat <<'HEREDOC' + first line + second line + third line + HEREDOC + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Coproc output transformation + # + - name: "Transform coproc output to uppercase" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "hello" + echo "world" + } + exec 9<&${COPROC[0]} + echo x > _sync + while read -r line; do + echo "$line" | tr a-z A-Z + done <&9 + wait + args: ["./test.sh"] + + - name: "Coproc output filtered with case" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "apple" + echo "banana" + echo "avocado" + echo "cherry" + } + exec 9<&${COPROC[0]} + echo x > _sync + while read -r line; do + case $line in + a*) echo "starts with a: $line";; + esac + done <&9 + wait + args: ["./test.sh"] + + # + # Waiting for a coprocess by its PID. The PID is taken as soon as the coprocess starts, and the + # coprocess ends only once told to (by a file): bash drops its variables once it has reaped it. + # + - name: "Wait for specific coproc PID" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc { echo "done"; until [ -e go ]; do :; done; } + pid=$COPROC_PID + read -r line <&${COPROC[0]} + echo "$line" + touch go + wait "$pid" + echo "wait status: $?" + args: ["./test.sh"] + + - name: "Coproc with failing body - wait captures exit status" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc { until [ -e go ]; do :; done; exit 42; } + pid=$COPROC_PID + touch go + wait "$pid" + echo "exit status: $?" + args: ["./test.sh"] + + - name: "Wait for named coproc PID" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc MYPROC { echo "done"; until [ -e go ]; do :; done; } + pid=$MYPROC_PID + read -r line <&${MYPROC[0]} + echo "$line" + touch go + wait "$pid" + echo "status: $?" + args: ["./test.sh"] + + # The PID is taken first: bash drops COPROC_PID once it has reaped the coprocess, which the + # kill may bring about before `wait` expands it. + - name: "COPROC_PID names the coprocess for kill and wait" + skip: true # TODO(signals): hangs: a wait for a job's program never returns + stdin: | + coproc cat + pid=$COPROC_PID + kill $pid + wait $pid + echo "status: $?" + + - name: "A coprocess's variables are unset once it's waited for" + known_failure: true # TODO(jobs): brush never unsets a finished coprocess's variables; bash does when it reaps it (so `wait $COPROC_PID` after it ends races in bash) + stdin: | + coproc MY { :; } + pid=$MY_PID + wait $pid + echo "status: $? pid: [${MY_PID-unset}] fds: [${MY[@]-unset}]" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml new file mode 100644 index 0000000..3375ef8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml @@ -0,0 +1,286 @@ +name: "Compound commands: for" +cases: + - name: "Single-line for loop" + stdin: | + for f in 1 2 3; do echo $f; done + + - name: "Multi-line for loop" + stdin: | + for f in 1 2 3; do + echo $f + done + + - name: "Empty for loop" + stdin: | + for f in; do echo $f; done + + - name: "for loop without in" + stdin: | + set -- a b c + + echo "Loop 1" + for f; do echo $f; done + + echo "Loop 2: no semicolon" + for f do echo $f; done + + - name: "for loop without in but spaces" + stdin: | + set -- a "b c" d + + echo "Loop 1" + for f; do echo $f; done + + echo "Loop 2: no semicolon" + for f do echo $f; done + + - name: "Break in for loop" + stdin: | + for f in 1 2 3; do + echo $f + break + done + + - name: "Break in nested for loop" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + true && break + done + echo "Left inner loop" + done + + - name: "Break 1 in nested for loops" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + break 1 + done + done + + - name: "Break 2 in nested for loops" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + break 2 + done + done + + - name: "Break out of nested for/while loops" + stdin: | + for f in 1 2 3; do + while true; do + echo "f=$f" + break 2 + done + done + + - name: "Continue in for loop" + stdin: | + for f in 1 2 3; do + echo $f + continue + echo $f + done + + - name: "Continue with bad N" + known_failure: true # Fails differently + stdin: | + for f in 1 2 3; do + echo $f + continue 0 + echo result: $? + done + + - name: "Continue in nested for loop" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with explicit N=1" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 1 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with explicit N=2" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 2 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with too large N" + known_failure: true + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 3 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Multi-line for loop in a script" + test_files: + - path: "script.sh" + contents: | + for f in 1 2 3; do + echo $f + done + args: ["./script.sh"] + + - name: "For loop piped" + stdin: | + for f in ab ac bd ef; do echo $f; done | grep b + + - name: "For loop output redirection" + stdin: | + for f in a b c; do echo $f; done > ./out.txt + + - name: "For reserved word handling" + stdin: | + for for in for; do echo $for; done + + - name: "For with return in body" + stdin: | + f() { + for i in 1 2 3; do + echo "Iteration $i" + if [ "$i" = "2" ]; then + return 42 + fi + done + echo "After for" + } + f + echo "Exit code: $?" + + - name: "For with exit in body" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + if [ "$i" = "2" ]; then + exit 42 + fi + done + echo "After for" + + - name: "For loop with nameref variable" + stdin: | + target="" + declare -n ref=target + for ref in a b c; do + echo "loop: target=$target" + done + echo "final: target=$target" + + - name: "continue with a non-numeric argument aborts the script" + known_failure: true # TODO(builtins): a usage error skips continue, so the loop body keeps running + ignore_stderr: true + stdin: | + for i in 1 2; do continue abc; echo "body $i"; done + echo "after loop: $?" + + - name: "break with a count of zero" + known_failure: true # TODO(loops): break 0 reports the count and fails with 1, but doesn't leave the loop + ignore_stderr: true + stdin: | + for i in 1 2; do break 0; echo "body $i"; done + echo "after loop: $?" + + - name: "break and continue with a count of zero leave every loop" + known_failure: true # TODO(loops): a count of zero doesn't leave every loop + ignore_stderr: true + stdin: | + for i in 1 2; do for j in a b; do break 0; done; echo "outer $i"; done + echo "after break: $?" + for i in 1 2; do for j in a b; do continue -1; done; echo "outer $i"; done + echo "after continue: $?" + + - name: "break and continue outside a loop" + known_failure: true # TODO(loops): outside a loop, they end the line (or fail in a function), where bash reports it and goes on + ignore_stderr: true + stdin: | + break; echo "after break: $?" + continue; echo "after continue: $?" + + - name: "break and continue check for a loop before their options" + known_failure: true # TODO(loops): the loop check doesn't come before option parsing + ignore_stderr: true + stdin: | + f() { break -Z; echo "after break -Z: $?"; continue -Z; echo "after continue -Z: $?"; } + f + + - name: "break and continue act only on the loops of the current function" + known_failure: true # TODO(loops): a function's body doesn't start outside its caller's loops + ignore_stderr: true + stdin: | + f() { break; echo "f went on: $?"; } + for i in 1 2; do f; echo "loop body $i"; done + g() { for j in a; do break 2; done; echo "g went on"; } + for i in 1 2; do g; echo "loop body $i"; done + for i in 1; do ( break; echo "subshell went on" ); echo "after subshell: $?"; done + + - name: "break and continue in a substitution act on the enclosing loops" + ignore_stderr: true + stdin: | + for i in 1 2; do x=$(break; echo "not captured"); echo "captured [$x]"; done + for i in 1; do x=$(for j in 1; do break 2; done; echo "not captured"); echo "[$x]"; done + for i in 1; do cat <(break; echo "not read"); echo "after process substitution"; done + + - name: "break and continue past the outermost loop" + known_failure: true # TODO(loops): a count past the outermost loop doesn't stop there + stdin: | + for i in 1 2; do for j in a b; do break 5; done; echo "not reached"; done + echo "after break: $?" + for i in 1 2; do for j in a b; do echo "$i$j"; continue 9; done; done + echo "after continue: $?" + + - name: "break and continue read their operand as bash does" + known_failure: true # TODO(loops): break and continue don't read their operand as bash does + ignore_stderr: true + stdin: | + for i in 1 2; do break -- 1; echo "not reached"; done + echo "after --: $?" + for i in 1 2; do break " 1 "; echo "not reached"; done + echo "after blanks: $?" + for i in 1; do break --help >/dev/null; echo "help: $?"; done + + - name: "break and continue clamp their count to the loop depth" + known_failure: true # TODO(loops): the shell doesn't track its loop depth, so a count past it leaves every loop, the function, and the script + stdin: | + for i in 1 2; do + for j in 1 2; do break 99; echo "inner $i$j"; done + echo "outer $i" + done + echo "after: $?" + f() { for k in 1 2; do continue 99; echo "in f $k"; done; echo "f end"; } + for i in 1 2; do f; echo "loop $i"; done + + - name: "break and continue report a negative count and fail with status 1" + stdin: | + for i in 1; do break -1 2>err.txt; done; echo "break -1: $?" + [[ -s err.txt ]] && echo "break reported" + for i in 1; do continue -1 2>err.txt; done; echo "continue -1: $?" + [[ -s err.txt ]] && echo "continue reported" + rm err.txt diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml new file mode 100644 index 0000000..3fb7447 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml @@ -0,0 +1,99 @@ +name: "Compound commands: if" +cases: + - name: "Basic if" + stdin: | + if false; then echo 1; else echo 2; fi + if false; then echo 3; elif false; then echo 4; else echo 5; fi + if true; then echo 6; else echo 7; fi + if false; then echo 8; elif true; then echo 10; else echo 11; fi + + - name: "Multi-line if" + test_files: + - path: "script.sh" + contents: | + if false; then + echo 1 + else + echo 2 + fi + + if false; then + echo 3 + elif false; then + echo 4 + else + echo 5 + fi + + if true; then + echo 6 + else + echo 7 + fi + + if false; then + echo 8 + elif true; then + echo 10 + else + echo 11 + fi + args: ["./script.sh"] + + - name: "If with return in condition" + stdin: | + f() { + if return 42; then + echo "Then branch" + else + echo "Else branch" + fi + echo "After if" + } + f + echo "Exit code: $?" + + - name: "If with exit in condition" + stdin: | + if exit 42; then + echo "Then branch" + else + echo "Else branch" + fi + echo "After if" + + - name: "If with return in elif condition" + stdin: | + f() { + if false; then + echo "Then branch" + elif return 42; then + echo "Elif then branch" + else + echo "Else branch" + fi + echo "After if" + } + f + echo "Exit code: $?" + + - name: "If with break in condition inside loop" + stdin: | + for i in 1 2 3; do + if break; then + echo "Then branch" + fi + echo "After if" + done + echo "After loop" + + - name: "If with continue in condition inside loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + if continue; then + echo "Then branch" + fi + echo "After if" + done + echo "After loop" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml new file mode 100644 index 0000000..bab2e4c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml @@ -0,0 +1,10 @@ +name: "Select statement" +cases: + - name: "basic" + known_failure: true # TODO(select): select statement not implemented + stdin: | + echo "1" | select opt in a b c; do + echo "selected: $opt" + break + done + echo "after select" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml new file mode 100644 index 0000000..86a261b --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml @@ -0,0 +1,54 @@ +name: "Compound commands: subshell" +cases: + - name: "Basic subshell usage" + stdin: | + (echo hi) + + - name: "Subshells in sequence" + ignore_stderr: true + stdin: | + (echo hi)(echo there) + + - name: "Subshell env usage" + stdin: | + (subshell_var=value && echo "subshell_var: ${subshell_var}") + echo "subshell_var: ${subshell_var}" + + - name: "Subshell output redirection" + stdin: | + (echo Hello; echo world) >out.txt + echo "Dumping out.txt..." + cat out.txt + + - name: "Piped subshell usage" + stdin: | + (echo hi) | wc -l + + - name: "Breaks in subshell" + stdin: | + for i in 1 2 3; do + echo $i + (for i in 1 2 3; do break 2; done) + done + + - name: "Exec in subshell" + stdin: | + echo "Before subshell" + (exec cat <<<"Hello from exec in subshell") + echo "After subshell" + + - name: "Nested subshells on one line" + stdin: | + ( (echo hi) ) + x=$( ( (echo hi) ) ) + echo "x=$x" + + - name: "A subshell starts outside any loop" + known_failure: true # TODO(subshell): a `break` in a subshell inside a loop breaks the parent's loop, not just the subshell's + ignore_stderr: true + stdin: | + for i in 1 2; do + (break; echo "survived $i: $?") + (continue 2; echo "continued $i: $?") + done + echo done diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml new file mode 100644 index 0000000..222b4ad --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml @@ -0,0 +1,101 @@ +name: "Compound commands: until" +cases: + - name: "Single-line until loop" + stdin: | + until true; do echo 'In loop'; done + + - name: "Arithmetic in until loop" + incompatible_configs: ["sh"] + stdin: | + i=5 + until ((i == 0)); do echo $i; i=$((i - 1)); done + + - name: "Until with return in condition" + stdin: | + f() { + until return 42; do + echo "Body" + done + echo "After until" + } + f + echo "Exit code: $?" + + - name: "Until with exit in condition" + stdin: | + until exit 42; do + echo "Body" + done + echo "After until" + + - name: "Until with break in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until break; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with break 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until break 2; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with continue 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until continue 2; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with continue in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until continue; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with return in body" + stdin: | + f() { + i=0 + until [ $i -ge 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + return 42 + fi + i=$((i + 1)) + done + echo "After until" + } + f + echo "Exit code: $?" + + - name: "Until with exit in body" + stdin: | + i=0 + until [ $i -ge 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + exit 42 + fi + i=$((i + 1)) + done + echo "After until" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml new file mode 100644 index 0000000..d7ec69f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml @@ -0,0 +1,127 @@ +name: "Compound commands: while" +cases: + - name: "Single-line while loop" + stdin: | + while false; do echo 'In loop'; done + + - name: "break in while loop" + stdin: | + while true; do + echo 'In loop' + break + done + + - name: "break 2 in nested loops" + stdin: | + while false; do + echo 'Starting inner loop' + while true; do + echo 'In loop' + break 2 + done + echo 'Finished inner loop' + done + + - name: "Arithmetic in while loop" + stdin: | + i=5 + while ((i > 0)); do echo $i; i=$((i - 1)); done + + - name: "Alternative arithmetic in while loop" + stdin: | + c=0 + limit=4 + while [ $c -lt $limit ]; do + case "$c" in + 0) + echo "0" + ;; + 1) + echo "1" + ;; + *) + break + ;; + esac + ((c++)) + done + echo "Done" + + - name: "While with return in condition" + stdin: | + f() { + while return 42; do + echo "Body" + done + echo "After while" + } + f + echo "Exit code: $?" + + - name: "While with exit in condition" + stdin: | + while exit 42; do + echo "Body" + done + echo "After while" + + - name: "While with break in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while break; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with break 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while break 2; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with continue 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while continue 2; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with return in body" + stdin: | + f() { + i=0 + while [ $i -lt 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + return 42 + fi + i=$((i + 1)) + done + echo "After while" + } + f + echo "Exit code: $?" + + - name: "While with exit in body" + stdin: | + i=0 + while [ $i -lt 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + exit 42 + fi + i=$((i + 1)) + done + echo "After while" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/errors.yaml b/userland/brush/source/brush-shell/tests/cases/compat/errors.yaml new file mode 100644 index 0000000..54e9ed5 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/errors.yaml @@ -0,0 +1,744 @@ +name: "Error handling" +cases: + - name: "Fatal error (interactive/subshell)" + ignore_stderr: true + args: ["-i"] + stdin: | + echo "before expansion" + (echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (non-interactive/subshell)" + ignore_stderr: true + stdin: | + echo "before expansion" + (echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (interactive/command substitution)" + ignore_stderr: true + args: ["-i"] + stdin: | + echo "before expansion" + x=$(echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (non-interactive/command substitution)" + ignore_stderr: true + stdin: | + echo "before expansion" + x=$(echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (interactive/sourced file)" + ignore_stderr: true + args: ["-i"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + stdin: | + source error.sh + echo "after sourcing script with error" + + - name: "Fatal error (non-interactive/sourced file)" + ignore_stderr: true + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + stdin: | + source error.sh + echo "after sourcing script with error" + + - name: "Fatal error (interactive/executed script)" + ignore_stderr: true + args: ["-i", "error.sh"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Fatal error (non-interactive/executed script)" + ignore_stderr: true + args: ["error.sh"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Shell language syntax error (command line)" + incompatible_os: ["azurelinux"] # Needs investigation + ignore_stderr: true + args: + - "-c" + - | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Shell language syntax error (non-interactive)" + incompatible_os: ["azurelinux"] # Needs investigation + ignore_stderr: true + stdin: | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Shell language syntax error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Special built-in error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Special built-in error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Special built-in error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Regular utility error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Regular utility error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Regular utility error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Special built-in redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Special built-in redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Special built-in redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Compound command redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Compound command redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Compound command redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Function redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Function redirection error (non-interactive)" + ignore_stderr: true + stdin: | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Function redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Utility redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Utility redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Utility redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Variable assignment error (command line)" + ignore_stderr: true + args: + - "-c" + - | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "Variable assignment error (non-interactive)" + ignore_stderr: true + stdin: | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "A special builtin named by an expansion keeps its redirection error fatal in POSIX mode" + known_failure: true # TODO(posix): a special builtin's failed redirection doesn't end the shell when an expansion names the command + ignore_stderr: true + stdin: | + set -o posix + cmd=: + $cmd > /nonexistent-dir/file + echo "not reached" + + - name: "local of a readonly variable fails" + known_failure: true # TODO(local): local shadows a readonly variable rather than failing with status 1 + ignore_stderr: true + stdin: | + readonly r=1 + f() { local r=2; echo "in f: $r"; } + f + echo "f: $?" + + - name: "A special builtin's misuse in the EXIT trap sets the shell's status in POSIX mode" + known_failure: true # TODO(posix): the EXIT trap's misuse leaves the shell's status as it was + ignore_stderr: true + stdin: | + (set -o posix; trap "set -o bogus" EXIT; echo "in subshell") + echo "after subshell: $?" + + - name: "Variable assignment error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "Expansion error (command line)" + known_failure: true # Needs investigation + ignore_stderr: true + args: + - "-c" + - | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Expansion error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before expansion" + echo "${UNSET_REQUIRED:?missing parameter}" + echo "after expansion" + + - name: "Expansion error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before expansion" + echo "${UNSET_REQUIRED:?missing parameter}" + echo "after expansion" + + - name: "Command not found (command line)" + ignore_stderr: true + args: + - "-c" + - | + missing_command + echo "after missing command (status $?)" + + - name: "Command not found (non-interactive)" + ignore_stderr: true + stdin: | + missing_command + echo "after missing command (status $?)" + + - name: "Command not found (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + missing_command + echo "after missing command (status $?)" + + - name: "Unrecoverable read error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Unrecoverable read error (non-interactive)" + known_failure: true + ignore_stderr: true + stdin: | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Unrecoverable read error (interactive)" + known_failure: true + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Failing special built-in (non-POSIX mode, non-interactive)" + ignore_stderr: true + stdin: | + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (non-POSIX mode, interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (POSIX mode, non-interactive)" + ignore_stderr: true + stdin: | + set -o posix + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (POSIX mode, interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + set -o posix + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "A special builtin's misuse doesn't end a POSIX shell when its status is tested, and an assignment error does" + ignore_stderr: true + stdin: | + set -o posix + (set -o bogus || echo "ran ||"; echo "survived set -o bogus ||") + (if shift x; then :; fi; echo "survived if shift x") + (readonly r=1; readonly r=2 || echo "ran ||"; echo "survived readonly r=2 ||") + (. /nonexistent-file || echo "ran ||"; echo "survived . ||") + + - name: "Prefix assignment to a readonly variable (POSIX mode, non-interactive)" + known_failure: true # in POSIX mode, bash doesn't run the command; brush does + ignore_stderr: true + stdin: | + set -o posix + readonly R=1 + R=2 printf 'ran\n' + echo "after" + + - name: "errexit with failing command (non-interactive)" + stdin: | + set -e + false + echo "after false" + + - name: "errexit with failing command (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + set -e + echo "before false" + false + echo "after false" + + - name: "errexit with unbound variable access (interactive)" + args: ["-i", "-e", "-u", "-o", "pipefail"] + ignore_stderr: true + stdin: | + echo "before false" + false + echo "after false" + + # Only the presence of escape sequences is compared, not the (differing) wording. The harness + # passes --disable-color by default, which would hide the behavior under test. + - name: "Error messages are uncolored when stderr is not a terminal" + known_failure: true # brush colors its error messages unless given --disable-color + removed_default_args: ["--disable-color"] + stdin: | + out=$(missing_command 2>&1) + [[ $out == *$'\e'* ]] && echo "command not found: colored" || echo "command not found: plain" + out=$(cd /no/such/dir 2>&1) + [[ $out == *$'\e'* ]] && echo "builtin error: colored" || echo "builtin error: plain" + + - name: "Expansion error in a redirected group" + known_failure: true # TODO(errors): the error leaves the group before it's shown, so it's shown on the shell's stderr despite the group's redirect, and the shell exits with status 1 rather than 127 + stdin: | + unset zz + { echo "${zz?unset}"; } 2>/dev/null + echo "not reached" + + - name: "Builtin diagnostics carry the script location" + known_failure: true # TODO(errors): brush prints no "file: line N:" location + stdin: | + export 1x=2 + + - name: "Builtin usage errors are reported without an error prefix" + known_failure: true # TODO(errors): brush's error formatter prints "error: " and no location + stdin: | + { export -Z; } 2>&1 | head -n 1 + + - name: "Builtin I/O errors are reported as bash reports them" + known_failure: true # TODO(errors): brush reports "i/o error: ... (os error 2)" + stdin: | + cd /nonexistent-dir + + # The leading "file: line N:" a shell prefixes is stripped. + - name: "Parameter expansion errors name the parameter" + known_failure: true # TODO(errors): brush reports "expansion error: " or "expanding unset variable: ${name}" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + unset y zz arr + (echo ${y:?}) 2>&1 | strip + (echo ${y?}) 2>&1 | strip + (echo ${y:?custom message}) 2>&1 | strip + (set -u; echo $zz) 2>&1 | strip + (set -u; echo ${arr[1]}) 2>&1 | strip + + - name: "Arithmetic errors name the expression and the token at fault" + known_failure: true # TODO(errors): brush reports "arithmetic evaluation error: " without the expression + min_oracle_version: "5.3" # bash 5.2 says "syntax error", not "arithmetic syntax error" + stdin: | + strip() { sed -E 's/^[^ ]*: line [0-9]+: //'; } + (echo $((1/0))) 2>&1 | strip + (echo $((5%0))) 2>&1 | strip + (echo $((1+))) 2>&1 | strip + ((1/0)) 2>&1 | strip + + - name: "Special builtin runtime error is fatal in POSIX mode" + min_oracle_version: "5.3" # bash 5.2 reports the error and goes on + known_failure: true # TODO(posix): this special builtin error doesn't end a non-interactive POSIX shell + ignore_stderr: true + stdin: | + set -o posix + export 1x=2 + echo "after export: $?" + + - name: "Unsetting a readonly variable is fatal in POSIX mode" + min_oracle_version: "5.3" # bash 5.2 reports the error and goes on + ignore_stderr: true + stdin: | + (set -o posix; readonly rx=1; unset rx; echo "after unset: $?"); echo "unset: $?" + readonly ry=1 + unset ry + echo "outside POSIX mode: $?" + + - name: "unset -v's invalid name is fatal in POSIX mode, and plain unset's isn't" + known_failure: true # TODO(unset): an invalid name is accepted silently, so it can't end the shell + ignore_stderr: true + stdin: | + set -o posix + (unset -v 1x; echo "survived: $?"); echo "unset -v 1x: $?" + (unset -v 'a[1'; echo "survived: $?"); echo "unset -v 'a[1': $?" + (unset 1x; echo "survived: $?"); echo "unset 1x: $?" + (unset -f 1x; echo "survived: $?"); echo "unset -f 1x: $?" + + - name: "Builtin errors in a pipeline are reported on the command's stderr" + known_failure: true # TODO(pipeline): a builtin's error in a stage escapes the stage's stderr + stdin: | + strip() { sed -E 's/\x1b\[[0-9;]*m//g; s/^[^ ]*: line [0-9]+: //; s/^error: //'; } + shopt -s -u extglob 2>&1 | strip + echo "shopt: ${PIPESTATUS[*]}" + export -Z 2>/dev/null | cat + echo "export: ${PIPESTATUS[*]}" + cd /nonexistent-dir 2>/dev/null | cat + echo "cd: ${PIPESTATUS[*]}" + true | cd /nonexistent-dir 2>/dev/null + echo "last stage: ${PIPESTATUS[*]}" + + - name: "A special builtin's usage and assignment errors are fatal in POSIX mode" + known_failure: true # TODO(posix): a special builtin's usage and assignment errors don't end a non-interactive POSIX shell + ignore_stderr: true + stdin: | + set -o posix + (set -o bogus; echo "survived: $?") + echo "status $?: set -o bogus" + (set --bogus; echo "survived: $?") + echo "status $?: set --bogus" + (trap -Z; echo "survived: $?") + echo "status $?: trap -Z" + (shift abc; echo "survived: $?") + echo "status $?: shift abc" + (readonly 1x; echo "survived: $?") + echo "status $?: readonly 1x" + (export 1x=2 y=3; echo "survived: $?") + echo "status $?: export 1x=2 y=3" + (readonly r=1; readonly r=2; echo "survived: $?") + echo "status $?: readonly r=1; readonly r=2" + (readonly r=1; unset r; echo "survived: $?") + echo "status $?: readonly r=1; unset r" + (readonly r=1; export r=2; echo "survived: $?") + echo "status $?: readonly r=1; export r=2" + (. /nonexistent-file; echo "survived: $?") + echo "status $?: . /nonexistent-file" + (f() { return abc; }; f; echo "survived: $?") + echo "status $?: f() { return abc; }; f" + (return 3; echo "survived: $?") + echo "status $?: return 3" + (exec -Z; echo "survived: $?") + echo "status $?: exec -Z" + (export --help >/dev/null; echo "survived: $?") + echo "status $?: export --help >/dev/null" + (for i in 1; do break abc; done; echo "survived: $?") + echo "status $?: for i in 1; do break abc; done" + + - name: "A special builtin's ordinary failures are not fatal in POSIX mode" + known_failure: true # TODO(posix): any error a special builtin returns ends a non-interactive POSIX shell + ignore_stderr: true + stdin: | + set -o posix + for cmd in 'trap x BOGUS' 'set -- a; shift 3' 'export -f nofunc' 'unset -f -v x' \ + 'unset 1x' 'for i in 1; do break 0; done' 'break' 'continue 2'; do + (eval "$cmd"; echo "survived $?: $cmd") + done + + - name: "A failed exec ends a non-interactive shell unless execfail is set" + known_failure: true # TODO(exec): a failed exec doesn't end a non-interactive shell + ignore_stderr: true + stdin: | + (exec /nonexistent-command; echo "survived") + echo "missing: $?" + (exec /etc/passwd; echo "survived") + echo "not executable: $?" + (shopt -s execfail; exec /nonexistent-command; echo "survived execfail: $?") + + - name: "A special builtin's redirection error is fatal in POSIX mode" + known_failure: true # TODO(posix): the shell reports a redirection error before it knows the command + ignore_stderr: true + stdin: | + set -o posix + exec 3&1 | strip + (unset HOME; cd) 2>&1 | strip + [ 1 = 1 2>&1 | strip + help nosuchtopic 2>&1 | strip + kill -s BOGUS $$ 2>&1 | strip + type nosuch 2>&1 | strip + unalias nosuch 2>&1 | strip + + - name: "An assignment error drops the rest of the line" + known_failure: true # TODO(errors): runs the rest of the line + ignore_stderr: true + stdin: | + readonly r=1 + r=2; echo "same line: $?" + echo "next line: $?" + f() { r=3; }; f; echo "same line after f: $?" + echo "next line after f: $?" + + # An error that bash abandons the current line for stays that way when the command it happens + # in is a compound command with redirections: the redirections only decide where the message + # goes (the cases above and below check that), not what the error does to the enclosing list. + - name: "An error in a redirected group drops the rest of the line" + ignore_stderr: true + stdin: | + readonly r=1 + { r=2; } 2>/dev/null; echo "same line after assignment: $?" + echo "next line: $?" + { echo $((1/0)); } 2>/dev/null; echo "same line after arithmetic: $?" + echo "next line: $?" + shopt -s failglob + { echo /no-such-dir-*; } 2>/dev/null; echo "same line after failglob: $?" + echo "next line: $?" + + - name: "An error in a redirected compound command drops the rest of the line" + ignore_stderr: true + stdin: | + readonly r=1 + if true; then r=2; fi 2>/dev/null; echo "same line after if: $?" + echo "next line: $?" + for i in 1; do r=2; done 2>/dev/null; echo "same line after for: $?" + echo "next line: $?" + while true; do r=2; done 2>/dev/null; echo "same line after while: $?" + echo "next line: $?" + + - name: "An error in a redirected group isn't a failure for ||" + ignore_stderr: true + stdin: | + readonly r=1 + { r=2; } 2>/dev/null || echo "ran ||"; echo "same line" + echo "next line" + + - name: "An error in a redirected group isn't a false condition" + ignore_stderr: true + stdin: | + readonly r=1 + if { r=2; } 2>/dev/null; then echo "then"; else echo "else"; fi; echo "same line after if" + echo "next line" + ! { r=2; } 2>/dev/null && echo "negated"; echo "same line after !" + echo "next line" + + - name: "An error in a redirected group abandons the enclosing loop" + ignore_stderr: true + stdin: | + readonly r=1 + for i in 1 2 3; do { r=2; } 2>/dev/null; echo "iteration $i"; done + echo "after for" + i=0 + until { r=2; } 2>/dev/null; do i=$((i + 1)); [ $i -ge 3 ] && break; echo "tick $i"; done + echo "after until" + i=0 + while true; do { r=2; } 2>/dev/null || break; i=$((i + 1)); [ $i -ge 3 ] && break; done; echo "same line after while" + echo "after while" + + - name: "An error in a redirected group abandons the function it's in" + ignore_stderr: true + stdin: | + readonly r=1 + f() { { r=2; } 2>/dev/null; echo "still in f"; } + f + echo "next line" + + - name: "An error in a redirected group doesn't run the ERR trap" + ignore_stderr: true + stdin: | + trap 'echo "ERR trap"' ERR + readonly r=1 + { r=2; } 2>/dev/null; echo "same line" + echo "next line" + + # Not observable as a shell exit yet: an assignment error doesn't end a POSIX shell (see + # "A special builtin's usage and assignment errors are fatal in POSIX mode"), so this only + # checks that the rest of the line is still dropped. + - name: "An error in a redirected group drops the rest of the line (POSIX mode)" + ignore_stderr: true + stdin: | + set -o posix + readonly r=1 + { r=2; } 2>/dev/null; echo "same line" + + - name: "A fatal error in a redirected group drops the rest of the line (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + unset zz + { echo "${zz?missing parameter}"; } 2>/dev/null; echo "same line" + echo "next line" + + - name: "An error in a redirected group drops the rest of the line (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + readonly r=1 + { r=2; } 2>/dev/null; echo "same line" + echo "next line" + + - name: "A syntax error in a sourced file fails the source and goes on" + known_failure: true # TODO(errors): the syntax error ends the shell + ignore_stderr: true + test_files: + - path: "bad.sh" + contents: | + echo ) + stdin: | + . ./bad.sh + echo "after: $?" + + - name: "A syntax error in a sourced file ends a POSIX shell" + ignore_stderr: true + test_files: + - path: "bad.sh" + contents: | + echo ) + stdin: | + set -o posix + . ./bad.sh + echo "after: $?" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml b/userland/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml new file mode 100644 index 0000000..5d1fcaa --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml @@ -0,0 +1,705 @@ +name: "Extended tests" +cases: + - name: "Extended test with redirect" + stdin: | + [[ $(echo hello >&2) ]] 2>/dev/null + echo "Result: $?" + + - name: "File extended tests" + stdin: | + [[ -a /tmp ]] && echo "-a correctly checked /tmp" + [[ -a /some/non/existent/path ]] || echo "-a correctly checked non-existent path" + + [[ -c /dev/null ]] && echo "-c correctly checked /dev/null" + [[ -c /tmp ]] || echo "-c correctly checked /tmp" + + - name: "Existence tests" + stdin: | + [[ -e '' ]] || echo "-e correctly identified empty string as non-existent" + + [[ -e non-existent ]] || echo "-e correctly identified non-existent path" + [[ ! -e non-existent ]] && echo "! -e correctly identified non-existent path" + + touch test-file + [[ -e test-file ]] && echo "-e correctly identified existing non-dir" + + mkdir test-dir + [[ -e test-dir ]] && echo "-d correctly identified existing dir" + + - name: "Directory tests" + stdin: | + [[ -d '' ]] || echo "-d correctly identified empty string as non-existent" + + [[ -d non-existent ]] || echo "-d correctly identified non-existent path" + [[ ! -d non-existent ]] && echo "! -d correctly identified non-existent path" + + touch test-file + [[ -d test-file ]] || echo "-d correctly identified non-dir file" + + mkdir test-dir + [[ -d test-dir ]] && echo "-d correctly identified existing dir" + + - name: "File regular tests" + stdin: | + [[ -f '' ]] || echo "-f correctly identified empty string as non-existent" + + [[ -f non-existent ]] || echo "-f correctly identified non-existent path" + [[ ! -f non-existent ]] && echo "! -f correctly identified non-existent path" + + touch test-file + [[ -f test-file ]] && echo "-f correctly identified regular file" + + mkdir test-dir + [[ -f test-dir ]] || echo "-f correctly identified directory as non-regular file" + + - name: "File symbolic link tests" + stdin: | + [[ -L non-existent ]] || echo "-L correctly identified non-existent path" + + touch test-file + [[ -L test-file ]] || echo "-L correctly identified non-link file" + + ln -s test-file valid-link + [[ -L valid-link ]] && echo "-L correctly identified valid symbolic link" + + ln -s non-existent-target dangling-link + [[ -L dangling-link ]] && echo "-L correctly identified dangling symbolic link" + + - name: "File sticky bit tests" + stdin: | + touch test-file + [[ -k test-file ]] || echo "-k correctly identified file without sticky bit" + + chmod 1600 test-file + [[ -k test-file ]] && echo "-k correctly identified file with sticky bit" + + - name: "Fifo test" + stdin: | + touch test-file + [[ -p test-file ]] || echo "-p correctly identified non-fifo file" + + mkfifo fifo-file + [[ -p test-file ]] && echo "-p correctly identified fifo file" + + - name: "File executable tests" + stdin: | + [[ -x non-existent ]] || echo "-x correctly identified non-existent path" + + touch test-file + ln -sf link test-file + [[ -x test-file ]] || echo "-x correctly identified non-executable file" + [[ -x link ]] || echo "-x correctly identified link to non-executable file" + + chmod o+x test-file + [[ -x test-file ]] || echo "-x correctly identified other-only-executable file" + [[ -x link ]] || echo "-x correctly identified link to other-only-executable file" + + chmod a+x test-file + [[ -x test-file ]] && echo "-x correctly identified executable file" + [[ -x link ]] && echo "-x correctly identified link to executable file" + + - name: "File writable tests" + stdin: | + [[ -w non-existent ]] || echo "-w correctly identified non-existent path" + + touch test-file + ln -sf link test-file + [[ -w test-file ]] || echo "-w correctly identified non-writable file" + [[ -w link ]] || echo "-w correctly identified link to non-writable file" + + chmod o+x test-file + [[ -w test-file ]] || echo "-w correctly identified other-only-writable file" + [[ -w link ]] || echo "-w correctly identified link to other-only-writable file" + + chmod a+x test-file + [[ -w test-file ]] && echo "-w correctly identified writable file" + [[ -w link ]] && echo "-w correctly identified link to writable file" + + - name: "Variable set and nameref tests" + stdin: | + foo="bar" + declare -n bang=foo + [[ -R bang ]] && echo "-R correctly identified nameref variable that is set" + + declare -n somevar + [[ -R somevar ]] || echo "-R correctly identified nameref variable that isn't set" + + - name: "Files refer to same device and inode tests" + stdin: | + [[ /bin/sh -ef /bin/sh ]] && echo "-ef correctly identified device and inode numbers" + + [[ ! /etc/os-release -ef /bin/sh ]] && echo "-ef correctly identified device and inode numbers that do not match" + + - name: "File is newer" + stdin: | + touch -d "2 hours ago" bar + touch foo + + [[ foo -nt bar ]] && echo "-nt correctly identified newer file" + [[ foo -nt foo ]] && echo "-nt incorrectly identified file as newer than itself" + [[ foo -nt file_no_exists ]] && echo "-nt correctly identified when file2 does not exist" + + - name: "File is older" + stdin: | + touch -d "2 hours ago" foo + touch bar + + [[ foo -ot bar ]] && echo "-ot correctly identified older file" + [[ foo -ot foo ]] && echo "-ot incorrectly identified file as older than itself" + [[ file_no_exists -ot foo ]] && echo "-ot correctly identified when file1 does not exist" + + - name: "Unary string extended tests" + stdin: | + [[ -z "" ]] && echo "-z: Pass" + [[ -z "something" ]] && echo "-z: Fail" + + [[ -n "something" ]] && echo "-n: Pass" + [[ -n "" ]] && echo "-n: Fail" + + - name: "Shell option extended tests" + stdin: | + set -o emacs + [[ -o emacs ]] && echo "1: option enabled" + + set +o emacs + [[ -o emacs ]] && echo "2: option enabled" + + - name: "Binary string extended tests" + stdin: | + [[ "" == "" ]] && echo "1. Pass" + [[ "" = "" ]] && echo "2. Pass" + [[ "" != "" ]] && echo "3. Fail" + + [[ "a" != "b" ]] && echo "4. Pass" + [[ "a" = "b" ]] && echo "5. Fail" + [[ "a" == "b" ]] && echo "6. Fail" + + [[ "a" < "b" ]] && echo "7. Pass" + [[ "a" < "a" ]] && echo "8. Fail" + [[ "b" < "a" ]] && echo "9. Fail" + [[ "a" > "b" ]] && echo "10. Fail" + [[ "a" > "a" ]] && echo "11. Fail" + [[ "b" > "a" ]] && echo "12. Pass" + + - name: "Binary string matching" + stdin: | + [[ "abc" == a* ]] && echo "1. Pass" + [[ "abc" != a* ]] && echo "2. Fail" + [[ a* != "abc" ]] && echo "3. Pass" + + - name: "Binary string matching with expansion" + stdin: | + exclude="0123456789" + [[ "clue" == +([$exclude]) ]] && echo "1. Fail" + [[ "8675309" == +([$exclude]) ]] && echo "2. Pass" + + - name: "Quoted pattern binary string matching" + stdin: | + [[ "abc" == "a*" ]] && echo "1. Matches" + [[ "abc" != "a*" ]] && echo "2. Matches" + + - name: "Tilde binary string matching" + stdin: | + x='~/' + [[ $x == ~* ]] && echo "1. Matches" + + - name: "Arithmetic extended tests" + stdin: | + [[ 0 -eq 0 ]] && echo "1. Pass" + [[ 0 -ne 0 ]] && echo "2. Fail" + [[ 0 -lt 0 ]] && echo "3. Fail" + [[ 0 -le 0 ]] && echo "4. Pass" + [[ 0 -gt 0 ]] && echo "5. Fail" + [[ 0 -ge 0 ]] && echo "6. Pass" + + [[ 0 -eq 1 ]] && echo "7. Fail" + [[ 0 -ne 1 ]] && echo "8. Pass" + [[ 0 -lt 1 ]] && echo "9. Pass" + [[ 0 -le 1 ]] && echo "10. Pass" + [[ 0 -gt 1 ]] && echo "11. Fail" + [[ 0 -ge 1 ]] && echo "12. Fail" + + [[ 1 -eq 0 ]] && echo "13. Fail" + [[ 1 -ne 0 ]] && echo "14. Pass" + [[ 1 -lt 0 ]] && echo "15. Fail" + [[ 1 -le 0 ]] && echo "16. Fail" + [[ 1 -gt 0 ]] && echo "17. Pass" + [[ 1 -ge 0 ]] && echo "18. Pass" + + - name: "Regex" + stdin: | + [[ "a" =~ ^a$ ]] && echo "1. Pass" + [[ "abc" =~ a* ]] && echo "2. Pass" + [[ a =~ ^(a)$ ]] && echo "3. Pass" + [[ a =~ ^(a|b)$ ]] && echo "4. Pass" + [[ a =~ c ]] && echo "5. Pass" + + # A ']' in the first member position (after any '^') is a member of the bracket + # expression, not its terminator. + - name: "Regex with leading ] in bracket expression" + stdin: | + [[ "]" =~ ^[]]$ ]] && echo "1. Matched" + [[ "a" =~ ^[]a]$ ]] && echo "2. Matched" + [[ "[" =~ ^[][]$ ]] && echo "3. Matched" + [[ "]" =~ ^[][]$ ]] && echo "4. Matched" + [[ "a" =~ ^[][]$ ]] || echo "5. Not matched" + [[ "[" =~ ^[^][]$ ]] || echo "6. Not matched" + [[ "a" =~ ^[^][]$ ]] && echo "7. Matched" + [[ "[" =~ ^[]a[]$ ]] && echo "8. Matched" + # Only the first '^' negates; the second is an ordinary member, so the ']' + # that follows it does terminate the expression. + [[ "xa" =~ ^[^^][ab]$ ]] && echo "9. Matched" + [[ "x[" =~ ^[^^][ab]$ ]] || echo "10. Not matched" + [[ "x]" =~ ^[^^]]$ ]] && echo "11. Matched" + # That leading ']' can also be the low end of a range. + [[ "^" =~ ^[]-a]$ ]] && echo "12. Matched" + [[ "]" =~ ^[]-a]$ ]] && echo "13. Matched" + [[ "-" =~ ^[]-a]$ ]] || echo "14. Not matched" + [[ "b" =~ ^[]-a]$ ]] || echo "15. Not matched" + echo "done" + + # `fancy_regex` and POSIX ERE disagree on how a '-' range endpoint binds, and + # brush passes the user's bracket expression through without normalizing it. + - name: "Regex with '-' as a range endpoint" + known_failure: true + stdin: | + for s in '+' ',' '-' '.' '/' 'a'; do + [[ "$s" =~ ^[+--]$ ]] && echo "1. matched: $s" + done + for s in '+' ',' '-' '.' '/' 'a'; do + [[ "$s" =~ ^[--/]$ ]] && echo "2. matched: $s" + done + echo "done" + + # A reversed range is rejected by both shells, but bash reports a bad regex as + # a usage error (status 2) while brush treats it as a failed match (status 1). + - name: "Regex with a reversed range" + known_failure: true + ignore_stderr: true + stdin: | + [[ x =~ ^[9-0]$ ]] + echo "status=$?" + + # POSIX ERE reads '?*' as two stacked quantifiers; `fancy_regex` rejects the + # second as having no valid target. + - name: "Regex with stacked quantifiers" + known_failure: true + ignore_stderr: true + stdin: | + [[ "]" =~ ^[a]?*]$ ]] + echo "status=$?" + + - name: "Regex with case insensitivity" + stdin: | + shopt -u nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + + shopt -s nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + + - name: "Regex with capture" + stdin: | + pattern='(Hello), ([a-z]+)\.' + if [[ "Hello, world." =~ ${pattern} ]]; then + echo "Match found!" + for i in "${!BASH_REMATCH[@]}"; do + echo "$i: '${BASH_REMATCH[$i]}'" + done + fi + + - name: "Regex with capture including optional matches" + stdin: | + pattern='(Hello)(,?) ([a-z]+)\.' + if [[ "Hello world." =~ ${pattern} ]]; then + echo "Match found!" + for i in "${!BASH_REMATCH[@]}"; do + echo "$i: '${BASH_REMATCH[$i]}'" + done + fi + + - name: "Regex with quoting" + stdin: | + regex="^$" + + # TODO(test): The commented out lines appear differ in behavior between versions of bash. + # [[ "" =~ '' ]] && echo "1. Matched" + [[ "" =~ '^$' ]] && echo "2. Matched" + # [[ "" =~ "" ]] && echo "3. Matched" + [[ "" =~ "^$" ]] && echo "4. Matched" + [[ "" =~ ^$ ]] && echo "5. Matched" + [[ "" =~ ${regex} ]] && echo "6. Matched" + [[ "" =~ "${regex}" ]] && echo "7. Matched" + + - name: "Regex with escaping" + stdin: | + [[ '' =~ ^\$$ ]] && echo "1. Matched" + + - name: "Regex with double parens" + stdin: | + if [[ xy =~ x+((y)) ]]; then + echo "Matches" + fi + + - name: "Regex with special chars in parens" + stdin: | + [[ "<" =~ (<) ]] && echo "1. Matched" + [[ ">" =~ (<) ]] && echo "2. Matched" + + - name: "Regex with spaces inside parens" + stdin: | + [[ "x" =~ (a| *) ]] && echo "1. Matched" + [[ "a b" =~ ^(a |b) ]] && echo "2. Matched: [${BASH_REMATCH[0]}]" + [[ "a b" =~ ^(a |b)$ ]] || echo "3. No match" + [[ " " =~ ^( )$ ]] && echo "4. Matched: [${BASH_REMATCH[1]}]" + + - name: "Regex with unescaped open bracket in character class" + stdin: | + [[ "[" =~ ^([x[]) ]] && echo "Matched" + + - name: "Empty and space checks" + stdin: | + check() { + var="$1" + [[ ${var} && ! ${var//[[:space:]]/} ]] + } + + check "" && echo "1. Only space" + check " " && echo "2. Only space" + check $'\t' && echo "3. Only space" + check " a " && echo "4. Only space" + + - name: "Newlines in test expression" + stdin: | + [[ + "a" == "a" + && + "b" == "b" + ]] && echo "Succeeded" + + - name: "Variable set checks" + stdin: | + declare set_but_no_value + [[ -v set_but_no_value ]] && echo "1. Set but no value" + + declare set_with_value=xyz + [[ -v set_with_value ]] && echo "2. Set with value" + + [[ -v not_set ]] || echo "3. Not set" + + - name: "Variable set checks on array elements" + known_failure: true # TODO(extended-tests): -v with a subscript always reports an array element as unset + stdin: | + indexed=(x y) + [[ -v indexed[1] ]] && echo "1. Indexed element set" + [[ -v indexed[5] ]] || echo "2. Indexed element unset" + + declare -A assoc=([key]=value) + [[ -v assoc[key] ]] && echo "3. Associative element set" + [[ -v assoc[nope] ]] || echo "4. Associative element unset" + + test -v 'indexed[0]' && echo "5. test -v sees the element" + + - name: "Variables in extended tests" + stdin: | + var=10 + + [[ $var -eq 10 ]] && echo "1. Pass" + [[ var -eq 10 ]] && echo "2. Pass" + + - name: "Regex with min/max counts" + stdin: | + [[ z =~ ^z{2,6}$ ]] && echo "1. Matches" + [[ zzzz =~ ^z{2,6}$ ]] && echo "2. Matches" + [[ zzzzzzzzz =~ ^z{2,6}$ ]] && echo "3. Matches" + + - name: "Regex with newline" + stdin: | + [[ $'\n' =~ . ]] && echo "1. Matches" + + - name: "Arithmetic comparisons with whitespace in operands" + stdin: | + # Leading/trailing whitespace should be trimmed before comparison + [[ 0 -eq ' 0' ]] && echo "1. leading space" + [[ 0 -eq '0 ' ]] && echo "2. trailing space" + [[ 0 -eq ' 0 ' ]] && echo "3. both spaces" + + # Tabs + [[ 0 -eq ' 0' ]] && echo "4. leading tab" + [[ 0 -eq '0 ' ]] && echo "5. trailing tab" + + # Newlines + [[ 0 -eq ' 0 + ' ]] && echo "6. newline" + + # Other operators + [[ 5 -lt ' 10' ]] && echo "7. -lt with space" + [[ 10 -gt ' 5' ]] && echo "8. -gt with space" + [[ 5 -ne ' 6' ]] && echo "9. -ne with space" + [[ 5 -le ' 5' ]] && echo "10. -le with space" + [[ 5 -ge ' 5' ]] && echo "11. -ge with space" + + # Negative numbers with whitespace + [[ -5 -eq ' -5' ]] && echo "12. negative with space" + + - name: "Terminal fd check with whitespace" + stdin: | + # -t operator should trim whitespace before parsing fd number + # These should all behave identically (false since not a terminal) + [[ -t 0 ]] + r1=$? + [[ -t ' 0' ]] + r2=$? + [[ -t '0 ' ]] + r3=$? + + # All should have the same result + [[ $r1 -eq $r2 && $r2 -eq $r3 ]] && echo "All -t results match" + + - name: "Nameref -R with attribute removal" + stdin: | + target="value" + declare -n ref=target + [[ -R ref ]] && echo "ref is a nameref" + [[ -R target ]] && echo "target is a nameref" || echo "target is not a nameref" + declare +n ref + [[ -R ref ]] && echo "still nameref" || echo "no longer a nameref" + + - name: "Nameref -v follows nameref" + known_failure: true + stdin: | + target="value" + declare -n ref=target + [[ -v ref ]] && echo "ref is set (via target)" + unset target + [[ -v ref ]] && echo "ref still set" || echo "ref not set (target gone)" + + - name: "Nameref -v to array element treats resolved name literally" + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref='arr[2]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref -v with explicit subscript on nameref to whole array" + # TODO(nameref): brush doesn't yet handle [[ -v "ref[N]" ]] where ref is + # a nameref to a whole array. Bash resolves ref→arr and then checks arr[2], + # but brush currently treats the subscript as part of the nameref target + # string rather than applying it after resolution. The fix belongs in + # extendedtests.rs (ShellVariableIsSetAndAssigned) — the operand "ref[2]" + # needs to be split into name="ref" + subscript="2", the nameref resolved + # on the name portion, then the subscript applied to the resolved target. + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref2=arr + [[ -v "ref2[2]" ]] && echo "set" || echo "unset" + + - name: "Nameref -v to nonexistent array index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[5]' + [[ -v ref ]] && echo "set" || echo "unset" + + # + # Newlines within [[ ]] conditional expressions. + # + # bash allows newlines (1) at the start of a term (after `[[`, `(`, `&&`, + # `||`, `!`) and (2) trailing after a complete binary/unary/parenthesized + # term -- but NOT after a bare-word string test. These cases pin down both + # the accepted and rejected forms. See issue #1176. + # + + - name: "Newline after ( in [[ ]]" + stdin: | + [[ ( + a = a ) ]] && echo ok + + - name: "Newline after ( before bare word in [[ ]]" + stdin: | + [[ ( + a ) ]] && echo ok + + - name: "Newline before ) after binary test in [[ ]]" + stdin: | + [[ ( a = a + ) ]] && echo ok + + - name: "Newline before ) after unary test in [[ ]]" + stdin: | + [[ ( -n a + ) ]] && echo ok + + - name: "Newline before ) after nested parens in [[ ]]" + stdin: | + [[ ( ( a = a ) + ) ]] && echo ok + + - name: "Multiple newlines before ) in [[ ]]" + stdin: | + [[ ( a = a + + + + ) ]] && echo ok + + - name: "Newline after && in [[ ]]" + stdin: | + [[ ( a = a && + b = b ) ]] && echo ok + + - name: "Newline before && after binary test in [[ ]]" + stdin: | + [[ a = a + && b = b ]] && echo ok + + - name: "Newline before || after binary test in [[ ]]" + stdin: | + [[ a = b + || b = b ]] && echo ok + + - name: "Leading newline after [[ " + stdin: | + [[ + a = a ]] && echo ok + + - name: "Newline after ! in [[ ]]" + stdin: | + [[ ! + a = b ]] && echo ok + + - name: "Newline before ]] after binary test" + stdin: | + [[ a = a + ]] && echo ok + + - name: "Newline before ]] after unary test" + stdin: | + [[ -n a + ]] && echo ok + + - name: "Newline before ]] after parenthesized expression" + stdin: | + [[ ( a = a ) + ]] && echo ok + + - name: "Newline before ]] after not of parenthesized expression" + stdin: | + [[ ! ( a = b ) + ]] && echo ok + + # Cases bash rejects as syntax errors: a newline may not follow a bare-word + # string test (whether before `]]`, `)`, or a binary/logical operator). + + - name: "Error: newline before ]] after bare word" + ignore_stderr: true + stdin: | + [[ a + ]] && echo should-not-print + + - name: "Error: newline before ) after bare word" + ignore_stderr: true + stdin: | + [[ ( a + ) ]] && echo should-not-print + + - name: "Error: newline before && after bare word" + ignore_stderr: true + stdin: | + [[ a + && b ]] && echo should-not-print + + - name: "Error: newline before ) after not of bare word" + ignore_stderr: true + stdin: | + [[ ( ! a + ) ]] && echo should-not-print + + # POSIX regexes have no line anchors: ^ and $ match only at the ends of the + # whole string, while . matches any character including newline. + - name: "Regex anchors match the whole string, not lines" + stdin: | + s=$'a\nb' + [[ $s =~ ^b ]] && echo "wrong 1" || echo "ok 1" + [[ $s =~ a$ ]] && echo "wrong 2" || echo "ok 2" + [[ $s =~ ^a.b$ ]] && echo "ok 3" + t=$' \t\nabc \t\n' + re=$'[ \t\n]+$' + [[ $t =~ $re ]] && printf '[%s]\n' "${t::${#t}-${#BASH_REMATCH}}" + + # An unquoted unary operator at the start of a test is read as the operator and + # takes the next word as its operand, even a word that looks like an operator. + - name: "Unary operator takes an operator-like word as operand" + stdin: | + [[ -z == ]]; echo "1: $?" + [[ -n == ]]; echo "2: $?" + [[ -z -z ]]; echo "3: $?" + [[ -n -eq ]]; echo "4: $?" + [[ -z =~ ]]; echo "5: $?" + [[ -z ! ]]; echo "6: $?" + [[ -f -nt ]]; echo "7: $?" + [[ ! -z == ]]; echo "8: $?" + [[ ( -z != ) ]]; echo "9: $?" + [[ -n x && -z != ]]; echo "10: $?" + [[ -z == || -n = ]]; echo "11: $?" + + - name: "Quoted or expanded unary operator is a plain word" + stdin: | + [[ "-z" == x ]]; echo "1: $?" + [[ '-z' == -z ]]; echo "2: $?" + [[ \-z == -z ]]; echo "3: $?" + x=-z + [[ $x == -z ]]; echo "4: $?" + [[ $x ]]; echo "5: $?" + [[ x == -z ]]; echo "6: $?" + [[ -zz == x ]]; echo "7: $?" + + - name: "Error: word after unary operand of [[ ]]" + ignore_stderr: true + stdin: | + [[ -z == x ]] && echo should-not-print + + - name: "Error: unary operator as left side of binary test" + ignore_stderr: true + stdin: | + [[ -f -nt x ]] && echo should-not-print + + - name: "Error: unary operator as left side of arithmetic test" + ignore_stderr: true + stdin: | + [[ -n -eq 1 ]] && echo should-not-print + + - name: "Error: unary operator as left side of < test" + ignore_stderr: true + stdin: | + [[ -z < x ]] && echo should-not-print + + - name: "Error: unary operator as left side of =~ test" + ignore_stderr: true + stdin: | + [[ -z =~ x ]] && echo should-not-print + + - name: "Error: unary operator followed by ||" + ignore_stderr: true + stdin: | + [[ -n || x ]] && echo should-not-print + + - name: "Error: unary operator followed by ]]" + ignore_stderr: true + stdin: | + [[ -z ]] && echo should-not-print + + - name: "Error: unary operator followed by ) in [[ ]]" + ignore_stderr: true + stdin: | + [[ ( -z ) ]] && echo should-not-print + + - name: "Error: negated unary operator followed by ]]" + ignore_stderr: true + stdin: | + [[ ! -n ]] && echo should-not-print + + - name: "Error: ]] as unary operand" + ignore_stderr: true + stdin: | + [[ -z ]] ]] && echo should-not-print diff --git a/userland/brush/source/brush-shell/tests/cases/compat/fd_management.yaml b/userland/brush/source/brush-shell/tests/cases/compat/fd_management.yaml new file mode 100644 index 0000000..93adb3a --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/fd_management.yaml @@ -0,0 +1,72 @@ +name: "File descriptor management" +# Regression tests for issue #1173: nested/concurrent shell constructs must not +# exhaust the process-wide file-descriptor table. +# +# brush runs subshells, command substitutions, background jobs, and function +# call contexts as in-process tasks rather than via fork(2), so they all share a +# single process-wide descriptor table. Each context inherits the caller's open +# files; sharing those descriptors -- rather than giving each context its own +# copy -- is what keeps deeply nested or highly concurrent scripts within the +# limit. A real shell gives each forked subshell its own descriptor table and so +# stays well within the limit too. +# +# Each case lowers the descriptor limit with `ulimit -n` and runs a workload that +# stays within the limit only when inherited descriptors are shared across +# contexts; if each context copied them, the workload would exceed the limit and +# fail with "Too many open files" and truncated output. The limits leave the +# correct (sharing) behavior several-fold headroom. These cases assume Unix +# `ulimit -n` semantics. +cases: + # Concurrent fan-out: every backgrounded job is alive simultaneously (they are + # all launched before `wait`), so copying the inherited descriptors per job + # would multiply the live descriptor count by the number of jobs. + - name: "Many concurrent background jobs each using command substitution" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + emit() { + local v + v=$(echo "$1") + echo "$v" + } + ( for i in $(seq 1 60); do emit "$i" & done; wait ) | wc -l | tr -d ' ' + + # nvm-style: backgrounded jobs in a subshell, piped to a filter, each touching + # /dev/null via redirects -- the precise shape that exhausted descriptors in + # `nvm ls`. + - name: "Subshell of backgrounded jobs with redirects piped to a filter" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + work() { + local v + v=$(echo "alias-$1" 2>/dev/null) + echo "$v" 2>/dev/null + } + ( for i in $(seq 1 50); do work "$i" 2>/dev/null & done; wait ) | sort | wc -l | tr -d ' ' + + # Nested depth: each command-substitution level keeps its ancestors' contexts + # alive while it runs, so copying inherited descriptors would grow the live + # count with nesting depth. + - name: "Deeply nested command substitution with redirects stays within the fd limit" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + rec() { + local n=$1 + if [ "$n" -le 0 ]; then + printf 'x' 2>/dev/null + return + fi + local inner + inner=$(rec $((n - 1)) 2>/dev/null) + printf '%s' "$inner" 2>/dev/null + } + out=$(rec 40) + echo "${#out}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/functions.yaml b/userland/brush/source/brush-shell/tests/cases/compat/functions.yaml new file mode 100644 index 0000000..bf84c57 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/functions.yaml @@ -0,0 +1,250 @@ +name: "Functions" +cases: + - name: "Basic function invocation" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()." + } + echo "Calling myfunc()..." + myfunc + echo "Returned." + args: ["./script.sh"] + + - name: "Function invocation with args" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()" + echo "1: $1" + echo "*: $*" + } + echo "Calling myfunc()..." + myfunc a b c + echo "Returned." + args: ["./script.sh"] + + - name: "Function invocation with empty arg" + stdin: | + myfunc() { + echo "count: ${#*}" + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + } + + myfunc a b c + myfunc a "" c + + - name: "Function definition with output redirection" + stdin: | + myfunc() { + echo "In myfunc()" + } >>./test.txt + + myfunc + myfunc + + - name: "Function call with env variables" + stdin: | + myfunc() { + echo ${myvar} + } + + myvar="default" + myfunc + myvar="overridden" myfunc + myfunc + + - name: "Function definition without braces" + stdin: | + myfunc() + if true; then + echo true + else + echo false + fi + + myfunc + + - name: "Function with extended test body" + stdin: | + is_thing() [[ -n $1 ]] + + is_thing hello && echo "hello is a thing" + is_thing "" || echo "empty string is not a thing" + + - name: "Function with extended test body and redirect" + stdin: | + is_thing() [[ -n $1 ]] 2>/dev/null + + is_thing hello + echo "Result: $?" + + - name: "Nested function definition" + stdin: | + outer() { + echo "Entered outer" + + inner() { + echo "In inner" + } + + echo "Invoking inner" + + inner + + echo "Returning from outer" + } + + echo "Calling outer from toplevel" + outer + + echo "Calling inner from toplevel" + inner + + - name: "Exporting functions to child instance" + stdin: | + mytestfunc() { + echo "In mytestfunc" + } + + export -f mytestfunc + + $0 -c 'mytestfunc' + + - name: "Function names with interesting characters" + stdin: | + my/func() { + echo "In my/func" + } + + my/func + + - name: "Functions shadowing builtins" + stdin: | + .() { + echo "In custom dot function" + } + + echo "Func result: $?" + + . + echo "Call result: $?" + + - name: "Reserved words cannot be used as function names without function keyword" + ignore_stderr: true + stdin: | + for (){ :; } + echo "exit: $?" + + - name: "Reserved words can be used as function names with function keyword" + stdin: | + function for { echo "custom for"; } + for x in 1; do echo "normal for: $x"; done + + - name: "Reserved words can be used as function names with function keyword and parens" + stdin: | + function while() { echo "custom while"; } + while false; do echo "nope"; done + echo "done" + + - name: "Functions shadowing builtins (POSIX mode)" + ignore_stderr: true + stdin: | + set -o posix + + .() { + echo "In custom dot function" + } + + echo "Func result: $?" + + . + echo "Call result: $?" + + - name: "Empty string arguments preserved with nullglob" + stdin: | + shopt -s nullglob + + myfunc() { + echo "arg count: $#" + for arg in "$@"; do + echo "arg: '$arg'" + done + } + + myfunc 3 - 2 "" 1.2.3b_alpha4 + + - name: "Multiple empty string arguments" + stdin: | + myfunc() { + echo "count: $#" + echo "1: '$1'" + echo "2: '$2'" + echo "3: '$3'" + echo "4: '$4'" + } + + myfunc "" "non-empty" "" "last" + + - name: "Prefix assignment in a function leaves the caller's prefix assignment alone" + stdin: | + f() { FOO=2 true; echo "[$FOO]"; } + FOO=1 f + g() { FOO=2 h; echo "[$FOO]"; } + h() { echo "h: $FOO"; } + FOO=1 g + + - name: "Prefix assignments to one variable build on each other" + stdin: | + f() { echo "[$FOO]"; } + FOO=1 FOO+=2 f + + - name: "Prefix assignment with += appends to the value where it's visible" + stdin: | + BAR=a + BAR+=b env | grep ^BAR= + declare -i N=5 + N+=3 env | grep ^N= + f() { FOO+=2 g; echo "[$FOO]"; } + g() { echo "g: $FOO"; } + FOO=1 f + echo "[$BAR] [$N]" + + - name: "Prefix assignment can't override a readonly variable" + ignore_stderr: true + stdin: | + g() { echo "g: $FOO"; } + f() { readonly FOO; FOO=2 g; echo "f: $FOO rc=$?"; } + FOO=1 f + k() { echo "k: $L"; } + h() { local -r L=1; L=2 k; echo "h: rc=$?"; } + h + readonly R=1 + R=2 B=3 env | grep -E '^(R|B)=' + echo "R=$R" + + - name: "A function definition's redirection applies to the function's errors" + known_failure: true # TODO(errors): an error is reported after the redirections around it are undone + stdin: | + f() { echo ${x?boom}; } 2>/dev/null + f | cat + echo "after f: ${PIPESTATUS[*]}" + readonly r=1 + g() { r=2; } 2>/dev/null + g + echo "after g: $?" + + - name: "A compound command's redirection applies to errors in it" + known_failure: true # TODO(errors): an error is reported after the redirections around it are undone + stdin: | + readonly r=1 + { r=2; } 2>/dev/null + echo "after group: $?" + { echo ${y?b}; } 2>/dev/null & + wait + echo "after job" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/here.yaml b/userland/brush/source/brush-shell/tests/cases/compat/here.yaml new file mode 100644 index 0000000..5625348 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/here.yaml @@ -0,0 +1,742 @@ +name: "Here docs/strings" +cases: + - name: "Basic here doc" + stdin: | + cat <" on stderr); the stdout + # is what verifies the IFS join. + ignore_stderr: true + stdin: | + arr=(a b c) + IFS=: + unset x + : ${x:=${arr[*]}} + echo "[$x]" + + - name: "Assign-default array at join uses space" + ignore_stderr: true + stdin: | + arr=(a b c) + IFS=: + unset y + : ${y:=${arr[@]}} + echo "[$y]" + + - name: "Use-default array star join honors IFS" + stdin: | + arr=(a b c) + IFS=: + unset z + echo "[${z:-${arr[*]}}]" + + - name: "Assign-default positional star join honors IFS" + ignore_stderr: true + stdin: | + set -- a b c + IFS=: + unset p + : ${p:=$*} + echo "[$p]" + + - name: "Run of non-whitespace delimiters yields empty fields" + stdin: | + IFS=: + var="a:b:::c" + set -- $var + echo "Count: $#" + for arg in "$@"; do + echo "[$arg]" + done + + - name: "Leading delimiter yields an empty field, trailing one does not" + stdin: | + IFS=: + for var in ":a" "a:" ":a:" ":" "::"; do + set -- $var + echo "$var -> $#" + for arg in "$@"; do + echo " [$arg]" + done + done + + - name: "Whitespace around a delimiter belongs to the same run" + stdin: | + IFS=': ' + for var in "a : b" "a :: b" "a: :b" " a b "; do + set -- $var + echo "$var -> $#" + for arg in "$@"; do + echo " [$arg]" + done + done + + - name: "Carriage return, vertical tab and form feed split like whitespace" + stdin: | + for c in $'\r' $'\v' $'\f'; do + IFS="$c" + var="a${c}b${c}${c}c" + set -- $var + echo "count=$#" + done + + - name: "A delimiter split across two expansions in one word" + stdin: | + IFS=: + a="1:" + b=":4" + set -- $a$b + echo "Count: $#" + for arg in "$@"; do + echo "[$arg]" + done + + - name: "Each field of an array expansion splits on its own" + stdin: | + IFS=: + arr=("a:b" "c::d") + set -- ${arr[@]} + echo "Count: $#" + for arg in "$@"; do + echo "[$arg]" + done + + # Cases below test IFS behavior with empty/unset IFS across different + # expansion types: variable, brace, mixed, arithmetic, command substitution. + + - name: "Empty IFS disables splitting of variable expansion" + stdin: | + str="a b c" + IFS="" + arr=( $str ) + echo "split: ${#arr[@]} elements" + + - name: "Unset IFS uses default splitting" + stdin: | + str="a b c" + unset IFS + arr=( $str ) + echo "split: ${#arr[@]} elements" + + - name: "Local IFS=: then unset restores default splitting" + stdin: | + test_func() { + local str2="a b c" + local IFS=: + unset IFS + local arr=( $str2 ) + echo "split: ${#arr[@]} elements" + for (( i=0; i<${#arr[@]}; i++ )); do + echo " [$i]='${arr[$i]}'" + done + } + test_func + + - name: "Local IFS='' disables splitting of variable" + stdin: | + test_func() { + local str2="a b c" + local IFS="" + local arr=( $str2 ) + echo "split: ${#arr[@]} elements" + for (( i=0; i<${#arr[@]}; i++ )); do + echo " [$i]='${arr[$i]}'" + done + } + test_func + + - name: "Brace expansion with local IFS=: then unset" + stdin: | + test_func() { + local IFS=: + unset IFS + set -- python3_{11..14} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Brace expansion with local IFS=''" + stdin: | + test_func() { + local IFS="" + set -- python3_{11..14} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Mixed variable and brace expansion with local IFS=''" + stdin: | + test_func() { + local str="python3_" + local IFS="" + set -- ${str}{11..14} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Command substitution with local IFS=''" + stdin: | + test_func() { + local IFS="" + set -- $(echo "a b c") + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Arithmetic expansion with local IFS=''" + stdin: | + test_func() { + local IFS="" + set -- $((1+1)) $((2+2)) + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Special parameters with local IFS=''" + stdin: | + test_func() { + local IFS="" + set -- $* + echo "Count: $#" + } + test_func + + - name: "Complex mixed variable and brace expansion with local IFS=''" + stdin: | + test_func() { + local str="file" + local IFS="" + set -- ${str}{1..3}.txt + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Empty IFS with set -- variable splitting" + stdin: | + test_func() { + local var="a b c" + local IFS="" + set -- $var + echo "Count: $#" + echo "[$1]" + } + test_func + + - name: "Empty IFS with for-loop variable splitting" + stdin: | + test_func() { + local var="x y z" + local IFS="" + for word in $var; do + echo "[$word]" + done + } + test_func + + - name: "Empty IFS with comma brace expansion" + stdin: | + test_func() { + local IFS="" + set -- {a,b,c} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Empty IFS with sequence brace expansion" + stdin: | + test_func() { + local IFS="" + set -- {1..5} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Empty IFS with parameter default value expansion" + stdin: | + test_func() { + local var="a b c" + local IFS="" + set -- ${var:-default} + echo "Count: $#" + echo "[$1]" + } + test_func + + - name: "Empty IFS scoping across nested functions" + stdin: | + inner() { + local IFS="" + local var="a b c" + set -- $var + echo "Inner count: $#" + } + outer() { + local var="a b c" + set -- $var + echo "Outer before count: $#" + inner + set -- $var + echo "Outer after count: $#" + } + outer + + - name: "Empty IFS with positional parameters expansion" + stdin: | + test_func() { + local IFS="" + set -- a b c + set -- $@ + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + test_func + + - name: "Brace expansion preserves array[@] element separation" + stdin: | + arr=(1 2 3) + set -- {a,b}"${arr[@]}" + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion with array[*] concatenates elements" + stdin: | + arr=(1 2 3) + set -- {a,b}"${arr[*]}" + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion with unquoted array[@] splits elements" + stdin: | + arr=(1 2 3) + set -- {a,b}${arr[@]} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + + # Brace expansion happens before field splitting and its results are separate + # words regardless of IFS; a non-whitespace IFS that stays set must not + # merge or re-split them. + + - name: "Brace expansion stays separate under IFS=:" + stdin: | + IFS=: + set -- python3_{11..14} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion stays separate under IFS=, (the list separator)" + stdin: | + IFS=, + set -- {a,b}{c,d} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion under IFS=: still splits the expansions inside it" + stdin: | + IFS=: + v="1:2" + set -- {$v,x} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion under IFS=: with quoted array[*] inside the list" + stdin: | + IFS=: + arr=(1 2) + set -- {"${arr[*]}",z} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Brace expansion under IFS=: with tilde prefix" + env: + HOME: /h + stdin: | + IFS=: + set -- ~/{x,y} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + # `$*` and `${arr[*]}` join with the first character of IFS. An unset IFS + # means a space; an empty IFS means no separator at all. + + - name: "Empty IFS joins \"$*\" with no separator" + stdin: | + set -- a b c + IFS= + echo "[$*]" + x="$*" + echo "[$x]" + arr=(1 2 3) + echo "[${arr[*]}]" + y=${arr[*]} + echo "[$y]" + + - name: "Unset IFS joins \"$*\" with a space" + stdin: | + set -- a b c + unset IFS + echo "[$*]" + arr=(1 2 3) + echo "[${arr[*]}]" + + - name: "Empty IFS join inside a larger double-quoted word" + stdin: | + set -- a b c + IFS= + echo "[<$*>]" + echo "[${#*}]" + case "$*" in abc) echo joined;; *) echo "not joined";; esac + + # Field splitting applies only to the results of parameter expansion, command + # substitution and arithmetic expansion. Literal text in the word is never + # split, even when it contains IFS characters, but it is still globbed. + + - name: "Literal word text is not field-split" + stdin: | + IFS=: + set -- a:b + echo "Count: $#" + set -- {a:b,c} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + set -- a:$'b' + echo "Count: $#" + + - name: "Literal word text still globs under a non-default IFS" + test_files: + - path: "a:1" + - path: "a:2" + stdin: | + IFS=: + set -- a:* + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + IFS='*' + set -- a:* + echo "Count: $#" + + - name: "Expansion result adjacent to literal text" + stdin: | + IFS=: + x="1:2" + set -- $x:3 + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + x="1:" + set -- $x:3 + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + set -- pre:$x + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Default and alternate value words are split like any expansion result" + stdin: | + IFS=: + x= + set -- ${x:-a:b} + echo "Count: $#" + set -- ${x:-"a:b"} + echo "Count: $#" + set -- ${x:=c:d} + echo "Count: $#" + echo "[$x]" + set -- ${x:+e:f} + echo "Count: $#" + + - name: "compgen -W splits its word list on IFS" + stdin: | + IFS=: + compgen -W 'a:b c' + v="p:q" + compgen -W "$v" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/interactive.yaml b/userland/brush/source/brush-shell/tests/cases/compat/interactive.yaml new file mode 100644 index 0000000..26d6360 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/interactive.yaml @@ -0,0 +1,288 @@ +name: "Interactive" +incompatible_configs: ["sh"] +cases: + - name: "Basic interactive test" + pty: true + ignore_stdout: true # Needs investigation + ignore_whitespace: true # There's an extra newline for now in test output + stdin: | + #expect-prompt + echo hi + #send:Enter + #expect:hi + #expect-prompt + #send:Ctrl+D + + - name: "SIGINT ends the command an interactive shell is running" + known_failure: true # TODO(signals): an interactive brush goes on with the command it's running + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + kill -INT $$; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "Programs inherit an ignored SIGINT after the shell first waits for one" + skip: true # TODO(signals): flaky; tokio's SIGINT listener replaces the inherited ignore, but a wait for a program sets it up only once its select! polls it (in random order), so the ignore survives when the first program has already exited + pty: true + ignore_stdout: true # Prompts don't line up + # The shell replaces itself with one started ignoring SIGINT, given the flags the harness + # gives brush; it waits for one program, and then another sends itself SIGINT. + stdin: | + #expect-prompt + exec sh -c 'p=$1; shift; trap "" INT; exec env PS1="$p" "$0" --norc --noprofile -i "$@"' "$BASH" "$PS1" ${BRUSH_VERSION:+--no-config --disable-bracketed-paste --disable-color --input-backend=basic} + #send:Enter + #expect-prompt + sh -c 'exit 0'; sh -c 'kill -INT $$; touch survived.txt'; echo "done-$((2 + 2))" + #send:Enter + #expect:done-4 + #expect-prompt + #send:Ctrl+D + + - name: "An interactive shell's inherited _ isn't exported by its first prompt" + pty: true + ignore_stdout: true # Prompts don't line up + min_oracle_version: "5.3" # bash 5.2 keeps the inherited `_` exported until a command updates it + # The shell replaces itself with an interactive one that inherits `_`, given the flags the + # harness gives brush; that one's first command passes on whatever `_` it exports then. + stdin: | + #expect-prompt + exec env _=/inherited/underscore PS1="$PS1" "$BASH" --norc --noprofile -i ${BRUSH_VERSION:+--no-config --disable-bracketed-paste --disable-color --input-backend=basic} + #send:Enter + #expect-prompt + exec printenv _ > out.txt + #send:Enter + + - name: "Simple tab completion" + skip: true # TODO(input): Need to find the right way to enable this with a rich input backend. + pty: true + ignore_stdout: true + test_files: + - path: "test-file.txt" + contents: | + Hello, world. + stdin: | + #expect-prompt + cat test-file. + #send:Tab + #send:Enter + #expect:Hello, world. + #expect-prompt + #send:Ctrl+D + + - name: "PROMPT_COMMAND" + ignore_stderr: true + args: ["-i"] + stdin: | + declare -a calls=() + command=0 + + PROMPT_COMMAND='calls+=("$command")' + + command=first + command=second + command=third + + unset PROMPT_COMMAND + + declare -p calls + + # Reading commands from a pipe doesn't make a shell interactive, so no prompt is + # displayed and PROMPT_COMMAND never runs. + - name: "PROMPT_COMMAND in a non-interactive shell reading stdin" + args: ["-s"] + stdin: | + PROMPT_COMMAND='echo PROMPT-COMMAND-RAN' + echo one + echo two + + # Nor is the prompt itself expanded there. PS1 is left single-quoted so the command + # substitution runs at prompt time rather than at assignment time, and it writes to stderr + # to escape its own capture -- expanding this prompt is otherwise silent. + - name: "prompt expansion in a non-interactive shell reading stdin" + args: ["-s"] + stdin: | + PS1='$(echo PS1-EXPANDED >&2)$ ' + echo one + echo two + + # PS0 is expanded after a command is read and before it runs, and only by an interactive + # shell -- so its command substitution has no more business running here than PS1's does. + - name: "PS0 expansion in a non-interactive shell reading stdin" + args: ["-s"] + stdin: | + PS0='$(echo PS0-EXPANDED >&2)' + echo one + echo two + + # PROMPT_COMMAND runs between commands, so what it leaves in $?, PIPESTATUS, and $_ must + # not be visible to the next command. + - name: "PROMPT_COMMAND preserves the last argument in $_" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PROMPT_COMMAND='true PC-ARG' + true USER-ARG + echo "UNDERSCORE[$_]" + unset PROMPT_COMMAND + + # bash accepts a string or an indexed array in PROMPT_COMMAND and ignores any other type. + - name: "PROMPT_COMMAND as an indexed array" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PROMPT_COMMAND=('echo INDEXED-0' 'echo INDEXED-1') + echo command-ran + unset PROMPT_COMMAND + + # Each element is saved/restored on its own, so element N sees the status of the last + # *user* command rather than whatever element N-1 left behind. `(exit 6)` is what makes + # the two distinguishable: a leak from `false` would print 1 here, not 6. + - name: "PROMPT_COMMAND array elements each see the last command's status" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PROMPT_COMMAND=('false' 'echo PC-SAW[$?]') + (exit 6) + echo "AFTER[$?]" + unset PROMPT_COMMAND + + # An `exit` from PROMPT_COMMAND ends the shell then and there: the remaining array + # elements don't run, the command waiting on stdin never does, and the shell exits with + # the status `exit` was given. + - name: "exit from PROMPT_COMMAND ends the shell with its status" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PROMPT_COMMAND=('echo PC-A' 'exit 7' 'echo PC-C') + echo NEVER-RUNS + + - name: "PROMPT_COMMAND as an associative array is ignored" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + declare -A PROMPT_COMMAND + PROMPT_COMMAND[x]='echo ASSOC-X' + PROMPT_COMMAND[y]='echo ASSOC-Y' + echo command-ran + unset PROMPT_COMMAND + + # `return` outside a function or sourced script requests no control flow at all -- the + # builtin only does so when `in_function()` or `in_sourced_script()` -- so neither a + # PROMPT_COMMAND entry nor a line typed at the prompt can end the shell this way. bash + # reports the error and carries on, and the remaining array elements still run. + - name: "return does not exit the shell, from PROMPT_COMMAND or the prompt" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PROMPT_COMMAND=('echo PC-A' 'return' 'echo PC-B') + echo STILL-ALIVE + unset PROMPT_COMMAND + return + echo STILL-ALIVE-AFTER-BARE-RETURN + + # Expanding the prompt runs commands too (command substitutions in PS1), so what it leaves + # behind must not be visible to the next command either. + - name: "prompt expansion preserves the last argument in $_" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PS1='$(true PS1-ARG)$ ' + true USER-ARG + echo "UNDERSCORE[$_]" + + - name: "prompt expansion preserves $? and PIPESTATUS" + ignore_stderr: true # bash echoes prompts and job-control warnings on a non-tty `-i` shell + args: ["-i"] + stdin: | + PS1='$( (exit 9) | (exit 8) )$ ' + (exit 4) | (exit 5) + echo "AFTER[$?][${PIPESTATUS[*]}]" + + - name: "Interactive -c running a program" + known_failure: true # TODO(jobs): the program takes the terminal's foreground from a background process group, and is stopped by SIGTTOU for good + pty: true + timeout_in_seconds: 5 + args: ["-i", "-c", "cat /dev/null; echo done"] + stdin: | + #expect:done + + - name: "A fatal error in a function drops the rest of the interactive line" + known_failure: true # TODO(interactive): runs the rest of the line + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + f() { set -u; echo "$unset_var"; }; f; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal error in a builtin drops the rest of the interactive line" + known_failure: true # TODO(interactive): runs the rest of the line + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + set -u; compgen -W '$unset_var'; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal error in a builtin ends an interactive shell's subshell" + known_failure: true # TODO(interactive): the subshell carries on + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + ( set -u; compgen -W '$unset_var'; touch went-on.txt ) + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal expansion error drops the rest of the interactive line" + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + set -u; echo "$unset_var"; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal expansion error ends an interactive shell's subshell" + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + ( set -u; echo "$unset_var"; touch went-on.txt ) + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal error in eval drops only the eval'd code in an interactive shell" + pty: true + ignore_stdout: true # Prompts don't line up + stdin: | + #expect-prompt + set -u; eval 'echo "$unset_var"; touch skipped.txt'; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "A fatal error in a sourced file drops only that file in an interactive shell" + pty: true + ignore_stdout: true # Prompts don't line up + test_files: + - path: "script.sh" + contents: | + echo "$unset_var" + touch skipped.txt + stdin: | + #expect-prompt + set -u; . ./script.sh; touch went-on.txt + #send:Enter + #expect-prompt + #send:Ctrl+D diff --git a/userland/brush/source/brush-shell/tests/cases/compat/line_continuation.yaml b/userland/brush/source/brush-shell/tests/cases/compat/line_continuation.yaml new file mode 100644 index 0000000..a31dc0a --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/line_continuation.yaml @@ -0,0 +1,65 @@ +name: "Line continuations" +cases: + - name: "Line continuation inside operators" + stdin: | + true &\ + & echo and + false |\ + | echo or + echo pipe |\ + & cat + case x in x) echo case ;\ + ; esac + cat <\ + var1: $ref" + ref=var2 + echo "var1 after: $var1" + # To change what a nameref points to, use declare -n again + declare -n ref=var2 + echo "ref -> var2: $ref" + + - name: "Nameref chained (A -> B -> C)" + known_failure: true + stdin: | + ultimate="final_value" + declare -n middle=ultimate + declare -n top=middle + echo "top: $top" + echo "middle: $middle" + top="changed" + echo "ultimate: $ultimate" + + - name: "Nameref circular reference" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=a + echo "a: '${a}'" + echo "done" + + - name: "Nameref self-reference" + known_failure: true + ignore_stderr: true + stdin: | + declare -n self=self + echo "self: '${self}'" + echo "done" + + - name: "Nameref in function - pass by reference" + known_failure: true + stdin: | + modify_var() { + local -n ref=$1 + ref="modified by function" + } + + myvar="original" + echo "before: $myvar" + modify_var myvar + echo "after: $myvar" + + - name: "Nameref in function scope - local nameref to outer var" + known_failure: true + stdin: | + outer="outer_value" + inner_func() { + local -n ref=outer + echo "inside func: $ref" + ref="modified_by_func" + } + echo "before: $outer" + inner_func + echo "after: $outer" + + - name: "Nameref in function - multiple namerefs (swap)" + known_failure: true + stdin: | + swap() { + local -n x=$1 + local -n y=$2 + local tmp="$x" + x="$y" + y="$tmp" + } + a="alpha" + b="beta" + echo "before: a=$a b=$b" + swap a b + echo "after: a=$a b=$b" + + - name: "Nameref with export" + stdin: | + target="exported_val" + declare -n ref=target + export ref + declare -p target | grep -o 'x' | head -1 || echo "no export flag" + + - name: "Nameref unset through ref removes target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset ref + echo "after: target='${target}' ref='${ref}'" + # ref still exists as a nameref, but target is gone + declare -p ref 2>/dev/null && echo "ref still declared" + declare -p target 2>/dev/null || echo "target is gone" + + - name: "Nameref unset target directly" + known_failure: true + stdin: | + target="exists" + declare -n ref=target + echo "ref: $ref" + unset target + echo "ref after unset: '${ref}'" + + - name: "Nameref array append with +=" + known_failure: true + stdin: | + target=(a b) + declare -n ref=target + ref+=(c d) + echo "target: ${target[@]}" + echo "count: ${#target[@]}" + declare -p target + + - name: "Nameref array element unset" + known_failure: true + stdin: | + target=(a b c d) + declare -n ref=target + unset 'ref[2]' + declare -p target + + - name: "Nameref array slicing" + known_failure: true + stdin: | + target=(a b c d e) + declare -n ref=target + echo "slice: ${ref[@]:1:2}" + echo "all: ${ref[@]}" + echo "count: ${#ref[@]}" + + - name: "Nameref to array element" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref: $ref" + ref="changed" + echo "arr[2]: ${arr[2]}" + + - name: "Nameref in subshell" + known_failure: true + stdin: | + target="original" + declare -n ref=target + (ref="subshell_value"; echo "inside: $target") + echo "outside: $target" + + - name: "Nameref in command substitution" + known_failure: true + stdin: | + target="original" + declare -n ref=target + result=$(ref="cmd_sub_value"; echo "$target") + echo "result: $result" + echo "target: $target" + + - name: "Nameref with integer attribute" + known_failure: true + stdin: | + target=0 + declare -ni ref=target + ref="hello" + echo "target: $target" + ref=42 + echo "target: $target" + declare -p ref + declare -p target + + - name: "Nameref with set -u and unset target" + known_failure: true + ignore_stderr: true + stdin: | + set -u + declare -n ref=unset_target + echo "$ref" + echo "should not print" + + - name: "Nameref to empty string target name" + ignore_stderr: true + stdin: | + declare -n ref="" + echo "ref: '${ref}'" + ref="value" 2>/dev/null + echo "done" + + - name: "Multiple namerefs to same target" + known_failure: true + stdin: | + target="initial" + declare -n ref1=target + declare -n ref2=target + ref1="from_ref1" + echo "ref2: $ref2" + echo "target: $target" + ref2="from_ref2" + echo "ref1: $ref1" + echo "target: $target" + + - name: "Circular reference in assignment context" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=a + a="value" 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Nameref with prefix enumeration" + stdin: | + declare -n ref_one=target1 + declare -n ref_two=target2 + regular_ref_var="not a nameref" + echo "prefix: ${!ref_@}" + + - name: "Nameref with local in nested functions" + known_failure: true + stdin: | + outer() { + local myval="outer_value" + inner + echo "after inner: $myval" + } + inner() { + local -n ref=myval + echo "ref sees: $ref" + ref="modified_by_inner" + } + outer + + - name: "Nameref for-in loop changes target" + known_failure: true + stdin: | + var1="a" + var2="b" + var3="c" + declare -n ref + for ref in var1 var2 var3; do + echo "ref=$ref" + done + echo "var1=$var1 var2=$var2 var3=$var3" + + - name: "Nameref to array element - arithmetic increment" + known_failure: true + stdin: | + arr=(10 20 30 40) + declare -n ref='arr[2]' + echo "before: $ref" + (( ref++ )) + echo "after: $ref" + echo "arr[2]: ${arr[2]}" + + - name: "Nameref to array element - arithmetic compound assign" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[1]' + echo "before: $ref" + (( ref += 50 )) + echo "after: $ref" + echo "arr[1]: ${arr[1]}" + + - name: "Nameref to array element - arithmetic expression" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[2]' + (( ref = ref * 2 )) + echo "arr[2]: ${arr[2]}" + + - name: "Nameref to array element - string append" + known_failure: true + stdin: | + arr=(hello world) + declare -n ref='arr[0]' + ref+=" there" + echo "arr[0]: ${arr[0]}" + + - name: "Nameref to array element - length" + known_failure: true + stdin: | + arr=("short" "a longer string here" "x") + declare -n ref='arr[1]' + echo "len: ${#ref}" + + - name: "Nameref to array element - parameter transformations" + known_failure: true + stdin: | + arr=("hello world" "foo") + declare -n ref='arr[0]' + echo "upper: ${ref^^}" + echo "Q: ${ref@Q}" + + - name: "Nameref to array element - substring" + known_failure: true + stdin: | + arr=("abcdefgh" "xyz") + declare -n ref='arr[0]' + echo "sub: ${ref:1:3}" + + - name: "Nameref to array element - pattern substitution" + known_failure: true + stdin: | + arr=("hello world hello" "x") + declare -n ref='arr[0]' + echo "first: ${ref/hello/bye}" + echo "all: ${ref//hello/bye}" + + - name: "Nameref to array element - default value" + known_failure: true + stdin: | + arr=("" "val") + declare -n ref='arr[0]' + echo "default: ${ref:-fallback}" + + - name: "Nameref to array element - negative index" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref='arr[-1]' + echo "ref: $ref" + + - name: "Nameref to associative array element" + known_failure: true + stdin: | + declare -A map=([foo]=bar [baz]=qux) + declare -n ref='map[foo]' + echo "ref: $ref" + ref="changed" + echo "map_foo: ${map[foo]}" + + - name: "Nameref to array element - unset through ref" + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref='arr[2]' + unset ref + declare -p arr + + - name: "Nameref chain 4 levels" + known_failure: true + stdin: | + final="deep" + declare -n c=final + declare -n b=c + declare -n a=b + echo "a: $a" + a="changed" + echo "final: $final" + + - name: "Nameref 3-level chain to array element" + known_failure: true + stdin: | + arr=(A B C) + declare -n mid='arr[1]' + declare -n top=mid + echo "top: $top" + top="CHANGED" + echo "arr[1]: ${arr[1]}" + + - name: "Nameref to associative array - keys and values" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]=2) + declare -n ref=map + echo "x: ${ref[x]}" + ref[z]=3 + echo "z: ${map[z]}" + for k in "${!ref[@]}"; do echo "key=$k"; done | sort + + - name: "Nameref - declare -p shows nameref attribute" + stdin: | + target="hello" + declare -n ref=target + declare -p ref + declare -p target + + - name: "Nameref - test -v follows nameref" + known_failure: true + stdin: | + declare -n ref=nonesuch + [[ -v ref ]] && echo "set" || echo "unset" + nonesuch="now" + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to array - length and keys" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref=arr + echo "len: ${#ref[@]}" + echo "keys: ${!ref[@]}" + echo "elem0len: ${#ref}" + + - name: "Nameref with printf -v" + known_failure: true + stdin: | + declare -n ref=formatted + printf -v ref "num=%d" 42 + echo "formatted: $formatted" + + - name: "Nameref - declare +n removes attribute" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: $ref" + declare +n ref + echo "after: $ref" + declare -p ref + + - name: "Nameref to IFS" + known_failure: true + stdin: | + declare -n ref=IFS + saved="$IFS" + ref=":" + echo "ifs: '$IFS'" + IFS="$saved" + + - name: "Nameref to array - whole array assignment" + known_failure: true + stdin: | + declare -n ref=myarr + ref=(x y z) + declare -p myarr + + - name: "Nameref local -n recursive function" + known_failure: true + stdin: | + count_down() { + local v=$1 + if (( v <= 0 )); then return; fi + local -n o=result + o+="$v " + count_down $((v - 1)) + } + result="" + count_down 3 + echo "result: $result" + + - name: "Nameref to array - negative index access" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref=arr + echo "last: ${ref[-1]}" + + - name: "Nameref in eval" + known_failure: true + stdin: | + target="eval_val" + declare -n ref=target + eval 'echo "eval: $ref"' + + - name: "Nameref in here-string" + known_failure: true + stdin: | + target="here_data" + declare -n ref=target + cat <<< "$ref" + + - name: "Nameref in extended test operators" + known_failure: true + stdin: | + target="abc" + declare -n ref=target + [[ $ref == abc ]] && echo "eq ok" + [[ $ref =~ ^a.c$ ]] && echo "regex ok" + [[ $ref < bcd ]] && echo "lt ok" + + - name: "Nameref to sparse array" + known_failure: true + stdin: | + declare -a arr + arr[5]=five + arr[10]=ten + declare -n ref=arr + echo "keys: ${!ref[@]}" + echo "len: ${#ref[@]}" + echo "elem5: ${ref[5]}" + + - name: "Nameref concurrent writes through two refs" + known_failure: true + stdin: | + target="init" + declare -n r1=target + declare -n r2=target + r1+="_one" + r2+="_two" + echo "target: $target" + + - name: "Nameref to array element - ternary in arithmetic" + stdin: | + arr=(0 1 0) + declare -n ref='arr[1]' + echo "ternary: $(( ref ? 100 : 200 ))" + + - name: "Nameref chain to assoc array element" + known_failure: true + stdin: | + declare -A map=([k]=v) + declare -n mid='map[k]' + declare -n top=mid + echo "top: $top" + + - name: "Nameref to array element - set test" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + echo "set: ${ref+SET}" + unset 'arr[1]' + echo "unset: ${ref+SET}" + + - name: "Nameref local -n overrides outer nameref" + known_failure: true + stdin: | + a="A" + b="B" + declare -n ref=a + f() { + local -n ref=b + echo "inner: $ref" + } + f + echo "outer: $ref" + + - name: "Nameref target lowercase transform" + known_failure: true + stdin: | + declare -l target="" + declare -n ref=target + ref="HELLO" + echo "target: $target" + + - name: "Nameref export -n unexports target" + known_failure: true + stdin: | + export target="val" + declare -n ref=target + export -n ref + env | grep "^target=" | head -1 || echo "not exported" + + - name: "Nameref to array - push via index" + known_failure: true + stdin: | + arr=(a b) + declare -n ref=arr + ref[${#ref[@]}]="c" + echo "arr: ${ref[@]}" + echo "len: ${#ref[@]}" + + - name: "Nameref with readonly target - write fails" + ignore_stderr: true + stdin: | + target="immutable" + declare -n ref=target + readonly ref + ref="attempt" 2>/dev/null + echo "exit: $?" + echo "target: $target" + + - name: "Nameref - declare -a through nameref applies to target" + known_failure: true + stdin: | + declare -n ref=myarr + declare -a ref + ref=(1 2 3) + declare -p myarr 2>/dev/null || echo "not found" + + - name: "Nameref - declare -A through nameref applies to target" + known_failure: true + stdin: | + declare -n ref=mymap + declare -A ref + ref=([a]=1 [b]=2) + echo "a: ${mymap[a]}" + echo "b: ${mymap[b]}" + + - name: "Nameref to array element - test -v treats resolved name literally" + known_failure: true + stdin: | + # In bash, [[ -v ref ]] where ref→arr[2] looks for a variable literally + # named "arr[2]", not array element arr at index 2. So it's always unset. + arr=(a b c d) + declare -n ref='arr[2]' + echo "ref expands to: $ref" + [[ -v ref ]] && echo "set" || echo "unset" + unset 'arr[2]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to array element - test -v with nonexistent index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[5]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to arr[@] expands all elements" + known_failure: true + stdin: | + arr=(x y z) + declare -n ref='arr[@]' + echo "ref: $ref" + + - name: "Nameref circular with 3 nodes" + known_failure: true + ignore_stderr: true + stdin: | + declare -n c1=c2 + declare -n c2=c3 + declare -n c3=c1 + echo "c1: '${c1}'" + echo "exit: $?" + + - name: "Nameref in case pattern" + known_failure: true + stdin: | + target="hello" + declare -n ref=target + case $ref in + hello) echo "matched hello" ;; + *) echo "no match" ;; + esac + + - name: "Nameref local shadowing in nested functions" + known_failure: true + stdin: | + a="A" + b="B" + f() { + local -n ref=a + g + echo "f ref: $ref" + } + g() { + local -n ref=b + echo "g ref: $ref" + ref="modified_B" + } + f + echo "a=$a b=$b" + + - name: "Nameref to special variable RANDOM" + known_failure: true + stdin: | + declare -n ref=RANDOM + r1=$ref + r2=$ref + [[ "$r1" != "$r2" ]] && echo "different (dynamic)" || echo "same" + + - name: "Nameref for-in loop retargets through iteration" + known_failure: true + stdin: | + var1="A" + var2="B" + var3="C" + declare -n ref + for ref in var1 var2 var3; do + echo "ref=$ref" + done + echo "var1=$var1 var2=$var2 var3=$var3" + + # + # Additional edge cases: bare declare -n, invalid targets + # + + - name: "Nameref declared without value - bare declare -n" + known_failure: true + stdin: | + declare -n ref + echo "ref: '${ref}'" + declare -p ref 2>/dev/null + ref="value" + echo "ref after assign: '${ref}'" + declare -p ref 2>/dev/null + + - name: "Nameref declared without value - expansion behavior" + stdin: | + declare -n ref + echo "set: ${ref+SET}" + echo "default: ${ref:-fallback}" + echo "length: ${#ref}" + + - name: "Nameref to numeric-only target name" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref=123 + echo "exit: $?" + echo "ref: '${ref}'" + echo "done" + + - name: "Nameref to name with special characters" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref="a-b" + echo "exit1: $?" + declare -n ref2="a.b" + echo "exit2: $?" + declare -n ref3="a+b" + echo "exit3: $?" + echo "done" + + - name: "Nameref to name with spaces" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref="a b" + echo "exit: $?" + echo "done" + + - name: "Nameref to positional parameter name" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref=1 + echo "exit: $?" + echo "done" + + - name: "Nameref to @ or *" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref1="@" + echo "exit1: $?" + declare -n ref2="*" + echo "exit2: $?" + echo "done" + + # + # Assignment semantics clarity + # + + - name: "Nameref assignment writes to target not retargets" + known_failure: true + stdin: | + var1="first" + var2="second" + declare -n ref=var1 + ref=var2 + echo "var1: $var1" + echo "ref: $ref" + declare -p ref + declare -p var1 + + # + # unset -n edge cases + # + + - name: "Unset -n on non-nameref variable" + known_failure: true + stdin: | + var="value" + unset -n var + echo "var: '${var}'" + declare -p var 2>/dev/null || echo "var is gone" + + - name: "Unset vs unset -n on same nameref" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset ref + echo "after unset ref: target='${target}' ref='${ref}'" + target="restored" + echo "target restored: ref=$ref" + + - name: "Unset -n then recreate nameref" + known_failure: true + stdin: | + target="hello" + declare -n ref=target + echo "before: $ref" + unset -n ref + echo "after unset -n:" + declare -p ref 2>/dev/null || echo "ref gone" + declare -n ref=target + echo "recreated: $ref" + + - name: "Unset nameref where target is already unset" + known_failure: true + stdin: | + declare -n ref=nonexistent + unset ref + echo "exit: $?" + declare -p ref 2>/dev/null && echo "ref still exists" || echo "ref gone" + + - name: "Unset -n nameref where target is already unset" + known_failure: true + stdin: | + declare -n ref=nonexistent + unset -n ref + echo "exit: $?" + declare -p ref 2>/dev/null || echo "ref gone" + + - name: "Unset -v on nameref removes target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + unset -v ref + echo "target: '${target}'" + declare -p ref 2>/dev/null && echo "ref exists" + declare -p target 2>/dev/null || echo "target gone" + + - name: "Unset nameref in function scope" + known_failure: true + stdin: | + target="global" + f() { + local -n ref=target + unset ref + echo "inside f: target='${target}'" + } + f + echo "outside: target='${target}'" + + # + # declare flag combinations with -n + # + + - name: "Nameref with uppercase attribute -u" + known_failure: true + stdin: | + declare -u target + declare -n ref=target + ref="hello world" + echo "target: $target" + echo "ref: $ref" + + - name: "Nameref combined -nx export" + known_failure: true + ignore_stderr: true + stdin: | + declare -nx ref=MYVAR + ref="exported_value" + echo "MYVAR: $MYVAR" + declare -p ref + declare -p MYVAR 2>/dev/null + + - name: "Nameref combined -na conflict" + known_failure: true + ignore_stderr: true + stdin: | + declare -na ref=target + echo "exit: $?" + declare -p ref 2>/dev/null + + - name: "Nameref combined -nA conflict" + known_failure: true + ignore_stderr: true + stdin: | + declare -nA ref=target + echo "exit: $?" + declare -p ref 2>/dev/null + + - name: "Declare -rn readonly nameref" + known_failure: true + ignore_stderr: true + stdin: | + target="val" + declare -rn ref=target + echo "ref: $ref" + declare -n ref=other 2>/dev/null + echo "retarget exit: $?" + unset -n ref 2>/dev/null + echo "unset -n exit: $?" + declare -p ref + + # + # Readonly deeper edge cases + # + + - name: "Readonly nameref - cannot unset -n" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=target + readonly ref + unset -n ref 2>/dev/null + echo "exit: $?" + declare -p ref + + - name: "Make target readonly through nameref" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + declare -n ref=target + readonly ref + declare -p target + target="new" 2>/dev/null + echo "target write exit: $?" + + # + # Export deeper edge cases + # + + - name: "Export through nameref - child sees it" + known_failure: true + stdin: | + declare -n ref=MY_EXPORT + ref="exported_value" + export ref + bash -c 'echo "child: $MY_EXPORT"' + + - name: "Unexport through nameref with declare +x" + known_failure: true + stdin: | + export target="was_exported" + declare -n ref=target + declare +x ref + env | grep "^target=" || echo "not exported" + echo "target: $target" + + # + # Parameter expansion - missing operators + # + + - name: "Nameref with := assign default creates target" + known_failure: true + stdin: | + declare -n ref=brand_new + echo "before: '${brand_new}'" + echo "assign: ${ref:=created}" + echo "after: $brand_new" + declare -p brand_new + + - name: "Nameref with :? error on unset" + known_failure: true # error prefix differs: brush prints "error:" vs bash prints "bash: line N:" + ignore_stderr: true + stdin: | + declare -n ref=missing + ( echo "${ref:?variable is required}" ) 2>&1 | tail -1 + echo "outer continues" + + - name: "Nameref with @E escape" + known_failure: true + stdin: | + target='hello\tworld\n' + declare -n ref=target + echo "E: ${ref@E}" + + - name: "Nameref substring with negatives" + known_failure: true + stdin: | + target="abcdefghij" + declare -n ref=target + echo "offset 3: ${ref:3}" + echo "offset 3 len 4: ${ref:3:4}" + echo "negative offset: ${ref: -3}" + echo "negative offset len: ${ref: -3:2}" + + - name: "Nameref with case modification operators" + known_failure: true + stdin: | + target="hello world" + declare -n ref=target + echo "^: ${ref^}" + echo "^^: ${ref^^}" + echo ",: ${ref,}" + echo ",,: ${ref,,}" + + - name: "Nameref with pattern removal prefix and suffix anchors" + known_failure: true + stdin: | + target="hello hello hello" + declare -n ref=target + echo "prefix: ${ref/#hello/bye}" + echo "suffix: ${ref/%hello/bye}" + + # + # Indirection ${!ref} interaction + # + + - name: "Nameref indirection on chain" + known_failure: true + stdin: | + ultimate="deep_value" + declare -n middle=ultimate + declare -n top=middle + echo "!top: ${!top}" + echo "!middle: ${!middle}" + echo "top: $top" + + - name: "Nameref indirection - ${!ref} on non-nameref" + known_failure: true + stdin: | + target="value" + ref=target + echo "!ref (not nameref): ${!ref}" + declare -n nref=target + echo "!nref (nameref): ${!nref}" + + - name: "Nameref indirection with array nameref keys" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + echo "!ref[@]: ${!ref[@]}" + + - name: "Nameref indirection on nameref to array element" + known_failure: true + stdin: | + arr=(x y z) + declare -n ref='arr[1]' + echo "!ref: ${!ref}" + + # + # Arithmetic deeper + # + + - name: "Nameref in (( )) comparison" + stdin: | + target=42 + declare -n ref=target + (( ref == 42 )) && echo "equal" + (( ref > 10 )) && echo "greater" + (( ref < 100 )) && echo "less" + + - name: "Nameref in $((ref)) expression" + stdin: | + target=7 + declare -n ref=target + echo "expr: $(( ref + 3 ))" + echo "expr2: $(( ref * ref ))" + + - name: "Nameref array element in arithmetic for loop" + known_failure: true + stdin: | + arr=(0) + declare -n ref='arr[0]' + for (( ref=1; ref<=5; ref++ )); do + echo "ref=$ref arr[0]=${arr[0]}" + done + + - name: "Nameref in arithmetic assignment chain" + known_failure: true + stdin: | + a=0 + b=0 + declare -n refa=a + declare -n refb=b + (( refa = refb = 42 )) + echo "a=$a b=$b" + + # + # Execution contexts + # + + - name: "Nameref in pipe components" + known_failure: true + stdin: | + target="pipe_test" + declare -n ref=target + echo "$ref" | cat + echo "$ref" | { read line; echo "pipe read: $line"; } + + - name: "Nameref in trap handler" + known_failure: true + stdin: | + target="before_trap" + declare -n ref=target + trap 'echo "trap: ref=$ref target=$target"' EXIT + ref="after_trap" + + - name: "Nameref in brace group" + known_failure: true + stdin: | + target="brace" + declare -n ref=target + { ref="modified"; echo "inside: $target"; } + echo "outside: $target" + + - name: "Nameref in while loop" + known_failure: true + stdin: | + target=0 + declare -n ref=target + while (( ref < 3 )); do + echo "loop: ref=$ref" + (( ref++ )) + done + echo "final target: $target" + + - name: "Nameref in until loop" + known_failure: true + stdin: | + target=3 + declare -n ref=target + until (( ref <= 0 )); do + echo "until: ref=$ref" + (( ref-- )) + done + echo "final target: $target" + + - name: "Nameref in here-doc" + known_failure: true + stdin: | + target="heredoc_value" + declare -n ref=target + cat < 3 )); then + echo "greater" + fi + if [[ "$ref" == "5" ]]; then + echo "string match" + fi + + - name: "Nameref in background job" + known_failure: true + stdin: | + target="bg_test" + declare -n ref=target + { echo "bg ref: $ref"; } & + wait + echo "main target: $target" + + # + # Function scope deeper + # + + - name: "Nameref in function - target name same as local in caller" + known_failure: true + stdin: | + f() { + local val="inner" + g val + echo "after g: val=$val" + } + g() { + local -n ref=$1 + ref="modified" + } + f + + - name: "Nameref scope - same name different targets in nested calls" + known_failure: true + stdin: | + a="A" + b="B" + outer() { + local -n ref=a + echo "outer before: $ref" + inner + echo "outer after: $ref" + } + inner() { + local -n ref=b + echo "inner: $ref" + ref="B_modified" + } + outer + echo "a=$a b=$b" + + - name: "Nameref in function - ref to caller local" + known_failure: true + stdin: | + wrapper() { + local secret="hidden" + revealer secret + echo "after: secret=$secret" + } + revealer() { + local -n ref=$1 + echo "revealed: $ref" + ref="exposed" + } + wrapper + + - name: "Nameref in function - local var shadows nameref target" + known_failure: true + stdin: | + target="global_target" + f() { + local target="local_target" + local -n ref=target + echo "ref: $ref" + ref="modified" + echo "local target: $target" + } + f + echo "global target: $target" + + - name: "Nameref - return value pattern via nameref" + known_failure: true + stdin: | + compute() { + local -n _result=$1 + _result=$(( 6 * 7 )) + } + answer=0 + compute answer + echo "answer: $answer" + + - name: "Nameref - multiple out params via namerefs" + known_failure: true + stdin: | + divide() { + local -n _quotient=$1 + local -n _remainder=$2 + _quotient=$(( $3 / $4 )) + _remainder=$(( $3 % $4 )) + } + q=0 + r=0 + divide q r 17 5 + echo "17/5 = $q remainder $r" + + - name: "Nameref with declare -g in function" + known_failure: true + stdin: | + f() { + declare -gn ref=global_target + ref="from_function" + } + f + echo "global_target: $global_target" + declare -p ref 2>/dev/null && echo "ref exists globally" + + - name: "Nameref global pointing to local" + known_failure: true + stdin: | + declare -n gref=local_var + f() { + local local_var="local_value" + echo "gref in f: $gref" + } + f + echo "gref outside: '${gref}'" + + # + # Circular/chain deeper + # + + - name: "Circular nameref - 4 node cycle" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=c + declare -n c=d + declare -n d=a + echo "a: '${a}'" + echo "done" + + - name: "Chain where intermediate target is unset" + known_failure: true + stdin: | + declare -n top=middle + declare -n middle=bottom + bottom="value" + echo "top: $top" + unset -n middle + echo "top after: '${top}'" + + - name: "Nameref chain - declare -p at each level" + stdin: | + val="hello" + declare -n b=val + declare -n a=b + declare -p a + declare -p b + declare -p val + + - name: "Nameref self-reference - write attempt" + ignore_stderr: true + stdin: | + declare -n self=self + self="value" 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Nameref self-reference - declare -p" + known_failure: true + ignore_stderr: true + stdin: | + declare -n self=self + declare -p self + + - name: "Circular nameref - write attempt" + known_failure: true + ignore_stderr: true + stdin: | + declare -n x=y + declare -n y=x + x="value" 2>/dev/null + echo "write exit: $?" + echo "done" + + # + # Special variables + # + + - name: "Nameref to SECONDS" + stdin: | + SECONDS=0 + declare -n ref=SECONDS + echo "type check: $(( ref >= 0 ? 1 : 0 ))" + + - name: "Nameref to FUNCNAME in function" + known_failure: true + stdin: | + f() { + local -n ref=FUNCNAME + echo "funcname[0]: ${ref[0]}" + } + f + + - name: "Nameref to BASH_REMATCH" + known_failure: true + stdin: | + [[ "hello123world" =~ ([0-9]+) ]] + declare -n ref=BASH_REMATCH + echo "match: ${ref[0]}" + echo "group1: ${ref[1]}" + + - name: "Nameref to REPLY" + known_failure: true + stdin: | + declare -n ref=REPLY + read ref <<< "input_data" + echo "REPLY: $REPLY" + + - name: "Nameref to OPTIND" + known_failure: true + stdin: | + OPTIND=1 + declare -n ref=OPTIND + echo "optind: $ref" + ref=5 + echo "OPTIND: $OPTIND" + + # + # Extended test edge cases + # + + - name: "Nameref -v with nameref chain" + known_failure: true + stdin: | + val="exists" + declare -n mid=val + declare -n top=mid + [[ -v top ]] && echo "set" || echo "unset" + unset val + [[ -v top ]] && echo "set" || echo "unset" + + - name: "Nameref -R on regular variable" + stdin: | + regular="value" + [[ -R regular ]] && echo "is nameref" || echo "not nameref" + + - name: "Nameref -R on unset variable" + stdin: | + [[ -R nonexistent ]] && echo "is nameref" || echo "not nameref" + + - name: "Nameref in test -z / -n" + known_failure: true + stdin: | + target="" + declare -n ref=target + [[ -z "$ref" ]] && echo "empty: yes" + [[ -n "$ref" ]] && echo "nonempty: yes" || echo "nonempty: no" + target="data" + [[ -z "$ref" ]] && echo "empty: yes" || echo "empty: no" + [[ -n "$ref" ]] && echo "nonempty: yes" + + # + # Compound assignment through nameref + # + + - name: "Nameref compound array assignment to nonexistent" + known_failure: true + stdin: | + declare -n ref=newarray + ref=(alpha beta gamma) + declare -p newarray + + - name: "Nameref compound assignment replaces" + known_failure: true + stdin: | + target=(old1 old2 old3) + declare -n ref=target + ref=(new1 new2) + declare -p target + + - name: "Nameref += integer append on integer var" + known_failure: true + stdin: | + declare -i target=10 + declare -n ref=target + ref+=5 + echo "target: $target" + ref+=3 + echo "target: $target" + + # + # Array operations deeper + # + + - name: "Nameref to array - copy array" + known_failure: true + stdin: | + src=(1 2 3) + declare -n ref=src + dst=("${ref[@]}") + declare -p dst + + - name: "Nameref to array - declare -p shows array type" + stdin: | + declare -a arr=(x y z) + declare -n ref=arr + declare -p ref + declare -p arr + + - name: "Nameref to assoc array - declare -p shows assoc type" + stdin: | + declare -A map=([a]=1) + declare -n ref=map + declare -p ref + declare -p map + + - name: "Nameref to array - string operations on element 0" + known_failure: true + stdin: | + arr=("hello world" "foo") + declare -n ref=arr + echo "upper: ${ref^^}" + echo "length: ${#ref}" + echo "sub: ${ref:0:5}" + + - name: "Nameref to array - append to specific index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + ref[1]+="X" + declare -p arr + + - name: "Nameref to associative array - delete key" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]=2 [z]=3) + declare -n ref=map + unset 'ref[y]' + for k in "${!ref[@]}"; do echo "$k=${ref[$k]}"; done | sort + + - name: "Nameref to associative array - key existence" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]="") + declare -n ref=map + echo "x set: ${ref[x]+yes}" + echo "y set: ${ref[y]+yes}" + echo "z set: ${ref[z]+yes}" + + - name: "Nameref to array element - computed index" + known_failure: true + stdin: | + arr=(10 20 30 40 50) + i=3 + declare -n ref="arr[$i]" + echo "ref: $ref" + ref=99 + echo "arr[3]: ${arr[3]}" + + - name: "Nameref to array element - out of bounds" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[10]' + echo "ref: '${ref}'" + ref="new" + declare -p arr + + - name: "Nameref to assoc array - missing key then assign" + known_failure: true + stdin: | + declare -A map=([x]=1) + declare -n ref='map[missing]' + echo "ref: '${ref}'" + ref="new" + echo "map[missing]: ${map[missing]}" + + - name: "Nameref to arr[@] - write attempt" + known_failure: true + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[@]' + ref="new" 2>/dev/null + echo "exit: $?" + declare -p arr + + - name: "Nameref to arr[*] expansion" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[*]' + echo "ref: $ref" + + # + # Variable attributes through ref + # + + - name: "Declare -i through nameref adds integer to target" + known_failure: true + stdin: | + declare -n ref=myvar + declare -i ref + ref="hello" + echo "myvar: $myvar" + ref=42 + echo "myvar: $myvar" + declare -p myvar + + - name: "Declare -u through nameref adds uppercase to target" + known_failure: true + stdin: | + declare -n ref=myvar + declare -u ref + ref="hello" + echo "myvar: $myvar" + declare -p myvar + + - name: "Declare -x through nameref exports target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + declare -x ref + declare -p target + + # + # Typeset compatibility + # + + - name: "Typeset -n creates nameref" + known_failure: true + stdin: | + target="typeset_val" + typeset -n ref=target + echo "ref: $ref" + declare -p ref + + - name: "Typeset +n removes nameref attribute" + known_failure: true + stdin: | + target="value" + typeset -n ref=target + echo "before: $ref" + typeset +n ref + echo "after: $ref" + declare -p ref + + # + # For-in loop retarget + mutation + # + + - name: "For loop nameref - modify each target" + known_failure: true + stdin: | + a=1 + b=2 + c=3 + declare -n ref + for ref in a b c; do + ref=$(( ref * 10 )) + done + echo "a=$a b=$b c=$c" + + # + # Empty/whitespace values + # + + - name: "Nameref to variable with empty string value" + known_failure: true + stdin: | + target="" + declare -n ref=target + echo "ref: '$ref'" + echo "set: ${ref+SET}" + echo "default: ${ref:-DEFAULT}" + echo "nocolon default: ${ref-NOCOLON}" + + - name: "Nameref to variable with newline value" + known_failure: true + stdin: | + target=$'line1\nline2' + declare -n ref=target + echo "ref: $ref" + echo "---" + echo "$ref" | wc -l + + # + # set -u edge cases + # + + - name: "Nameref to set empty var with set -u" + known_failure: true + stdin: | + set -u + target="" + declare -n ref=target + echo "ref: '$ref'" + echo "survived" + + - name: "Nameref with set -u and default expansion" + known_failure: true + stdin: | + set -u + declare -n ref=missing + echo "default: ${ref:-safe}" + echo "survived" + + - name: "Nameref with set -u - ${ref+x} does not error" + known_failure: true + stdin: | + set -u + declare -n ref=missing + echo "set test: ${ref+SET}" + echo "survived" + + # + # Miscellaneous edge cases + # + + - name: "Nameref multiple declare -n retargets" + known_failure: true + stdin: | + a="A" + b="B" + declare -n ref=a + echo "first: $ref" + declare -n ref=b + echo "second: $ref" + + - name: "Nameref in array subscript context" + stdin: | + idx=2 + declare -n ref=idx + arr=(a b c d e) + echo "arr[ref]: ${arr[$ref]}" + + - name: "Two namerefs to same target - unset via one access via other" + known_failure: true + stdin: | + target="value" + declare -n ref1=target + declare -n ref2=target + unset ref1 + echo "ref2: '${ref2}'" + echo "target: '${target}'" + + - name: "Nameref with word splitting" + known_failure: true + stdin: | + target="one two three" + declare -n ref=target + for word in $ref; do echo "word: $word"; done + echo "---" + for word in "$ref"; do echo "word: $word"; done + + - name: "Nameref to _ (underscore variable)" + known_failure: true + stdin: | + _="test_value" + declare -n ref=_ + echo "ref: $ref" + + - name: "Nameref preserves target type across reassignment" + known_failure: true + stdin: | + declare -a arr=(x y) + declare -n ref=arr + ref=(a b c) + declare -p arr + + - name: "Nameref with command in value is literal" + known_failure: true + stdin: | + target='$(echo injected)' + declare -n ref=target + echo "ref: $ref" + + - name: "Nameref target created by assignment" + known_failure: true + stdin: | + declare -n ref=brand_new_var + ref="created" + echo "brand_new_var: $brand_new_var" + declare -p brand_new_var + + - name: "Nameref array index assignment" + known_failure: true + stdin: | + declare -a arr=(a b c) + declare -n ref=arr + ref[0]="X" + ref[2]="Z" + declare -p arr + + - name: "Nameref assoc array assignment" + known_failure: true + stdin: | + declare -A map + declare -n ref=map + ref[key1]="val1" + ref[key2]="val2" + for k in "${!map[@]}"; do echo "$k=${map[$k]}"; done | sort + + - name: "Nameref in source command" + known_failure: true + stdin: | + tmp=$(mktemp) + echo 'declare -n ref=target; ref="from_source"' > "$tmp" + target="original" + source "$tmp" + echo "target: $target" + rm "$tmp" + + - name: "Nameref to integer variable - arithmetic coercion" + known_failure: true + stdin: | + declare -i num=10 + declare -n ref=num + ref=20+5 + echo "num: $num" + ref="3*4" + echo "num after expr: $num" + + - name: "Nameref with declare giving value" + known_failure: true + stdin: | + declare -n ref=target + declare ref="value" + echo "target: ${target}" + declare -p target 2>/dev/null + declare -p ref + + - name: "Read -r into nameref" + known_failure: true + stdin: | + declare -n ref=result + read -r ref <<< 'hello\tworld' + echo "result: $result" + + - name: "Nameref in process substitution" + known_failure: true + stdin: | + target="proc_sub" + declare -n ref=target + cat <(echo "$ref") + + - name: "Nameref in environment prefix assignment" + known_failure: true + stdin: | + target="old" + declare -n ref=target + ref=new printenv target 2>/dev/null || echo "ref=new as prefix" + echo "target after: $target" + + - name: "Local non-nameref shadows outer nameref" + known_failure: true + stdin: | + f() { + local -n ref=target + g + echo "f ref: $ref" + } + g() { + local ref="plain" + echo "g ref: $ref" + } + target="T" + f + echo "target: $target" + + - name: "Local -n referencing same-scope local" + known_failure: true + stdin: | + f() { + local target="local_val" + local -n ref=target + echo "ref: $ref" + ref="changed" + echo "target: $target" + } + f + + - name: "Nameref with array slice reassignment" + known_failure: true + stdin: | + arr=(1 2 3) + declare -n ref=arr + echo "before: ${ref[@]}" + ref=("${ref[@]:1}") + echo "after: ${ref[@]}" + + - name: "Declare -p -n lists only namerefs" + stdin: | + declare -n r1=a + declare -n r2=b + regular="not_ref" + declare -p -n 2>/dev/null | sort + + # + # Integer attribute arithmetic evaluation on assignment + # (Not nameref-specific but exercises same code path) + # + + - name: "Integer variable evaluates arithmetic on assignment" + known_failure: true + stdin: | + declare -i x + x=20+5 + echo "x: $x" + x="3*4" + echo "x: $x" + x="10/3" + echo "x: $x" + x="y=7, y+3" + echo "x: $x" + + - name: "Integer variable with variable references in expression" + known_failure: true + stdin: | + declare -i x + y=10 + x=y+5 + echo "x: $x" + x="y*2" + echo "x: $x" + + - name: "Integer variable += with arithmetic" + known_failure: true + stdin: | + declare -i x=10 + x+=5 + echo "x: $x" + x+=3*2 + echo "x: $x" + + - name: "Integer variable through nameref evaluates arithmetic" + known_failure: true + stdin: | + declare -i num=10 + declare -n ref=num + ref=20+5 + echo "num: $num" + ref="3*4" + echo "num: $num" + ref+=10 + echo "num: $num" + + # + # :? error format (non-nameref cases too) + # + + - name: "Expansion :? error with regular variable" + ignore_stderr: true + stdin: | + unset missing + ( echo "${missing:?custom error}" ) 2>/dev/null + echo "exit: $?" + + - name: "Expansion :? with no message" + ignore_stderr: true + stdin: | + unset missing + ( echo "${missing:?}" ) 2>/dev/null + echo "exit: $?" + + # + # Self-reference edge cases beyond basic declaration + # + + - name: "Nameref retarget to self after creation is rejected" + known_failure: true + ignore_stderr: true + stdin: | + target="val" + declare -n ref=target + echo "ref: $ref" + declare -n ref=ref + echo "exit: $?" + echo "ref: $ref" + declare -p ref + + - name: "Circular via retarget" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=c + c="value" + echo "a: $a" + # Now make it circular + declare -n c=a + echo "a after: '${a}'" + echo "done" + + # + # Additional edge cases from review + # + + - name: "Self-reference by adding -n to existing var" + known_failure: true + ignore_stderr: true + stdin: | + x=x + declare -n x + echo "exit: $?" + declare -p x + echo "val: '${x}'" + + - name: "Array assignment through nameref-to-subscript errors" + known_failure: true + ignore_stderr: true + stdin: | + arr=(1 2 3) + declare -n ref='arr[2]' + ref=(a b c) 2>/dev/null + echo "exit: $?" + declare -p arr + + - name: "Integer array element via nameref with +=" + known_failure: true + stdin: | + declare -ia arr=(10 20 30) + declare -n ref='arr[1]' + ref+=5 + echo "arr[1]: ${arr[1]}" + + - name: "Nameref in select loop" + known_failure: true + stdin: | + target="" + declare -n ref=target + echo "2" | { + select ref in apple banana cherry; do + echo "target=$target ref=$ref" + break + done + } + + - name: "Nameref to LINENO" + known_failure: true + stdin: | + declare -n ref=LINENO + echo "ref: $ref" + + - name: "Nameref to BASH_SOURCE" + known_failure: true + stdin: | + declare -n ref=BASH_SOURCE + echo "ref0: '${ref[0]}'" + + - name: "Compound array append through subscripted nameref errors" + known_failure: true + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[0]' + ref+=(x y z) 2>/dev/null + echo "exit: $?" + echo "arr: ${arr[@]}" + + - name: "Readonly propagates through 2-level nameref chain" + known_failure: true + ignore_stderr: true + stdin: | + target="original" + declare -n middle=target + declare -n top=middle + readonly top + declare -p target + top="new" 2>/dev/null + echo "exit: $?" + middle="new" 2>/dev/null + echo "exit: $?" + target="new" 2>/dev/null + echo "exit: $?" + echo "target: $target" + + - name: "Export -n (remove export) through nameref" + known_failure: true + stdin: | + target="exported_value" + export target + declare -n ref=target + env | grep '^target=' | head -1 + export -n ref + env | grep '^target=' || echo "target no longer exported" + + - name: "Declare -n with invalid target name containing spaces" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref='a b' 2>/dev/null + echo "exit: $?" + declare -n ref2='hello world' 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Declare -n with invalid target name special chars" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref='a+b' 2>/dev/null + echo "exit: $?" + declare -n ref2='a.b' 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Env var lookup through subscripted nameref" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref: $ref" + echo "ref length: ${#ref}" + + - name: "Subscripted nameref with [@] returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref[@]: ${ref[@]}" + echo "count: ${#ref[@]}" + + - name: "Subscripted nameref with explicit subscript returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref[0]: '${ref[0]}'" + echo "ref[1]: '${ref[1]}'" + + - name: "Subscripted nameref - member keys returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "keys: '${!ref[@]}'" + + - name: "Let assignment through subscripted nameref" + known_failure: true + stdin: | + arr=(10 20 30) + declare -n ref='arr[1]' + let ref=42 + echo "arr[1]: ${arr[1]}" + let ref+=8 + echo "arr[1]: ${arr[1]}" + + - name: "Arithmetic (( )) with subscripted nameref" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[0]' + (( ref += 50 )) + echo "arr[0]: ${arr[0]}" + echo "expr: $(( ref * 2 ))" + + - name: "String length through subscripted nameref" + known_failure: true + stdin: | + arr=("short" "a longer string here" "x") + declare -n ref='arr[1]' + echo "len: ${#ref}" + + - name: "Long nameref chain (8 levels - at bash max)" + known_failure: true + stdin: | + final_target="deep_value" + declare -n c8=final_target + declare -n c7=c8 + declare -n c6=c7 + declare -n c5=c6 + declare -n c4=c5 + declare -n c3=c4 + declare -n c2=c3 + declare -n c1=c2 + echo "c1: $c1" + c1="changed" + echo "final_target: $final_target" + + - name: "Nameref chain exceeds max depth" + known_failure: true + ignore_stderr: true + stdin: | + final_target="deep_value" + declare -n c9=final_target + declare -n c8=c9 + declare -n c7=c8 + declare -n c6=c7 + declare -n c5=c6 + declare -n c4=c5 + declare -n c3=c4 + declare -n c2=c3 + declare -n c1=c2 + echo "c1: '$c1'" + echo "done" + + - name: "Declare -x through subscripted nameref applies to base" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[2]' + declare -x ref 2>/dev/null + declare -p arr 2>/dev/null | grep -c 'x' || echo "no export" + + - name: "Exported nameref passes literal value to child" + stdin: | + target="value" + declare -nx myref=target + # The nameref's literal value (the target name) is exported to child + bash -c 'echo "myref: $myref"' + # The target itself needs separate export + export target + bash -c 'echo "target: $target"' + + - name: "Subscripted nameref with set -u when element is set" + known_failure: true + stdin: | + set -u + arr=(a b c) + declare -n ref='arr[1]' + echo "ref: $ref" + + - name: "Subscripted nameref with set -u when element is unset" + known_failure: true + ignore_stderr: true + stdin: | + set -u + arr=(a b c) + declare -n ref='arr[5]' + echo "$ref" + echo "should not print" + + - name: "Nameref with := default creates target through nameref" + known_failure: true + stdin: | + declare -n ref=new_var + echo "before: '${new_var:-}'" + result="${ref:=hello}" + echo "result: $result" + echo "new_var: $new_var" + + - name: "Nameref with := default when target exists but is empty" + known_failure: true + stdin: | + target="" + declare -n ref=target + result="${ref:=fallback}" + echo "result: $result" + echo "target: $target" + + - name: "Readonly nameref in for-in loop fails" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=var1 + readonly ref + for ref in a b c; do + echo "should not print: $ref" + done + echo "exit: $?" + echo "done" + + - name: "Circular nameref with set -e does not abort script" + known_failure: true + ignore_stderr: true + stdin: | + set -e + declare -n a=b + declare -n b=a + echo "val: '${a}'" + echo "script continues" + + - name: "Nameref to unset variable with set -eu errors and exits" + known_failure: true + ignore_stderr: true + stdin: | + set -eu + declare -n ref=nonexistent + echo "$ref" + echo "should not print" + + - name: "Export through subscripted nameref is rejected by bash" + # In bash, `export ref` where ref→arr[1] passes the literal "arr[1]" to + # export, which rejects it as an invalid identifier. Brush instead resolves + # the nameref to the base variable and exports it. + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + export ref 2>/dev/null + echo "exit: $?" + declare -p arr | grep -o 'x' | head -1 || echo "no export" + + - name: "Readonly through subscripted nameref makes base readonly" + # In bash, `readonly ref` where ref→arr[1] does NOT make the base array + # readonly. Brush currently resolves the nameref to the base variable and + # applies readonly there. The fix would be to have resolve_nameref_for_declaration + # in declare.rs handle subscripted nameref targets specially. + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + readonly ref + declare -p arr + arr[0]="new" 2>/dev/null + echo "exit: $?" + + - name: "Declare -p on subscripted nameref shows nameref itself" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + declare -p ref + echo "ref value: $ref" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml new file mode 100644 index 0000000..71a2954 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml @@ -0,0 +1,84 @@ +name: "BASH_XTRACEFD tests" +cases: + - name: "basic redirection to fd 3" + stdin: | + exec 3>trace.txt + BASH_XTRACEFD=3 + + set -x + echo "hello" + echo "world" + + set +x + cat trace.txt + + - name: "redirection takes effect immediately" + stdin: | + exec 3>trace.txt + + set -x + echo "before setting BASH_XTRACEFD" + + BASH_XTRACEFD=3 + echo "after setting BASH_XTRACEFD" + + set +x + + echo "Content of trace file:" + cat trace.txt + + - name: "unsetting BASH_XTRACEFD returns to stderr" + stdin: | + exec 3>trace.txt + BASH_XTRACEFD=3 + + set -x + echo "redirected" + + unset BASH_XTRACEFD + echo "back to stderr" + + set +x + + echo "Content of trace file:" + cat trace.txt + + - name: "invalid fd value falls back to stderr" + known_failure: true # bash validates BASH_XTRACEFD at assignment time, brush doesn't have that mechanism + stdin: | + BASH_XTRACEFD=999 + + set -x + echo "should go to stderr" + + set +x + + - name: "non-numeric value falls back to stderr" + known_failure: true # bash validates BASH_XTRACEFD at assignment time, brush doesn't have that mechanism + stdin: | + BASH_XTRACEFD=invalid + + set -x + echo "should go to stderr" + + set +x + + - name: "empty value falls back to stderr" + stdin: | + BASH_XTRACEFD= + + set -x + echo "should go to stderr" + + - name: "redirect to file that fails writes" + stdin: | + # This test relies on /dev/full being present. We degrade gracefully + # if it doesn't. + [[ -e /dev/full ]] || exit + + exec 3>/dev/full + BASH_XTRACEFD=3 + + set -x + echo "first echo" + echo "first echo result: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/default.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/default.yaml new file mode 100644 index 0000000..efc0be9 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/default.yaml @@ -0,0 +1,8 @@ +name: "Default options" +cases: + - name: "Default options" + stdin: | + echo "Default options: $-" + + - name: "Default options (-c)" + args: ["-c", 'echo "Default options: $-"'] diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml new file mode 100644 index 0000000..ed34879 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml @@ -0,0 +1,212 @@ +name: "Options: errtrace (-E)" +cases: + # + # Basic errtrace option behavior + # + - name: "errtrace disabled by default" + stdin: | + set +E 2>/dev/null || true + echo "errtrace test" + + - name: "errtrace can be enabled and disabled" + stdin: | + set -E + echo "enabled: $-" | grep -q E && echo "E is set" + set +E + echo "disabled: $-" | grep -qv E && echo "E is unset" + + # + # ERR trap inheritance in functions (core errtrace behavior) + # + - name: "errtrace disabled - functions don't inherit ERR trap" + stdin: | + set +E + trap 'echo "ERR trapped"' ERR + myfunc() { false; } + myfunc + echo "after" + + - name: "errtrace enabled - functions inherit ERR trap" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + myfunc() { false; } + myfunc + echo "after" + + - name: "errtrace with nested functions" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + inner() { false; } + outer() { inner; } + outer + echo "after" + + - name: "errtrace toggling mid-script" + stdin: | + trap 'echo "ERR trapped"' ERR + set -E + myfunc1() { false; } + myfunc1 + echo "after first call" + set +E + myfunc2() { false; } + myfunc2 + echo "after second call" + + # + # ERR trap inheritance in subshells + # + - name: "errtrace disabled - subshell doesn't inherit ERR trap" + stdin: | + set +E + trap 'echo "ERR"' ERR + ( + trap -p ERR + false + echo "no ERR trap fired" + ) + echo "after" + + - name: "errtrace enabled - subshell inherits ERR trap" + stdin: | + set -E + trap 'echo "ERR"' ERR + ( + trap -p ERR | grep -q ERR && echo "ERR trap inherited" + false + echo "after false in subshell" + ) + echo "after" + + # + # ERR trap inheritance in command substitution + # + - name: "errtrace disabled - command substitution doesn't inherit ERR trap" + stdin: | + set +E + trap 'echo "[ERR trapped]" >&2' ERR + result=$(trap -p ERR; false; echo "no trap inherited") + echo "result: $result" + + - name: "errtrace enabled - command substitution inherits ERR trap" + stdin: | + set -E + trap 'echo "[ERR in subshell]" >&2' ERR + result=$(false; echo "after false in subshell") + echo "result: $result" + + # + # ERR trap inheritance in pipelines + # + - name: "errtrace with pipeline - last command fails" + stdin: | + set -E + trap 'echo "[ERR: $?]" >&2' ERR + echo "data" | cat | false + echo "after pipeline" + + - name: "errtrace with pipeline - middle command fails (pipefail)" + stdin: | + set -E + set -o pipefail + trap 'echo "[ERR: $?]" >&2' ERR + echo "data" | false | cat + echo "after pipeline" + + - name: "errtrace - ERR trap in pipeline subshells" + stdin: | + set -E + trap 'echo "[ERR: $BASH_COMMAND]" >&2' ERR + echo "input" | { false; echo "after false in brace"; } + echo "done" + + # + # ERR trap with errexit interaction + # + - name: "errtrace with errexit - ERR trap fires before exit" + stdin: | + set -e + set -E + trap 'echo "ERR trap fired"' ERR + false + echo "should not print" + + - name: "errtrace with errexit in function" + stdin: | + set -e + set -E + trap 'echo "ERR trap"' ERR + myfunc() { false; } + myfunc + echo "should not print" + + # + # ERR trap inheritance in deeply nested functions + # + - name: "errtrace - ERR trap in deeply nested function" + stdin: | + set -E + trap 'echo "ERR at level ${#FUNCNAME[@]}"' ERR + level3() { false; } + level2() { level3; } + level1() { level2; } + level1 + echo "after" + + # + # ERR trap modification within functions + # + - name: "errtrace - ERR trap can be changed inside function" + stdin: | + set -E + trap 'echo "outer ERR"' ERR + myfunc() { + trap 'echo "inner ERR"' ERR + false + } + myfunc + false + echo "after" + + - name: "errtrace - ERR trap can be cleared inside function" + stdin: | + set -E + trap 'echo "ERR"' ERR + myfunc() { + trap - ERR + false + echo "no ERR in func" + } + myfunc + false + echo "after" + + # + # ERR trap in background jobs with errtrace + # + - name: "errtrace - ERR trap in background job" + stdin: | + set -E + trap 'echo "ERR" >&2' ERR + { false; echo "bg done"; } & + wait + echo "main done" + + # + # Sourced scripts with errtrace + # + - name: "errtrace - ERR trap inherited in sourced script" + test_files: + - path: "failing.sh" + contents: | + echo "in sourced" + false + echo "after false in sourced" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + . ./failing.sh + echo "after source" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml new file mode 100644 index 0000000..416f926 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml @@ -0,0 +1,314 @@ +name: "Options: extdebug" +cases: + # + # extdebug implies functrace (-T) + # + - name: "extdebug implies functrace - DEBUG inherited in functions" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before" + myfunc + echo "after" + + - name: "extdebug implies functrace - DEBUG inherited in subshells" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before" + (echo "in subshell") + echo "after" + + # + # DEBUG trap return value effects (extdebug-specific) + # + - name: "DEBUG trap - returns 0 command executes normally" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + trap 'return 0' DEBUG + echo "line 1" + echo "line 2" + echo "line 3" + + - name: "DEBUG trap - returns 1 command is skipped" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + skip_next=false + trap ' + if $skip_next; then + skip_next=false + return 1 + fi + return 0 + ' DEBUG + echo "line 1" + skip_next=true + echo "this should be skipped" + echo "line 3" + + - name: "DEBUG trap - returns 2 simulates return from function" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap ' + if [[ "${FUNCNAME[0]}" == "myfunc" && "$BASH_COMMAND" == *"should not"* ]]; then + return 2 + fi + return 0 + ' DEBUG + myfunc() { + echo "start of func" + echo "this should not print" + echo "also should not print" + } + myfunc + echo "after func: $?" + + - name: "DEBUG trap - return value ignored without extdebug" + known_failure: true # TODO(extdebug): return in trap handler error message differs + stdin: | + shopt -u extdebug + trap 'return 1' DEBUG + echo "line 1" + echo "line 2" + echo "line 3" + + # + # Command skipping scenarios (extdebug-specific) + # + - name: "Skip simple command" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + echo "keep this" + echo "skip this" + echo "keep this too" + + - name: "Skip in loop - iteration skipped but loop continues" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + for i in 1 2 3; do + echo "iter $i" + echo "skip $i" + echo "after skip $i" + done + echo "done" + + - name: "Skip in conditional" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + if true; then + echo "in then" + echo "skip in then" + echo "after skip in then" + fi + echo "after if" + + - name: "Return 2 in nested function - unwinds to caller" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap ' + if [[ "${FUNCNAME[0]}" == "inner" && "$BASH_COMMAND" == *"inner cmd"* ]]; then + return 2 + fi + return 0 + ' DEBUG + inner() { + echo "inner start" + echo "inner cmd" + echo "inner end" + } + outer() { + echo "outer start" + inner + echo "outer after inner" + } + outer + echo "main after outer" + + # + # BASH_ARGC and BASH_ARGV (extdebug-specific) + # + - name: "BASH_ARGC - contains argument counts per call frame" + stdin: | + shopt -s extdebug + show_argc() { + echo "BASH_ARGC: ${BASH_ARGC[*]}" + } + func_with_args() { + show_argc + } + func_with_args a b c + + - name: "BASH_ARGV - contains arguments in reverse order" + stdin: | + shopt -s extdebug + show_argv() { + echo "BASH_ARGV: ${BASH_ARGV[*]}" + } + func_with_args() { + show_argv + } + func_with_args a b c + + - name: "BASH_ARGC/ARGV - in nested calls" + stdin: | + shopt -s extdebug + inner() { + echo "inner ARGC: ${BASH_ARGC[*]}" + echo "inner ARGV: ${BASH_ARGV[*]}" + } + outer() { + inner x y + } + outer a b c + + - name: "BASH_ARGC/ARGV - empty when no arguments" + stdin: | + shopt -s extdebug + no_args() { + echo "BASH_ARGC: '${BASH_ARGC[*]}'" + echo "BASH_ARGV: '${BASH_ARGV[*]}'" + } + no_args + + # + # declare -F shows source info (extdebug-specific) + # + - name: "declare -F - shows function name only without extdebug" + stdin: | + myfunc() { echo hi; } + declare -F myfunc + + - name: "declare -F - shows filename and line number with extdebug" + known_failure: true # TODO(extdebug): declare -F not showing source info + test_files: + - path: "funcs.sh" + contents: | + myfunc() { + echo hi + } + stdin: | + shopt -s extdebug + . ./funcs.sh + declare -F myfunc | sed 's/[0-9]*/N/' + + # + # Interaction with other options + # + - name: "extdebug with errexit - skipped command doesn't trigger errexit" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + set -e + trap '[[ "$BASH_COMMAND" == "false" ]] && return 1; return 0' DEBUG + echo "before" + false + echo "after - should print because false was skipped" + + - name: "extdebug disabled after being enabled" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap 'return 1' DEBUG + echo "line 1 - should be skipped" + shopt -u extdebug + echo "line 2 - should print" + echo "line 3 - should print" + + - name: "extdebug with set -T - both affect DEBUG inheritance" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + myfunc + echo "done" + + # + # DEBUG trap with extdebug in various contexts + # + - name: "DEBUG trap with extdebug in function" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + count=0 + trap ' + ((count++)) + echo "debug $count: $BASH_COMMAND" + return 0 + ' DEBUG + myfunc() { + local x=1 + echo "x=$x" + } + myfunc + + - name: "DEBUG trap with extdebug controls command execution" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + should_skip=false + trap ' + if $should_skip; then + should_skip=false + echo "[skipping: $BASH_COMMAND]" + return 1 + fi + return 0 + ' DEBUG + echo "first" + should_skip=true + echo "second - will be skipped" + echo "third" + + # + # Edge cases + # + - name: "extdebug with recursive function" + known_failure: true # TODO(extdebug): DEBUG trap firing count differs + stdin: | + shopt -s extdebug + trap 'echo "depth: ${#FUNCNAME[@]}"' DEBUG + recurse() { + local n=$1 + if ((n > 0)); then + recurse $((n - 1)) + fi + } + recurse 3 + + - name: "BASH_COMMAND shows compound command" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + if true; then echo "in if"; fi + + - name: "DEBUG trap can modify variables" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + counter=0 + trap '((counter++)); return 0' DEBUG + echo "a" + echo "b" + echo "c" + echo "counter: $counter" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml new file mode 100644 index 0000000..44f9a20 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml @@ -0,0 +1,314 @@ +name: "Options: functrace (-T)" +cases: + # + # Basic functrace option behavior + # + - name: "functrace can be enabled and disabled" + stdin: | + set -T + echo "enabled: $-" | grep -q T && echo "T is set" + set +T + echo "disabled: $-" | grep -qv T && echo "T is unset" + + # + # DEBUG trap inheritance in functions + # + - name: "DEBUG trap - does not fire in function without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before func" + myfunc + echo "after func" + + - name: "DEBUG trap - fires in function with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before func" + myfunc + echo "after func" + + # + # DEBUG trap inheritance in subshells + # + - name: "DEBUG trap - does not fire in subshell without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before subshell" + (echo "in subshell") + echo "after subshell" + + - name: "DEBUG trap - fires in subshell with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before subshell" + (echo "in subshell") + echo "after subshell" + + # + # DEBUG trap inheritance in command substitution + # + - name: "DEBUG trap - does not fire in command substitution without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + result=$(echo "in substitution") + echo "result: $result" + + - name: "DEBUG trap - fires in command substitution with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + result=$(echo "in substitution") + echo "result: $result" + + # + # DEBUG trap inheritance in pipelines + # + - name: "DEBUG trap - does not fire in pipeline without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + echo "hello" | cat | cat + echo "after" + + - name: "DEBUG trap - fires in pipeline with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + echo "hello" | cat | cat + echo "after" + + # + # RETURN trap inheritance in functions + # + - name: "RETURN trap - does not fire in called function without set -T" + stdin: | + trap 'echo "[return: $FUNCNAME]"' RETURN + inner() { + echo "in inner" + } + outer() { + echo "in outer" + inner + echo "outer continuing" + } + outer + echo "after" + + - name: "RETURN trap - fires in called function with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main}]"' RETURN + inner() { + echo "in inner" + } + outer() { + echo "in outer" + inner + echo "outer continuing" + } + outer + echo "after" + + - name: "RETURN trap - fires for each nested function return with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main} -> $?]"' RETURN + level3() { echo "level3"; return 3; } + level2() { echo "level2"; level3; return 2; } + level1() { echo "level1"; level2; return 1; } + level1 + echo "done" + + # + # Function-specific trace attribute (declare -t) + # + - name: "declare -t - enables trace for specific function" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + traced_func() { + echo "in traced" + } + untraced_func() { + echo "in untraced" + } + declare -t traced_func + echo "calling untraced" + untraced_func + echo "calling traced" + traced_func + echo "done" + + - name: "declare +t - disables trace for specific function" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + declare +t myfunc + echo "calling func" + myfunc + echo "done" + + - name: "declare -f - shows trace attribute" + stdin: | + myfunc() { echo hi; } + declare -t myfunc + declare -f myfunc | head -1 + + # + # Toggling set -T mid-script + # + - name: "set -T enabled mid-script affects subsequent calls" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + func1() { echo "in func1"; } + func2() { echo "in func2"; } + echo "calling func1 without -T" + func1 + set -T + echo "calling func2 with -T" + func2 + echo "done" + + - name: "set +T disables inheritance mid-script" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + func1() { echo "in func1"; } + func2() { echo "in func2"; } + echo "calling func1 with -T" + func1 + set +T + echo "calling func2 without -T" + func2 + echo "done" + + # + # DEBUG trap inheritance in background jobs + # + - name: "DEBUG trap - in background job with set -T" + stdin: | + set -T + trap 'echo "[debug]" >&2' DEBUG + { + echo "in background" + } & + wait + echo "done" + + # + # DEBUG trap inheritance in process substitution + # + - name: "DEBUG trap - in process substitution with set -T" + stdin: | + set -T + trap 'echo "[debug]" >&2' DEBUG + cat <(echo "from procsub") + echo "done" + + # + # BASH_COMMAND visibility in DEBUG trap with functrace + # + - name: "BASH_COMMAND in function with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + myfunc() { + local v=10 + echo "v=$v" + } + myfunc + + # + # Nested functions with varying trace states + # + - name: "Outer function traced, inner function not traced" + stdin: | + trap 'echo "[debug in ${FUNCNAME:-main}]"' DEBUG + inner() { + echo "inner" + } + outer() { + echo "outer start" + inner + echo "outer end" + } + declare -t outer + outer + echo "done" + + - name: "Only inner function traced" + stdin: | + trap 'echo "[debug in ${FUNCNAME:-main}]"' DEBUG + inner() { + echo "inner" + } + outer() { + echo "outer start" + inner + echo "outer end" + } + declare -t inner + outer + echo "done" + + # + # RETURN trap with sourced scripts and set -T + # + - name: "RETURN trap - fires for function in sourced script with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + test_files: + - path: "sourced.sh" + contents: | + sourced_func() { + echo "in sourced_func" + } + sourced_func + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main}]"' RETURN + . ./sourced.sh + echo "after source" + + # + # Edge cases + # + - name: "DEBUG trap - count in deeply nested function calls with set -T" + known_failure: true # TODO(functrace): DEBUG trap count differs + stdin: | + set -T + count=0 + trap 'count=$((count + 1))' DEBUG + a() { b; } + b() { c; } + c() { echo "deep"; } + a + echo "debug count: $count" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml new file mode 100644 index 0000000..e2470b5 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml @@ -0,0 +1,76 @@ +name: "Options: inherit_errexit" +cases: + - name: "inherit_errexit disabled by default" + stdin: | + shopt -q inherit_errexit && echo "enabled" || echo "disabled" + + - name: "inherit_errexit disabled - command substitution ignores errexit" + stdin: | + set -e + shopt -u inherit_errexit + result=$(false; echo "still running") + echo "result: $result" + echo "after" + + - name: "inherit_errexit enabled - command substitution inherits errexit" + stdin: | + set -e + shopt -s inherit_errexit + result=$(false; echo "should not print") + echo "should not print" + + - name: "inherit_errexit with nested command substitution" + stdin: | + set -e + shopt -s inherit_errexit + result=$(echo "outer: $(false; echo inner)") + echo "should not print" + + - name: "inherit_errexit disabled - nested substitution continues" + stdin: | + set -e + shopt -u inherit_errexit + result=$(echo "outer: $(false; echo inner)") + echo "result: $result" + echo "after" + + - name: "inherit_errexit with backtick substitution" + stdin: | + set -e + shopt -s inherit_errexit + result=`false; echo "should not print"` + echo "should not print" + + - name: "inherit_errexit with assignment" + stdin: | + set -e + shopt -s inherit_errexit + var=$(false) + echo "should not print" + + - name: "inherit_errexit in function" + stdin: | + set -e + shopt -s inherit_errexit + myfunc() { + local result=$(false; echo "should not print") + echo "after local" + } + myfunc + echo "should not print" + + - name: "inherit_errexit toggling" + stdin: | + set -e + shopt -s inherit_errexit + result1=$(false; echo "should not print in first") + echo "should not print 1" + ignore_stderr: true + + - name: "inherit_errexit with multiple commands in substitution" + stdin: | + set -e + shopt -u inherit_errexit + result=$(echo "start"; false; echo "after false") + echo "result: $result" + echo "after" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml new file mode 100644 index 0000000..c217109 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml @@ -0,0 +1,9 @@ +name: "set -B" +cases: + - name: "set -B" + stdin: | + set +B + echo "+B: " ${a,b} + + set -B + echo "-B: " ${a,b} diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml new file mode 100644 index 0000000..b66b1e5 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml @@ -0,0 +1,27 @@ +name: "set -C" +cases: + - name: "set -C" + ignore_stderr: true + stdin: | + touch existing-file + + set -C + + echo hi > non-existing-file + echo "Result (non existing): $?" + echo "File contents: $(cat non-existing-file)" + echo + + echo hi > /dev/null + echo "Result (device file): $?" + echo + + echo hi > existing-file + echo "Result (existing file): $?" + echo "File contents: $(cat existing-file)" + echo + + echo hi >| existing-file + echo "Result (clobber): $?" + echo "File contents: $(cat existing-file)" + echo diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml new file mode 100644 index 0000000..bdc077c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml @@ -0,0 +1,20 @@ +name: "set -a" +cases: + - name: "set -a" + stdin: | + v1=1 v2=2 v3=3 v4=4 v5=(a b c) + declare -p v1 v2 v3 v4 + + set -a + v1=reassigned + v2+=appended + declare -i v3 + v4+=(appended) + v5[2]=updated + v6=new + v7[0]=new + v8=(new) + declare v9=new + declare -a v10=(new) + + declare -p v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml new file mode 100644 index 0000000..7becb9e --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml @@ -0,0 +1,1331 @@ +name: "Options: set -e" +cases: + - name: "errexit with set -o errexit" + stdin: | + set -o errexit + false + echo "should not print" + + - name: "errexit status check" + stdin: | + set -e + set -o | grep errexit + + # Basic behavior + - name: "Basic non-error case" + stdin: | + set -e + true + echo "Text after call" + + - name: "Basic error case" + stdin: | + set -e + false + echo "Text after call" + + - name: "Command line -e" + args: ["-e"] + stdin: | + false + echo "should not print" + + - name: "Multiple commands before error" + stdin: | + set -e + echo "first" + echo "second" + false + echo "should not print" + + - name: "Error in middle of script" + stdin: | + set -e + echo "before" + false + echo "after" + + - name: "Error in if condition" + stdin: | + set -e + if false; then + echo "then branch" + else + echo "else branch" + fi + echo "after if" + + - name: "Error in elif condition" + stdin: | + set -e + if false; then + echo "then branch" + elif false; then + echo "elif branch" + else + echo "else branch" + fi + echo "after if" + + - name: "Error in if body" + stdin: | + set -e + if true; then + false + echo "should not print" + fi + echo "after if" + + - name: "Error in elif body" + stdin: | + set -e + if false; then + echo "then branch" + elif true; then + false + echo "should not print" + fi + echo "after if" + + - name: "Error in else body" + stdin: | + set -e + if false; then + echo "then" + else + false + echo "should not print" + fi + echo "after if" + + - name: "Error in if condition with compound test" + stdin: | + set -e + if false && false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Error in while condition" + stdin: | + set -e + count=0 + while false; do + echo "loop body" + done + echo "after while" + + - name: "Error in while body" + stdin: | + set -e + count=0 + while [ $count -lt 2 ]; do + count=$((count + 1)) + false + echo "should not print" + done + echo "after while" + + - name: "Error in until condition" + stdin: | + set -e + count=0 + until true; do + echo "loop body" + done + echo "after until" + + - name: "Error in until body" + stdin: | + set -e + until false; do + false + echo "loop body" + break + done + echo "after until" + + # Boolean operators - set -e should NOT exit with && and || + - name: "Error on right side of &&" + stdin: | + set -e + true && false + echo "after &&" + + - name: "Error on left side of &&" + stdin: | + set -e + false && true + echo "after &&" + + - name: "Error on left side of && as the last command of a for loop" + known_failure: true # TODO(errexit): brush exits once the loop's status is the && list's + stdin: | + set -e + for x in a; do false && true; done + echo "after for" + + - name: "Error on both sides of ||" + stdin: | + set -e + false || false + echo "after ||" + + - name: "Error on left side of ||" + stdin: | + set -e + false || true + echo "after ||" + + - name: "Error on right side of ||" + stdin: | + set -e + true || false + echo "after ||" + + - name: "Error after && chain" + stdin: | + set -e + true && true + false + echo "should not print" + + - name: "Complex boolean expression" + stdin: | + set -e + false || true && false || true + echo "after complex expression" + + - name: "Negated command with failure" + stdin: | + set -e + ! false + echo "after negation" + + - name: "Negated command with success" + stdin: | + set -e + ! true + echo "after negation" + + - name: "Error after negated command" + stdin: | + set -e + ! false + false + echo "should not print" + + # Pipelines + - name: "Error in first command of pipeline (without pipefail)" + stdin: | + set -e + false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (without pipefail, negated)" + stdin: | + set -e + ! false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (without pipefail)" + stdin: | + set -e + true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (without pipefail, negated)" + stdin: | + set -e + ! true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! true | false | true + echo "after pipeline" + + - name: "Error in last command of pipeline (without pipefail)" + stdin: | + set -e + true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (without pipefail, negated)" + stdin: | + set -e + ! true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! true | false + echo "after pipeline" + + - name: "Errors in multiple commands of pipeline (without pipefail)" + stdin: | + set -e + + function ret() { + return $1 + } + + ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (without pipefail, negated)" + stdin: | + set -e + + function ret() { + return $1 + } + + ! ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + + function ret() { + return $1 + } + + ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + + function ret() { + return $1 + } + + ! ret 1 | ret 2 | ret 3 + + - name: "Pipeline in conditional" + stdin: | + set -e + if true | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated pipeline" + stdin: | + set -e + ! false | false + echo "after" + + # Functions + - name: "Error in function body exits function and script" + stdin: | + set -e + myfunc() { + echo "in function" + false + echo "should not print in function" + } + myfunc + echo "should not print after function" + + - name: "Function returning error in conditional" + stdin: | + set -e + myfunc() { + false + } + if myfunc; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Function called with &&" + stdin: | + set -e + myfunc() { + false + } + true && myfunc + echo "after" + + - name: "Function called with ||" + stdin: | + set -e + myfunc() { + false + } + false || myfunc + echo "after" + + - name: "Function called with negation" + stdin: | + set -e + myfunc() { + false + } + ! myfunc + echo "after" + + - name: "Error in function body with set -e inside function" + stdin: | + myfunc() { + set -e + echo "before error" + false + echo "should not print" + } + myfunc + echo "after function call" + + - name: "Function with explicit return still respects set -e" + stdin: | + set -e + myfunc() { + false + return 0 + } + myfunc + echo "after function" + + # Subshells + - name: "Error in subshell exits subshell but not parent" + stdin: | + set -e + echo "before subshell" + ( + echo "in subshell" + false + echo "should not print in subshell" + ) + echo "after subshell" + + - name: "Subshell with set -e exits on error" + stdin: | + false + ( + set -e + echo "before" + false + echo "should not print" + ) + echo "after subshell" + + - name: "Subshell failure in conditional" + stdin: | + set -e + if (false); then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Subshell with &&" + stdin: | + set -e + true && (false) + echo "after" + + # Command substitution + - name: "Error in command substitution" + stdin: | + set -e + result=$(false) + echo "after command substitution" + + - name: "Error in command substitution with assignment" + stdin: | + set -e + var=$(false; echo "value") + echo "var is: $var" + echo "after" + + - name: "Command substitution in conditional" + stdin: | + set -e + if [ "$(false; echo no)" = "no" ]; then + echo "matched" + fi + echo "after" + + - name: "set +e disables error exit" + stdin: | + set -e + echo "with -e" + set +e + false + echo "after false with +e" + + - name: "set -e can be re-enabled" + stdin: | + set -e + set +e + false + echo "after false with +e" + set -e + false + echo "should not print" + + - name: "set +e in function" + stdin: | + set -e + myfunc() { + set +e + false + echo "in function after false" + } + myfunc + false + echo "should not print" + + # Exit status handling + - name: "Non-zero exit status triggers exit" + stdin: | + set -e + (exit 1) + echo "should not print" + + - name: "Specific non-zero exit status" + stdin: | + set -e + (exit 42) + echo "should not print" + + - name: "Zero exit status" + stdin: | + set -e + (exit 0) + echo "after exit 0" + + # Assignment with command substitution + - name: "Assignment from failing command" + stdin: | + set -e + var=$(false) || true + echo "after assignment" + + - name: "Local variable assignment with failing command" + stdin: | + set -e + myfunc() { + local var=$(false) + echo "after local" + } + myfunc + echo "after function" + + # Complex scenarios + - name: "Error after successful conditional" + stdin: | + set -e + if true; then + echo "in if" + fi + false + echo "should not print" + + - name: "Nested conditionals with error" + stdin: | + set -e + if true; then + if false; then + echo "inner then" + else + echo "inner else" + fi + echo "after inner if" + fi + echo "after outer if" + + - name: "Error in case branch" + stdin: | + set -e + case "x" in + $(false)) + echo "matched" + ;; + esac + echo "after case" + + - name: "Error in case statement body" + stdin: | + set -e + case "x" in + x) + echo "matched" + false + echo "should not print" + ;; + esac + echo "after case" + + - name: "case pattern matching on non-match" + stdin: | + set -e + case "x" in + y) echo "y" ;; + z) echo "z" ;; + esac + echo "after case" + + # List constructs + - name: "Error in ; list" + stdin: | + set -e + true ; false ; echo "should not print" + + - name: "Semicolon separated commands respect set -e" + stdin: | + set -e + echo "first" ; echo "second" ; false ; echo "should not print" + + # Background jobs + - name: "Error in background job" + stdin: | + set -e + false & + wait + echo "after wait" + + # Interaction with other options + - name: "set -e with set -o pipefail" + stdin: | + set -e + set -o pipefail + true | false + echo "should not print" + + - name: "set -e without pipefail allows pipeline errors" + stdin: | + set -e + true | false | true + echo "after pipeline" + + # Edge cases with test builtin + - name: "test builtin false result in conditional" + stdin: | + set -e + if test 1 -eq 2; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "test builtin false result standalone" + stdin: | + set -e + test 1 -eq 2 + echo "should not print" + + - name: "[ builtin false result in conditional" + stdin: | + set -e + if [ 1 -eq 2 ]; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "[ builtin false result standalone" + stdin: | + set -e + [ 1 -eq 2 ] + echo "should not print" + + - name: "trap ERR" + stdin: | + set -e + trap 'echo "error trapped"' ERR + false + echo "should not print" + + - name: "trap EXIT" + stdin: | + set -e + trap 'echo "exit trapped"' EXIT + false + echo "should not print" + + # Combination of contexts + - name: "Error in function in conditional" + stdin: | + set -e + myfunc() { + false + } + if myfunc; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated function call" + stdin: | + set -e + myfunc() { + echo "in function" + false + } + ! myfunc + echo "after" + + - name: "Function in pipeline on internal error" + stdin: | + set -e + myfunc() { + false + } + myfunc | true + echo "after" + + - name: "Error after function in boolean context" + stdin: | + set -e + myfunc() { + false + } + myfunc || true + false + echo "should not print" + + # Arithmetic constructs with errexit + - name: "Arithmetic command (( )) standalone with false result" + stdin: | + set -e + (( 0 )) + echo "should not print" + + - name: "Arithmetic command (( )) standalone with true result" + stdin: | + set -e + (( 1 )) + echo "after arithmetic" + + - name: "Arithmetic command (( )) in if condition" + stdin: | + set -e + if (( 0 )); then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Arithmetic command (( )) with &&" + stdin: | + set -e + (( 0 )) && echo "matched" + echo "after" + + - name: "Arithmetic for loop condition false" + stdin: | + set -e + count=0 + for ((; count > 0 ;)); do + echo "body" + done + echo "after for loop" + + - name: "Arithmetic for loop with errexit in body" + stdin: | + set -e + for ((i=0; i<2; i++)); do + echo "iteration $i" + false + echo "should not print" + done + echo "should not print after loop" + + # Extended test [[ ]] with errexit + - name: "Extended test [[ ]] standalone with false result" + stdin: | + set -e + [[ 1 -eq 2 ]] + echo "should not print" + + - name: "Extended test [[ ]] standalone with true result" + stdin: | + set -e + [[ 1 -eq 1 ]] + echo "after test" + + - name: "Extended test [[ ]] in if condition" + stdin: | + set -e + if [[ 1 -eq 2 ]]; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Extended test [[ ]] with &&" + stdin: | + set -e + [[ 1 -eq 2 ]] && echo "matched" + echo "after" + + - name: "Extended test [[ ]] with ||" + stdin: | + set -e + [[ 1 -eq 2 ]] || echo "not matched" + echo "after" + + # Brace groups with errexit + - name: "Brace group in pipeline with errexit" + stdin: | + set -e + { false; } | true + echo "after pipeline" + + - name: "Brace group in pipeline with pipefail" + stdin: | + set -e -o pipefail + true | { false; } + echo "should not print" + + - name: "Brace group with internal error and errexit" + stdin: | + set -e + { + echo "in brace" + false + echo "should not print in brace" + } + echo "should not print after brace" + + # Nested subshells with errexit + - name: "Nested subshells with errexit" + stdin: | + set -e + ( + ( + false + echo "inner should not print" + ) + echo "outer should print - inner exited but does not affect parent" + ) + echo "after subshells" + + - name: "Deeply nested subshells with errexit" + stdin: | + set -e + ( + ( + ( + false + echo "level 3 should not print" + ) + echo "level 2 should print" + ) + echo "level 1 should print" + ) + echo "after" + + # Functions calling functions with errexit + - name: "Functions calling functions with errexit" + stdin: | + set -e + inner() { false; echo "inner should not print"; } + outer() { inner; echo "outer should not print"; } + outer + echo "should not print" + + - name: "Nested function call in conditional" + stdin: | + set -e + inner() { false; } + outer() { if inner; then echo "then"; else echo "else"; fi; echo "after inner call"; } + outer + echo "after" + + # Complex combinations + - name: "if with && and pipeline" + stdin: | + set -e + if true && false | true; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "if with || and pipeline failure" + stdin: | + set -e + if false || true | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated pipeline in conditional" + stdin: | + set -e + if ! false | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Command substitution in argument with errexit" + stdin: | + set -e + echo "value: $(false; echo result)" + echo "after echo" + + # PIPESTATUS with pipefail + - name: "PIPESTATUS with pipefail and multiple failures" + stdin: | + set -o pipefail + (exit 2) | (exit 3) | (exit 0) + echo "Exit: $?; PIPESTATUS: ${PIPESTATUS[@]}" + + - name: "PIPESTATUS preserved correctly with pipefail" + stdin: | + set -o pipefail + (exit 0) | (exit 5) | (exit 0) + echo "Exit: $?; PIPESTATUS: ${PIPESTATUS[@]}" + + - name: "pipefail in conditional" + stdin: | + set -o pipefail + if false | true; then + echo "then" + else + echo "else" + fi + echo "Exit: $?" + + # eval with errexit + - name: "eval with failing command" + stdin: | + set -e + eval "false" + echo "should not print" + + - name: "eval with successful command" + stdin: | + set -e + eval "true" + echo "after eval" + + - name: "eval with && chain" + stdin: | + set -e + eval "false && true" + echo "after eval" + + - name: "eval with || chain" + stdin: | + set -e + eval "false || true" + echo "after eval" + + - name: "eval in conditional" + stdin: | + set -e + if eval "false"; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "eval with multiple commands" + stdin: | + set -e + eval "echo first; false; echo should not print" + echo "should not print after eval" + + # source/dot with errexit + - name: "source script with failing command" + stdin: | + set -e + echo 'echo "in sourced"; false; echo "should not print"' > /tmp/test_source_$$.sh + source /tmp/test_source_$$.sh + echo "should not print after source" + rm -f /tmp/test_source_$$.sh + + - name: "source script with successful commands" + stdin: | + set -e + echo 'echo "in sourced"; true' > /tmp/test_source_$$.sh + source /tmp/test_source_$$.sh + echo "after source" + rm -f /tmp/test_source_$$.sh + + - name: "dot operator with failing command" + stdin: | + set -e + echo 'false' > /tmp/test_dot_$$.sh + . /tmp/test_dot_$$.sh + echo "should not print" + rm -f /tmp/test_dot_$$.sh + + - name: "source in conditional" + stdin: | + set -e + echo 'false' > /tmp/test_source_$$.sh + if source /tmp/test_source_$$.sh; then + echo "then" + else + echo "else" + fi + echo "after" + rm -f /tmp/test_source_$$.sh + + # trap DEBUG interaction + - name: "trap DEBUG with errexit" + stdin: | + set -e + trap 'echo "DEBUG"' DEBUG + echo "first" + false + echo "should not print" + + - name: "trap DEBUG in conditional with errexit" + stdin: | + set -e + trap 'echo "DEBUG"' DEBUG + if false; then + echo "then" + else + echo "else" + fi + echo "after" + + # BASH_COMMAND with errexit + - name: "BASH_COMMAND set during errexit" + stdin: | + set -e + trap 'echo "failed: $BASH_COMMAND"' EXIT + false + echo "should not print" + + - name: "BASH_COMMAND in function with errexit" + stdin: | + set -e + trap 'echo "failed: $BASH_COMMAND"' EXIT + myfunc() { false; } + myfunc + echo "should not print" + + # Arithmetic expansion failures + - name: "Division by zero in arithmetic expansion" + ignore_stderr: true + stdin: | + set -e + echo "before" + result=$((1/0)) + echo "should not print" + + - name: "Division by zero in (( ))" + ignore_stderr: true + known_failure: true # TODO(arithmetic): Division by zero should trigger errexit + stdin: | + set -e + echo "before" + (( 1/0 )) + echo "should not print" + + - name: "Division by variable zero" + ignore_stderr: true + stdin: | + set -e + x=0 + result=$((1/x)) + echo "should not print" + + # Multiple assignments with one failure + - name: "Multiple assignments with middle failure" + stdin: | + set -e + a=$(echo "a") b=$(false) c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + - name: "Multiple assignments all succeed" + stdin: | + set -e + a=$(echo "a") b=$(echo "b") c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + - name: "Multiple assignments first fails" + stdin: | + set -e + a=$(false) b=$(echo "b") c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + # time keyword with errexit + - name: "time with failing command" + ignore_stderr: true + stdin: | + set -e + time false + echo "should not print" + + - name: "time with successful command" + ignore_stderr: true + stdin: | + set -e + time true + echo "after time" + + - name: "time in conditional" + ignore_stderr: true + stdin: | + set -e + if time false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "time with pipeline" + ignore_stderr: true + stdin: | + set -e + time true | false + echo "after" + + # Pattern matching failures + - name: "Extended test with valid regex" + stdin: | + set -e + var="hello" + if [[ $var =~ ^h ]]; then + echo "matched" + else + echo "no match" + fi + echo "after" + + - name: "Extended test regex no match" + stdin: | + set -e + var="hello" + [[ $var =~ ^x ]] || echo "no match" + echo "after" + + # Extglob in case patterns + - name: "case with extglob pattern" + stdin: | + set -e + shopt -s extglob + case "abc" in + a*(b)c) echo "matched" ;; + *) echo "no match" ;; + esac + echo "after" + + - name: "case with extglob no match" + stdin: | + set -e + shopt -s extglob + case "xyz" in + a*(b)c) echo "matched" ;; + esac + echo "after" + + # Signal handler during errexit (limited - can't easily test INT) + - name: "EXIT trap runs before errexit terminates" + stdin: | + set -e + trap 'echo "EXIT trap: $?"' EXIT + false + echo "should not print" + + - name: "Multiple traps with errexit" + stdin: | + set -e + trap 'echo "EXIT: $?"' EXIT + trap 'echo "ERR: $?"' ERR + false + echo "should not print" + + # Coproc with errexit + - name: "coproc with failing command" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { exit 5; } + wait $COPROC_PID + echo "after coproc wait, status: $?" + + - name: "coproc with successful command" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { echo "from coproc"; } + cat <&${COPROC[0]} + wait $COPROC_PID + echo "after coproc" + + - name: "coproc in conditional" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { exit 1; } + if wait $COPROC_PID; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "coproc with name and brace group" + stdin: | + f() { coproc MYCOPROC { echo hello; }; } + f + + # Additional edge cases + - name: "Subshell in assignment with errexit" + known_failure: true # TODO(arithmetic): $(( exit 5 )) is parsed differently from bash + stdin: | + set -e + var=$(( exit 5 )) + echo "should not print" + + - name: "Arithmetic in condition of && chain" + stdin: | + set -e + (( 0 )) && echo "matched" || echo "not matched" + echo "after" + + - name: "Nested eval with errexit" + stdin: | + set -e + eval 'eval "false"' + echo "should not print" + + - name: "Command group with errexit" + stdin: | + set -e + { echo "first"; false; echo "should not print"; } + echo "should not print after group" + + - name: "Empty command with errexit" + stdin: | + set -e + : + echo "after colon" + + - name: "true command with errexit" + stdin: | + set -e + true + echo "after true" + + - name: "break in errexit context" + stdin: | + set -e + for i in 1 2 3; do + if [ $i -eq 2 ]; then + break + fi + echo "i=$i" + done + echo "after loop" + + - name: "continue in errexit context" + stdin: | + set -e + for i in 1 2 3; do + if [ $i -eq 2 ]; then + continue + fi + echo "i=$i" + done + echo "after loop" + + - name: "Complex nested structure" + stdin: | + set -e + outer() { + inner() { + if false; then + echo "inner then" + else + echo "inner else" + fi + false + echo "should not print in inner" + } + inner + echo "should not print in outer" + } + outer + echo "should not print" + + - name: "with -u" + ignore_stderr: true + stdin: | + set -eu + echo "before" + x=$unset + echo "after" + + - name: "with -x and if/else ending in assignment" + stdin: | + set -ex + if false; then + E=then + else + E=else + fi + echo "after ${E}" + + - name: "with -x and assignment after exempted failing command" + stdin: | + set -ex + false || E=value + echo "after ${E}" + + - name: "syntax error" + ignore_stderr: true + stdin: | + set -e + echo "before" + if false; then echo hi; done + echo "after" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml new file mode 100644 index 0000000..f9c973e --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml @@ -0,0 +1,7 @@ +name: "set -n" +cases: + - name: "set -n" + stdin: | + set -n + touch somefile.txt + ls diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml new file mode 100644 index 0000000..16411bd --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml @@ -0,0 +1,7 @@ +name: "set -t" +cases: + - name: "set -t" + args: ["-t"] + stdin: | + echo first + echo second diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml new file mode 100644 index 0000000..ed675f8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml @@ -0,0 +1,539 @@ +name: "Options: set -u" +cases: + # Basic behavior + - name: "Basic set variable access" + stdin: | + set -u + var="value" + echo "$var" + + - name: "Unset variable triggers error" + ignore_stderr: true + stdin: | + set -u + echo "$unset_var" + echo "should not print" + + - name: "Command line -u" + args: ["-u"] + ignore_stderr: true + stdin: | + echo "$unset_var" + echo "should not print" + + # Special parameters - should NOT error even when unset + - name: "Special parameter $@ does not error" + stdin: | + set -u + echo "args: $@" + + - name: "Special parameter $* does not error" + stdin: | + set -u + echo "args: $*" + + - name: "Special parameter $# does not error" + stdin: | + set -u + echo "count: $#" + + - name: "Special parameter $? does not error" + stdin: | + set -u + true + echo "status: $?" + + - name: "Special parameter $$ does not error" + stdin: | + set -u + echo "pid: $$" > /dev/null + echo "ok" + + - name: "Special parameter $! does not error when no background jobs" + known_failure: true + ignore_stderr: true + stdin: | + set -u + echo "last bg: ${!}" || echo "ok" + + - name: "Special parameter $0 does not error" + stdin: | + set -u + echo "shell: $0" > /dev/null + echo "ok" + + - name: "Special parameter $- does not error" + stdin: | + set -u + echo "flags: $-" > /dev/null + echo "ok" + + # Positional parameters + - name: "Unset positional parameter triggers error" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$1" + } + myfunc + echo "should not print" + + - name: "Set positional parameter does not error" + stdin: | + set -u + myfunc() { + echo "$1" + } + myfunc "arg" + + - name: "Positional parameters beyond provided args error" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$2" + } + myfunc "only_one_arg" + echo "should not print" + + # Parameter expansion forms + - name: "Unset variable with default does not error" + stdin: | + set -u + echo "${unset_var:-default}" + + - name: "Empty vs unset with :- operator" + stdin: | + set -u + empty="" + echo "${empty:-default}" + echo "${unset:-default}" + + - name: "Empty vs unset with - operator" + stdin: | + set -u + empty="" + echo "${empty-default}" + echo "${unset-default}" + + # Arrays (if supported) + - name: "Unset array element with default" + stdin: | + set -u + arr=(a b c) + echo "${arr[10]:-default}" + + - name: "Unset array treated as unset variable" + stdin: | + set -u + echo "${unset_array[@]}" + echo "should not print" + + - name: "Unset array with default does not error" + stdin: | + set -u + echo "${unset_array[@]:-default}" + + # Conditionals + - name: "Unset variable in conditional test errors" + ignore_stderr: true + stdin: | + set -u + if [ "$unset_var" = "value" ]; then + echo "then" + fi + echo "should not print" + + - name: "Unset variable in [[ test errors" + ignore_stderr: true + stdin: | + set -u + if [[ $unset_var = "value" ]]; then + echo "then" + fi + echo "should not print" + + - name: "Unset variable with default in conditional works" + stdin: | + set -u + if [ "${unset_var:-}" = "" ]; then + echo "empty" + fi + + - name: "test -v for unset variable" + stdin: | + set -u + if test -v unset_var; then + echo "set" + else + echo "unset" + fi + + - name: "test -z with unset variable errors" + ignore_stderr: true + stdin: | + set -u + if test -z "$unset_var"; then + echo "empty" + fi + echo "should not print" + + # Command substitution + - name: "Unset variable in command substitution errors" + ignore_stderr: true + stdin: | + set -u + result=$(echo "$unset_var") + echo "should not print" + + - name: "Unset variable with default in command substitution" + stdin: | + set -u + result=$(echo "${unset_var:-default}") + echo "$result" + + # Arithmetic expansion + - name: "Unset variable in arithmetic defaults to zero" + ignore_stderr: true + stdin: | + set -u + result=$((unset_var + 5)) + echo "$result" + + # set +u to disable + - name: "set +u disables unset variable check" + stdin: | + set -u + set +u + echo "$unset_var" + echo "ok" + + - name: "set -u can be re-enabled" + ignore_stderr: true + stdin: | + set -u + set +u + echo "$unset_var" + set -u + echo "$another_unset" + echo "should not print" + + - name: "set +u in function does not affect parent" + stdin: | + set -u + myfunc() { + set +u + echo "$unset_in_func" + } + myfunc + echo "$unset_in_parent" + echo "should not print" + + # Functions + - name: "Unset variable in function body errors" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$unset_var" + } + myfunc + echo "should not print" + + - name: "Local unset variable errors" + ignore_stderr: true + stdin: | + set -u + myfunc() { + local unset_local + echo "$unset_local" + } + myfunc + echo "should not print" + + - name: "Local variable declaration with value works" + stdin: | + set -u + myfunc() { + local my_var="value" + echo "$my_var" + } + myfunc + + - name: "Function parameter shadows unset global" + stdin: | + set -u + myfunc() { + local var="$1" + echo "$var" + } + myfunc "arg" + + # Subshells + - name: "Unset variable in subshell errors" + ignore_stderr: true + stdin: | + set -u + (echo "$unset_var") + echo "should not print" + + - name: "set +u in subshell does not affect parent" + ignore_stderr: true + stdin: | + set -u + ( + set +u + echo "$unset_in_subshell" + ) + echo "$unset_in_parent" + echo "should not print" + + # Variable assignment and unset + - name: "Variable set then unset errors on access" + ignore_stderr: true + stdin: | + set -u + var="value" + echo "$var" + unset var + echo "$var" + echo "should not print" + + - name: "Variable set then unset with default works" + stdin: | + set -u + var="value" + unset var + echo "${var:-default}" + + - name: "Unsetting non-existent variable does not error" + stdin: | + set -u + unset non_existent + echo "ok" + + # Export and environment + - name: "Exported unset variable errors" + ignore_stderr: true + stdin: | + set -u + export unset_exported + echo "$unset_exported" + echo "should not print" + + - name: "Exported variable with value works" + stdin: | + set -u + export exported_var="value" + echo "$exported_var" + + # Pattern expansion + - name: "Unset variable in pattern errors" + ignore_stderr: true + stdin: | + set -u + echo ${unset_var#prefix} + echo "should not print" + + # Length operator + - name: "Length of unset variable errors" + ignore_stderr: true + stdin: | + set -u + echo ${#unset_var} + echo "should not print" + + - name: "Length of empty variable works" + stdin: | + set -u + empty="" + echo ${#empty} + + # Indirect expansion + - name: "Indirect expansion of unset variable errors" + ignore_stderr: true + stdin: | + set -u + ref="unset_target" + echo ${!ref} + echo "should not print" + + - name: "Indirect expansion to set variable works" + stdin: | + set -u + target="value" + ref="target" + echo ${!ref} + + # Case statements + - name: "Unset variable in case pattern errors" + ignore_stderr: true + stdin: | + set -u + case "$unset_var" in + *) echo "matched" ;; + esac + echo "should not print" + + - name: "Unset variable with default in case works" + stdin: | + set -u + case "${unset_var:-default}" in + default) echo "matched" ;; + esac + + # For loops + - name: "Unset variable in for loop errors" + ignore_stderr: true + stdin: | + set -u + for item in $unset_var; do + echo "$item" + done + echo "should not print" + + - name: "Unset variable with default in for loop works" + stdin: | + set -u + for item in ${unset_var:-a b c}; do + echo "$item" + done + + # While loops with unset variables + - name: "Unset variable in while condition errors" + ignore_stderr: true + stdin: | + set -u + while [ "$unset_var" != "stop" ]; do + echo "loop" + break + done + echo "should not print" + + # Read command + - name: "read into variable then access works" + stdin: | + set -u + echo "value" | read my_var || true + echo "${my_var:-default}" + + - name: "Variable unset before read then set works" + stdin: | + set -u + echo "test" | { + read input + echo "$input" + } + + # Compound assignments + - name: "Associative array unset key with default" + stdin: | + set -u + declare -A assoc + echo "${assoc[key]:-default}" + + # Pipelines + - name: "Unset variable with default in pipeline works" + stdin: | + set -u + echo "${unset_var:-default}" | cat + + # Here documents and here strings + - name: "Unset variable in here doc errors" + ignore_stderr: true + stdin: | + set -u + cat </dev/null + declare >/dev/null + export >/dev/null + readonly >/dev/null + readonly -p >/dev/null + declare -f f >/dev/null + set +x + + - name: "command and builtin trace their query forms" + stdin: | + set -x + command -v echo >/dev/null + command -V echo >/dev/null + builtin >/dev/null + command >/dev/null + set +x + + - name: "command traces its own options" + stdin: | + set -x + command -p echo hi >/dev/null + set +x + + - name: "wrappers trace the full invocation for declaration targets" + known_failure: true # TODO: xtrace improvements required + stdin: | + value=world + set -x + command declare a=$value + builtin declare b=$value + command builtin declare c=$value + builtin command declare d=$value + command export e=$value + set +x + + - name: "export and readonly echo assignments but not bare names" + known_failure: true # TODO: xtrace improvements required + stdin: | + set -x + export a=1 b=2 + readonly c=3 d + declare e=4 + typeset t=5 + f() { local g=6 h; } + f + set +x + + - name: "export and readonly echo assignments recognized from strings" + known_failure: true # TODO: xtrace improvements required + stdin: | + set -x + readonly 'q=1' + command export 'w=2' + set +x + + - name: "assignment operands are traced as single quoted words" + known_failure: true # TODO: xtrace improvements required + stdin: | + v="a b" + set -x + declare sp="$v" + export esp="$v" + readonly rsp="$v" + set +x + + - name: "invalid export target is not echoed as an assignment" + ignore_stderr: true + stdin: | + set -x + export 'ea[0]=1' + set +x + + - name: "options following an operand keep their position in the trace" + ignore_stderr: true + stdin: | + set -x + declare a=1 -i + set +x + + - name: "compound assignment operands are traced as two lines" + # A shell traces a compound assignment operand as a standalone assignment line followed by the + # declaration command carrying only the variable name; brush emits a single combined line. + known_failure: true + stdin: | + set -x + declare -a b=(1 2) + declare -A c=([k]=v) + export ea=(1 2) + readonly ra=(1 2) + set +x + + - name: "DEBUG trap fires for a bare assignment statement" + # brush only fires the DEBUG trap for commands that reach command execution, so a standalone + # assignment statement is skipped. + known_failure: true + stdin: | + trap 'echo "<$BASH_COMMAND>"' DEBUG + x=1 + echo done + trap - DEBUG + + - name: "Words starting with # or ~ are quoted in traces" + stdin: | + set -x + : "~" "#a" "b#" "x~" + + - name: "[[ ]] traces show unary operands starting with # or ~ like bash" + stdin: | + x="~" y="#a" z="a=~" + set -x + [[ -n $x ]] + [[ -n $y ]] + [[ -n $z ]] + + - name: "[[ ]] traces show patterns starting with # or ~ like bash" + min_oracle_version: 5.3 # bash 5.2 escapes every char of a traced pattern + stdin: | + x="~" y="#a" w="~/x" + set -x + [[ a == "$x" ]] + [[ a == "$w" ]] + [[ a != "$y" ]] + + - name: "Traces leave commas unquoted, like bash" + stdin: | + set -x + : a,b "{a,b}" , + + - name: "Traces of assignments leave commas unquoted, like bash" + stdin: | + set -x + t=a,b + t+=c,d + arr=(a,b '{c,d}') + + - name: "[[ ]] traces leave commas in unary operands unquoted, like bash" + stdin: | + set -x + [[ -n a,b ]] + + - name: "[[ ]] traces leave commas in patterns unquoted, like bash" + min_oracle_version: 5.3 # bash 5.2 escapes every char of a traced pattern + stdin: | + set -x + [[ x == a,b ]] diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-failglob.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-failglob.yaml new file mode 100644 index 0000000..b7faa76 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-failglob.yaml @@ -0,0 +1,79 @@ +name: "Options: failglob" +cases: + - name: "failglob errors on unmatched glob" + ignore_stderr: true + stdin: | + shopt -s failglob + echo non-existent-*.txt 2>/dev/null + echo "exit=$?" + + - name: "failglob does not affect matching glob" + stdin: | + touch realfile.txt + shopt -s failglob + echo real*.txt + + - name: "failglob does not affect quoted glob chars" + stdin: | + shopt -s failglob + echo 'non-existent-*.txt' + + - name: "failglob with negation" + ignore_stderr: true + stdin: | + shopt -s failglob + ! echo non-existent-*.txt 2>/dev/null + echo "exit=$?" + + - name: "failglob in subshell" + ignore_stderr: true + stdin: | + shopt -s failglob + (echo non-existent-*.txt; echo "inner=$?") + echo "outer=$?" + + - name: "failglob lone ] is not a glob" + stdin: | + shopt -s failglob + echo foo] + + - name: "failglob lone [ is not a glob" + stdin: | + shopt -s failglob + echo [abc + + - name: "failglob aborts compound list" + ignore_stderr: true + stdin: | + shopt -s failglob + echo *.nomatch; echo "after" + + - name: "failglob in multi-member pipeline" + ignore_stderr: true + stdin: | + shopt -s failglob + echo *.nomatch | cat + echo "after" + + - name: "failglob extglob pattern" + ignore_stderr: true + stdin: | + shopt -s failglob + shopt -s extglob + echo @(nomatch_pattern_xyz) + echo "after" + + - name: "failglob takes precedence over nullglob" + ignore_stderr: true + stdin: | + shopt -s failglob nullglob + echo non-existent-*.txt 2>/dev/null + echo "exit=$?" + + - name: "failglob on unquoted variable with glob chars" + ignore_stderr: true + stdin: | + shopt -s failglob + x='*.nomatch_xyz' + echo $x 2>/dev/null + echo "exit=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml new file mode 100644 index 0000000..9018c33 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml @@ -0,0 +1,11 @@ +name: "shopt lastpipe" +cases: + - name: "shopt -s lastpipe" + stdin: | + echo ignored | read var + echo "1. var='${var}'" + + shopt -s lastpipe + set +o monitor + echo ignored | read var + echo "2. var='${var}'" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml new file mode 100644 index 0000000..077eccc --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml @@ -0,0 +1,18 @@ +name: "shopt login_shell" +cases: + - name: "login_shell is on for login shells" + args: + - "--login" + - "-c" + - "shopt -q login_shell" + + - name: "login_shell is off for non-login shells" + args: + - "-c" + - "shopt -q login_shell" + + - name: "login_shell is on for -l" + args: + - "-l" + - "-c" + - "shopt -q login_shell" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml new file mode 100644 index 0000000..6b0e70f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml @@ -0,0 +1,79 @@ +name: "Options: nullglob" +cases: + - name: "nullglob removes unmatched glob" + stdin: | + shopt -s nullglob + echo "result:" non-existent-*.txt + + - name: "nullglob does not affect matching glob" + stdin: | + touch realfile.txt + shopt -s nullglob + echo real*.txt + + - name: "nullglob does not affect quoted glob chars" + stdin: | + shopt -s nullglob + echo 'non-existent-*.txt' + + - name: "nullglob does not affect literal text without glob chars" + stdin: | + shopt -s nullglob + echo hello world + + - name: "nullglob with multiple unmatched patterns" + stdin: | + shopt -s nullglob + echo "result:" *.zzz *.yyy *.xxx + + - name: "nullglob with mix of matched and unmatched" + stdin: | + touch realfile.txt + shopt -s nullglob + echo real*.txt *.zzz + + - name: "nullglob with question mark glob" + stdin: | + shopt -s nullglob + echo "result:" ?.nonexistent + + - name: "nullglob with bracket glob" + stdin: | + shopt -s nullglob + echo "result:" [abc].nonexistent + + - name: "nullglob disabled by default" + stdin: | + echo *.nonexistent + + - name: "nullglob can be toggled" + stdin: | + shopt -s nullglob + echo "on:" *.nonexistent + shopt -u nullglob + echo "off:" *.nonexistent + + - name: "nullglob in subshell" + stdin: | + shopt -s nullglob + (echo "inner:" *.nonexistent) + echo "outer:" *.nonexistent + + - name: "nullglob with for loop" + stdin: | + shopt -s nullglob + for f in *.nonexistent; do + echo "unexpected: $f" + done + echo "done" + + - name: "nullglob with array assignment" + stdin: | + shopt -s nullglob + arr=(*.nonexistent) + echo "count: ${#arr[@]}" + + - name: "nullglob with directory glob" + stdin: | + shopt -s nullglob + echo "result:" nonexistent_dir/*/ diff --git a/userland/brush/source/brush-shell/tests/cases/compat/path_search.yaml b/userland/brush/source/brush-shell/tests/cases/compat/path_search.yaml new file mode 100644 index 0000000..7023488 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/path_search.yaml @@ -0,0 +1,291 @@ +name: "PATH search" + +# +# An empty entry in PATH names the working directory, so a command found through one is +# reported with a leading "./". brush finds the command but reports the bare name; the +# `known_failure` cases below cover each surface that exposes the difference. They share +# one root cause in the path search, so they should be retired together. +# +cases: + - name: "empty PATH entry is reported as the working directory" + known_failure: true # TODO(path): an empty PATH entry should resolve as "." + stdin: | + printf '#!/bin/sh\necho ran\n' > prog + chmod +x prog + PATH=":$PATH" + + echo "[type]" + type prog; echo "rc=$?" + echo "[type -p]" + type -p prog; echo "rc=$?" + echo "[type -P]" + type -P prog; echo "rc=$?" + echo "[type -a]" + type -a prog; echo "rc=$?" + echo "[command -v]" + command -v prog; echo "rc=$?" + + - name: "empty PATH entry is reported the same wherever it appears" + known_failure: true # TODO(path): an empty PATH entry should resolve as "." + stdin: | + printf '#!/bin/sh\necho ran\n' > prog + chmod +x prog + original=$PATH + + # Each of these keeps the rest of PATH reachable, so only the empty entry can + # account for finding `prog`, which exists solely in the working directory. + echo "[leading]" + PATH=":$original"; hash -r + command -v prog; echo "rc=$?" + + echo "[trailing]" + PATH="$original:"; hash -r + command -v prog; echo "rc=$?" + + echo "[middle]" + PATH="/nonexistent::$original"; hash -r + command -v prog; echo "rc=$?" + + - name: "empty PATH entry is hashed as the working directory" + known_failure: true # TODO(path): an empty PATH entry should resolve as "." + stdin: | + printf '#!/bin/sh\necho ran\n' > prog + chmod +x prog + PATH=":$PATH" + + hash -r + hash prog; echo "rc=$?" + hash -t prog; echo "rc=$?" + + - name: "empty PATH entry is passed to the command as the working directory" + known_failure: true # TODO(path): an empty PATH entry should resolve as "." + stdin: | + # A copy of env, found through the empty entry before the real one, reports the _ + # it's started with: the path the shell ran it by. (It keeps env's name, which a + # multi-call binary such as NixOS's coreutils goes by.) + cp "$(command -v env)" env + PATH=":$PATH" + + env | grep '^_=' + + - name: "relative PATH entry is relative to the working directory" + known_failure: true # TODO(path): a relative PATH entry is searched relative to the directory brush started in, not the shell's working directory + test_files: + - path: "sub/bin/prog" + executable: true + contents: | + #!/bin/sh + echo "ran prog" + stdin: | + cd sub + PATH=bin + type prog + type -a prog + command -v prog + hash prog; echo "hash: $?" + prog; echo "command: $?" + (exec prog); echo "exec in a subshell: $?" + exec prog + + - name: "script run by a relative path sees that path as its $0" + known_failure: true # TODO(path): a relative PATH entry is searched relative to the directory brush started in, not the shell's working directory + test_files: + - path: "sub/prog" + executable: true + contents: | + #!/bin/sh + echo "0=$0" + - path: "sub/bin/prog" + executable: true + contents: | + #!/bin/sh + echo "0=$0" + stdin: | + cd sub + ./prog + PATH=bin + prog + + - name: "empty PATH entry runs the program in the working directory" + known_failure: true # TODO(path): an empty PATH entry should resolve as "." + test_files: + - path: "sub/id" + executable: true + contents: | + #!/bin/sh + echo "id in the working directory" + - path: "sub/only-here" + executable: true + contents: | + #!/bin/sh + echo "only here" + stdin: | + # Unexported, so that the program can't be found again through the PATH a + # process is given, only as the shell found it. + cd sub + export -n PATH + PATH=":$PATH" + id + only-here + type only-here + + - name: "command found through an empty PATH entry is searched for again after cd" + known_failure: true # TODO(hash): a command hashed as found in the working directory isn't searched for again after cd + test_files: + - path: "a/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in a" + - path: "b/.keep" + - path: "fallback/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in fallback" + stdin: | + for first in "" "."; do + export PATH="${first}:${PWD}/fallback:${PATH}" + cd a + prog + hash -t prog; type prog + cd ../b + prog; echo "status: $?" + cd ../a + prog + hash -t prog; type prog + cd .. + export PATH="${PATH#"${first}:${PWD}/fallback:"}" + done + + - name: "PATH entry for the working directory written another way" + known_failure: true # TODO(path): a PATH entry such as `./` or `../dir` isn't taken to be the working directory + test_files: + - path: "a/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in a" + - path: "b/.keep" + - path: "fallback/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in fallback" + stdin: | + fallback="${PWD}/fallback" + for first in ./ ./. .// ../b; do + PATH="${first}:${fallback}" + hash -r + cd b + prog + cd ../a + prog + hash -t prog + cd .. + done + + - name: "command found through a PATH entry is run by the path that entry gives it" + test_files: + - path: "bin/prog" + executable: true + contents: | + #!/bin/sh + echo "0=$0" + stdin: | + PATH=./bin// + prog + PATH=.//bin + hash -r + prog + + - name: "PATH entry starting with a dot is the working directory only if it's the same" + known_failure: true # TODO(path): a PATH entry that's the working directory under another name isn't taken to be it + test_files: + - path: "a/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in a" + - path: "b/sub/.keep" + - path: "b/.hidden/.keep" + - path: "fallback/prog" + executable: true + contents: | + #!/bin/sh + echo "prog in fallback" + stdin: | + fallback="${PWD}/fallback" + ln -s . b/self + for first in ./sub .hidden .nosuch ./self; do + echo "[${first}]" + PATH="${first}:${fallback}" + hash -r + cd b + prog + cd ../a + prog + cd .. + done + + - name: "command hashed with hash -p is kept with an empty PATH entry" + test_files: + - path: "prog" + executable: true + contents: | + #!/bin/sh + echo "prog in the working directory" + stdin: | + true_path=$(type -P true) + PATH=":$PATH" + hash -p "${true_path}" prog + prog; echo "status: $?" + + - name: "with PATH unset, a command in the working directory is run by its name" + known_failure: true # TODO(path): with PATH unset, a command in the working directory is run as ./name, not by its bare name + test_files: + - path: "prog" + executable: true + contents: | + #!/bin/sh + echo "0=$0" + stdin: | + unset PATH + prog + + - name: "relative PATH entry is hashed as bash hashes it" + known_failure: true # TODO(hash): bash hashes a command found through a relative PATH entry as ./entry/name + test_files: + - path: "sub/bin/prog" + executable: true + contents: | + #!/bin/sh + echo "ran prog" + stdin: | + cd sub + PATH=bin + prog + hash -t prog + + # + # These two surfaces already agree with the oracle; they guard the parts of the + # behavior that work, so that a fix for the cases above doesn't regress them. + # + - name: "a command found through an empty PATH entry is runnable" + stdin: | + printf '#!/bin/sh\necho ran\n' > prog + chmod +x prog + PATH=":$PATH" + + prog; echo "rc=$?" + + - name: "command -V absolutizes a command found through an empty PATH entry" + stdin: | + # Normalize away the temporary directory the test runs in. + scrub() { sed -E "s|$PWD||g"; } + + printf '#!/bin/sh\necho ran\n' > prog + chmod +x prog + PATH=":$PATH" + + command -V prog | scrub; echo "rc=${PIPESTATUS[0]}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml b/userland/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml new file mode 100644 index 0000000..c2caead --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml @@ -0,0 +1,409 @@ +name: "Filename expansion" +cases: + - name: "Single file expansion" + stdin: "echo file1.txt" + + - name: "Expansion with noglob" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + set -f + echo "*.txt:" *.txt + + - name: "Multiple file expansion" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + echo *.txt + echo *."txt" + + - name: "File expansion with nocaseglob" + test_files: + - path: "FILE1.TXT" + - path: "file2.txt" + stdin: | + shopt -s nocaseglob + echo "*.txt:" *.txt + echo "*.TXT:" *.txt + + - name: "Nested directory expansion" + test_files: + - path: "dir/file1.txt" + stdin: "echo dir/*" + + - name: "Multiple level directory expansion" + test_files: + - path: "dir/subdir/file1.txt" + stdin: "echo dir/subdir/*.txt" + + - name: "Expansion with no matches" + test_files: + - path: "file1.txt" + stdin: "echo *.jpg" + + - name: "Expansion with no matches + nullglob" + test_files: + - path: "file1.txt" + stdin: | + shopt -s nullglob + echo "*.jpg:" *.jpg + + - name: "Expansion with matches and non-matches in directory" + test_files: + - path: "dir/file.txt" + - path: "dir/file.jpg" + stdin: "echo dir/*.jpg" + + - name: "Expansion with matches and non-matches in directory + nullglob" + test_files: + - path: "dir/file.txt" + - path: "dir/file.jpg" + stdin: | + shopt -s nullglob + echo "dir/*.jpg:" dir/*.jpg + + - name: "Expansion with special characters" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file?.txt" + + - name: "Expansion with brackets" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[12].txt" + + - name: "Expansion with range" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[1-2].txt" + + - name: "Expansion with negation" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[!2].txt" + + - name: "Expansion with tilde" + env: + HOME: /some/dir + stdin: | + echo ~/file1.txt + echo ~/file1.txt:~/file1.txt + + - name: "Expansion with dots" + stdin: | + echo ./file1.txt + echo ../file1.txt + + - name: "Expansion with mixed patterns" + test_files: + - path: "dir/subdir/file1.txt" + stdin: "echo dir/*/file?.txt" + + - name: "Expansion with escaped characters" + test_files: + - path: "file*.txt" + stdin: "echo file\\*.txt" + + - name: "Pathname expansion: extglob disabled" + ignore_stderr: true + test_files: + - path: "ab.txt" + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + - path: "abadac.txt" + - path: "fabadac.txt" + - path: "f.txt" + stdin: | + shopt -u extglob + echo !(a*) + echo "result: $?" + + echo @(abc|abd).txt + echo "result: $?" + + echo ab?(c).txt + echo "result: $?" + + echo *(ab|ad|ac).txt + echo "result: $?" + + echo f+(ab|ad|ac).txt + echo "result: $?" + + - name: "Pathname expansion: Inverted patterns" + ignore_stderr: true + test_files: + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + stdin: | + shopt -s extglob + echo "1: " !(a*) + echo "2: " !(abc.txt) + echo "3: " !(abc) + echo "4: " !(*) + + - name: "Pathname expansion: Degenerate inverted pattern" + test_files: + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + stdin: | + shopt -s extglob + echo !() + + - name: "Pathname expansion: Extended patterns" + ignore_stderr: true + test_files: + - path: "abc.txt" + - path: "abd.txt" + stdin: | + shopt -s extglob + echo "1: " @(abc|abd).txt + echo "2: " @(abc.txt) + echo "3: " @(abc) + echo "4: " @(|abc.txt) + + - name: "Pathname expansion: Optional patterns" + ignore_stderr: true + test_files: + - path: "ab.txt" + - path: "abc.txt" + stdin: | + shopt -s extglob + echo ab?(c).txt + + - name: "Pathname expansion: Star patterns" + ignore_stderr: true + test_files: + - path: "abadac.txt" + - path: "ab.txt" + stdin: | + shopt -s extglob + echo *(ab|ad|ac).txt + + - name: "Pathname expansion: Plus patterns" + ignore_stderr: true + test_files: + - path: "fabadac.txt" + - path: "f.txt" + stdin: | + shopt -s extglob + echo f+(ab|ad|ac).txt + + - name: "Pathname expansion: quoting" + test_files: + - path: test.txt + - path: subdir/test.txt + stdin: | + echo "./test"*.txt + echo "subdir"/*.txt + echo "test.*" + + - name: "Pathname expansion: dot files (no dotglob)" + min_oracle_version: 5.2 + stdin: | + touch .file + touch .dir + + shopt -u dotglob + echo "* : " * + echo "*i* : " *i* + echo "./* : " ./* + echo ".* : " .* + echo "./.*: " ./.* + + - name: "Pathname expansion: dot files after empty quotes (no dotglob)" + min_oracle_version: 5.2 + stdin: | + touch .file + mkdir sub + touch sub/.hidden + e= + + shopt -u dotglob + echo "\"\".* : " "".* + echo "''.* : " ''.* + echo "\$e\"\".*: " $e"".* + echo "sub/\"\".*: " sub/"".* + echo "\"\"* : " ""* + + - name: "Glob results are sorted" + test_files: + - path: "cherry.txt" + - path: "apple.txt" + - path: "banana.txt" + stdin: | + echo *.txt + + - name: "Multiple globs on one command line" + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "x.log" + - path: "y.log" + stdin: | + echo *.txt *.log + + - name: "Glob does not expand in variable assignment" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + x=*.txt + echo "$x" + + - name: "Glob expands in unquoted variable use" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + x=*.txt + echo $x + + - name: "Glob with trailing slash matches directories" + test_files: + - path: "mydir/file.txt" + - path: "myfile.txt" + stdin: | + echo */ + + - name: "Trailing path filters glob results" + test_files: + - path: "README.md" + - path: "photos/photo.png" + - path: "a/apple.txt" + - path: "b/banana.txt" + stdin: | + echo */banana.txt + echo */*.txt + + - name: "Trailing literal keeps dangling symlinks but drops missing" + test_files: + - path: "a/real.txt" + - path: "b/real.txt" + - path: "c/other.txt" + stdin: | + # a/link.txt is a *dangling* symlink: bash's glob matches it (directory-entry + # existence, via lstat), so brush must too. b and c have no link.txt and must + # be dropped. + ln -s nonexistent-target a/link.txt + echo "link.txt:" */link.txt + echo "real.txt:" */real.txt + + - name: "Trailing literal with directory symlinks" + test_files: + - path: "realdir/file.txt" + stdin: | + # A symlink to a directory should be traversed like a real directory. + ln -s realdir linkdir + ln -s nonexistent danglingdir + echo "*/file.txt:" */file.txt + + - name: "Globstar disabled by default" + test_files: + - path: "a/b/c.txt" + stdin: | + shopt -u globstar + echo **/c.txt + + - name: "Globstar recursive matching" + known_failure: true + test_files: + - path: "a/b/c.txt" + - path: "a/d.txt" + - path: "e.txt" + stdin: | + shopt -s globstar + echo **/*.txt + + - name: "GLOBIGNORE filters glob results" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "c.txt" + stdin: | + GLOBIGNORE="b.txt" + echo *.txt + + - name: "GLOBIGNORE with multiple patterns" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "c.txt" + - path: "d.log" + stdin: | + GLOBIGNORE="b.txt:c.txt" + echo *.txt + echo *.log + + - name: "GLOBIGNORE implicitly enables dotglob" + known_failure: true + min_oracle_version: 5.2 + test_files: + - path: "a.txt" + - path: ".hidden.txt" + stdin: | + GLOBIGNORE="a.txt" + echo *.txt + + - name: "Unsetting GLOBIGNORE restores behavior" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + stdin: | + GLOBIGNORE="a.txt" + echo *.txt + unset GLOBIGNORE + echo *.txt + + - name: "Star does not match slash in path" + test_files: + - path: "dir/file.txt" + - path: "top.txt" + stdin: | + echo *.txt + + - name: "Pathname expansion: dot files (with dotglob)" + min_oracle_version: 5.2 + stdin: | + touch .file + touch .dir + + shopt -s dotglob + echo "* : " * + echo "*i* : " *i* + echo "./* : " ./* + echo ".* : " .* + echo "./.*: " ./.* + + - name: "Pathname expansion: extglob containing parameter expansion" + known_failure: true # TODO(patterns): extglobs containing an expansion never match + stdin: | + shopt -s extglob + touch x + u=x + echo @($u) @(a|$u) @(a|${u:-y}) @(a|${v:-x}) + + - name: "Backslash from a variable in a path component without glob characters" + stdin: | + mkdir sub 'a*b' x + touch sub/file 'a*b/f' x/f + v='s\ub' + echo $v/* + echo $v*/* + v='a\*b' + echo $v/* + v='\x' + echo $v/* + echo "$v/"* diff --git a/userland/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml b/userland/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml new file mode 100644 index 0000000..d6324a1 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml @@ -0,0 +1,232 @@ +name: "Patterns" +cases: + - name: "Expansion with escaped characters" + test_files: + - path: "file*.txt" + stdin: "echo file\\*.txt" + + - name: "Basic pattern matching" + stdin: | + test_pattern() { + local str="$1" + local pattern="$2" + + if [[ "${str}" == ${pattern} ]]; then + echo "Matched: ${str} using ${pattern}" + else + echo "No match: ${str} using ${pattern}" + fi + } + + test_pattern "abc" "a*" + test_pattern "abc" "b*" + + test_pattern "?bc" "abc" + test_pattern "?bc" "aabc" + + test_pattern "ac" "[ab]c" + test_pattern "bc" "[ab]c" + test_pattern "cc" "[ab]c" + + test_pattern "ad" "[a-c]d" + test_pattern "bd" "[a-c]d" + test_pattern "dd" "[a-c]d" + + test_pattern "1" "[[:alpha:]]" + test_pattern "a" "[[:alpha:]]" + + test_pattern "a/b" "*b" + test_pattern "a/b" "a/b" + test_pattern "a/b" "*/*" + + - name: "Extglob pattern matching" + stdin: | + shopt -s extglob + + test_pattern() { + local str="$1" + local pattern="$2" + + if [[ "${str}" == ${pattern} ]]; then + echo "Matched: ${str} using ${pattern}" + else + echo "No match: ${str} using ${pattern}" + fi + } + + test_pattern "aabc" "!(a*)" + test_pattern "abc" "!(a*)" + test_pattern "def" "!(a*)" + + test_pattern "a.foo.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.bar.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.baz.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.tar.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a..tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.tar.gz" "a.!(foo|bar).tar.gz" + + test_pattern "abc" "@(abc|def)" + test_pattern "def" "@(abc|def)" + test_pattern "ghi" "@(abc|def)" + + test_pattern "abc" "ab?(c)" + test_pattern "ab" "ab?(c)" + test_pattern "abd" "ab?(c)" + + test_pattern "" "*(ab|ac)" + test_pattern "ab" "*(ab|ac)" + test_pattern "abab" "*(ab|ac)" + test_pattern "ad" "*(ab|ac)" + + test_pattern "" "+(ab|ac)" + test_pattern "ab" "+(ab|ac)" + test_pattern "abab" "+(ab|ac)" + test_pattern "ad" "+(ab|ac)" + + - name: "Extglob with escaping" + stdin: | + shopt -s extglob + + [[ \( == +(\() ]] && echo "1. Matched" + [[ x == +(\() ]] && echo "2. Matched" + [[ \) == +(\)) ]] && echo "3. Matched" + [[ x == +(\)) ]] && echo "4. Matched" + + - name: "Patterns: quoting" + stdin: | + [[ "abc" == "a"* ]] && echo "1. Matched" + [[ "abc" == a"*" ]] && echo "2. Matched" + [[ "abc" == "a*" ]] && echo "3. Matched" + + - name: "Patterns: escaped special characters" + stdin: | + myfunc() { + if [[ $1 == \\* ]]; then + echo "Matched: '$1'" + else + echo "Did *not* match: '$1'" + fi + } + + myfunc abc + myfunc "*" + + - name: "Pattern matching: character ranges" + stdin: | + [[ "x" == [a-z] ]] && echo "1. Matched" + [[ "x" == [0-9] ]] && echo "2. Matched" + [[ "-" == [---] ]] && echo "3. Matched" + [[ "*" == [\*-\*] ]] && echo "4. Matched" + [[ "-" == [\*-\*] ]] && echo "5. Matched" + [[ "." == [\*-/] ]] && echo "6. Matched" + + - name: "Pattern matching: invalid character ranges" + stdin: | + [[ "a" == [f-a] ]] && echo "1. Matched" + [[ "b" == [f-a] ]] && echo "2. Matched" + [[ "f" == [f-a] ]] && echo "3. Matched" + [[ "g" == [f-a] ]] && echo "4. Matched" + + - name: "Pattern matching: escaped hyphen in bracket" + stdin: | + [[ "-" == [a\-\*] ]] && echo "1. Matched" + [[ "*" == [a\-\*] ]] && echo "2. Matched" + [[ "a" == [a\-\*] ]] && echo "3. Matched" + [[ "b" == [a\-\*] ]] && echo "4. Matched" + [[ "a" == [a\-f] ]] && echo "5. Matched" + [[ "b" == [a\-f] ]] && echo "6. Matched" + [[ "f" == [a\-f] ]] && echo "7. Matched" + + - name: "Pattern matching: character sets" + stdin: | + [[ "x" == [abc] ]] && echo "1. Matched" + [[ "x" == [xyz] ]] && echo "2. Matched" + [[ "x" == [^xyz] ]] && echo "3. Matched" + [[ "x" == [!xyz] ]] && echo "4. Matched" + [[ "(" == [\(] ]] && echo "5. Matched" + [[ "+" == [+-] ]] && echo "6. Matched" + [[ "[" == [\[] ]] && echo "7. Matched" + [[ "]" == [\]] ]] && echo "8. Matched" + [[ "^" == [\^] ]] && echo "9. Matched" + + - name: "Pattern matching: character classes" + stdin: | + [[ "1" == [[:digit:]] ]] && echo "1. Matched" + [[ "1" == [[:alpha:]] ]] && echo "2. Matched" + + - name: "Pattern matching: case sensitivity" + stdin: | + shopt -u nocasematch + [[ "abc" == "ABC" ]] && echo "1. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "2. Matched" + + shopt -s nocasematch + [[ "abc" == "ABC" ]] && echo "3. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "4. Matched" + + - name: "Pattern matching: stars in negative extglobs" + stdin: | + shopt -s extglob + [[ 'd' == !(*d)d ]] && echo "1. Matched" + + - name: "Pattern matching: quotes in character class" + stdin: | + [[ f == ['f'] ]] && echo "1. Matched" + + # A ']' first in a bracket expression (after any '!' or '^') is a member, + # not the end of the expression. + - name: "Pattern matching: leading ] in bracket expression" + stdin: | + [[ "]" == [][] ]] && echo "1. Matched" + [[ "a" == [][a] ]] && echo "2. Matched" + [[ "[" == []abc[] ]] && echo "3. Matched" + [[ "x" == [^][] ]] && echo "4. Matched" + [[ "]" == [!]] ]] || echo "5. Not matched" + s="a]b[c" + echo "${s//[][]/_}" + # ble.sh strips function names out of `declare -F` output with this extglob; + # the bracket expression is meant to cover ']', '[', '{', '}', ':' and alnum. + shopt -s extglob + v=$'\nxxx is a function\nhello\n' + p=$'\n+([][{}:[:alnum:]]) is a function\n' + printf '[%s]\n' "${v//$p/|}" + + # A range whose low end is that leading ']' is validated like any other range; + # a reversed one matches nothing (or, inverted, everything). + - name: "Pattern matching: ranges starting at a leading ] in bracket expression" + stdin: | + for s in ']' 'a' 'A' '^' '-'; do + [[ "$s" == []-a] ]] && echo "in-range: $s" + done + for s in ']' 'a' 'A' '^' '-'; do + [[ "$s" == []-A] ]] && echo "reversed range matched: $s" + done + for s in ']' 'a' 'A' '^' '-'; do + [[ "$s" == [!]-A] ]] && echo "inverted reversed range matched: $s" + done + echo "done" + + # An invalid (reversed) range is dropped from the bracket expression, but the + # surviving members are still emitted in order. A '^' left first that way must + # not negate the generated regex character class. + - name: "Pattern matching: invalid range leaving a leading ^ member" + stdin: | + for s in '^' 'a' 'b'; do + [[ "$s" == [9-0^a] ]] && echo "1. matched: $s" + done + for s in '^' 'a' 'b'; do + [[ "$s" == [z-a^] ]] && echo "2. matched: $s" + done + echo "done" + + # A '-' endpoint must be escaped in the generated regex, or the class becomes + # ambiguous and the range collapses to a set. + - name: "Pattern matching: '-' as a range endpoint" + stdin: | + for s in '+' ',' '-' '.' '/' 'a'; do + [[ "$s" == [+--] ]] && echo "1. matched: $s" + done + for s in '+' ',' '-' '.' '/' 'a'; do + [[ "$s" == [--/] ]] && echo "2. matched: $s" + done + echo "done" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/piped_script.yaml b/userland/brush/source/brush-shell/tests/cases/compat/piped_script.yaml new file mode 100644 index 0000000..19f7cab --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/piped_script.yaml @@ -0,0 +1,208 @@ +name: "Scripts piped in on stdin (a la 'curl | sh')" +cases: + # Cases carrying `removed_default_args` drop `--input-backend=basic` so brush + # picks the backend it uses for real when stdin isn't a terminal. Otherwise + # that path goes untested. + + - name: "Multi-line if/then/fi" + removed_default_args: ["--input-backend=basic"] + stdin: | + if true + then + echo yes + fi + + - name: "Multi-line function definition" + removed_default_args: ["--input-backend=basic"] + stdin: | + greet() { + echo "hello, $1" + } + + greet world + + - name: "Here document" + removed_default_args: ["--input-backend=basic"] + stdin: | + cat <"$INSTALL_DIR/config" < $1") + exit $1 + stdin: | + ./script.sh 10 | ./script.sh 0 | ./script.sh 33 + + - name: "Side effects in pipe commands" + stdin: | + var=0 + echo "var: ${var}" + { var=1; } + echo "var: ${var}" + { var=2; echo hi; } | cat + echo "var: ${var}" + echo hi | { var=3; cat; } + echo "var: ${var}" + + - name: "pipeline extension" + stdin: | + echo -e "hello" |& wc -l + cat dfdfgdfgdf |& wc -l + foo() { cat dfgdfg; } |& wc -l + + - name: "Possible broken pipe case" + stdin: | + echo hello | var=value + + - name: "printf broken pipe returns 141 in PIPESTATUS" + stdin: | + printf '%s\n' {0..10000} | x=1 + echo "Last: $?, PIPESTATUS: ${PIPESTATUS[*]}" + + - name: "Function stage writing more than a pipe buffer before the next stage is spawned" + # Regression test: a shell function used as a non-last pipeline stage + # must run concurrently with the rest of the pipeline, not be awaited + # to completion before the next stage is even spawned. Otherwise, once + # the function writes more to its stdout pipe than the OS pipe buffer + # holds (commonly 64KiB on Linux) before returning, the write blocks + # forever because nothing is reading the other end yet. + stdin: | + big() { + local i + for i in $(seq 1 5000); do + echo "padding line number ${i} to exceed the pipe buffer size" + done + } + big | wc -l + echo "Last: $?, PIPESTATUS: ${PIPESTATUS[*]}" + + - name: "Builtin that fails in a pipeline stage" + stdin: | + cd /nonexistent 2>/dev/null | true + echo "status: $?" + cd /nonexistent 2>&1 | { read -r line; echo "got an error message: ${line:+yes}"; } + + - name: "Exit in a pipeline's last stage" + stdin: | + echo hi | exit 3 + echo "status: $?" + + - name: "Break and return in a pipeline's last stage" + stdin: | + for i in 1 2; do + echo "$i" | break + echo "i=$i" + done + + f() { + echo hi | return 5 + echo "after return" + } + f + echo "f: $?" + + - name: "Compound command writing more than a pipe holds" + timeout_in_seconds: 3 + stdin: | + # Each first stage writes 256 KiB, more than a pipe holds, before the next stage reads any. + s=x; for ((i = 0; i < 10; i++)); do s=$s$s; done + for ((i = 0; i < 256; i++)); do echo "$s"; done | wc -l + f() { for ((i = 0; i < 256; i++)); do echo "$s"; done; } + f | wc -l + { f; echo last; } | tail -n 1 + + - name: "Endless producer into head" + known_failure: true # TODO(pipeline): the producer runs to completion before head starts, so it never stops + timeout_in_seconds: 3 + stdin: | + while true; do echo y; done | head -n 1 + + - name: "Compound writer exceeds pipe capacity" + stdin: | + i=0 + while [ $i -lt 4096 ]; do printf '%s\n' 0123456789012345678901234567890; i=$((i+1)); done | wc -l + + - name: "Control flow in a pipeline stage stays in its subshell" + stdin: | + echo x | exit 3 + echo "after exit: $?" + echo x | exec cat + echo "after exec: $?" + echo x | { exit 6; } + echo "after group: $?" + f() { true | return 4; echo "f still running: $?"; } + f + for i in 1 2; do echo x | break; echo "loop body $i"; done + + - name: "A special builtin's fatal error in a pipeline stage ends only that stage" + known_failure: true # TODO(builtins): a usage error only skips exit, which returns 2 rather than exiting with 1 (see builtins/exit.yaml) + ignore_stderr: true + stdin: | + set -o posix + true | export -Z + echo "after usage error: $?" + true | exit 1 2 + echo "after exit: $?" + + - name: "An expansion error in a pipeline stage ends only that stage" + ignore_stderr: true + stdin: | + echo ${x?boom} | cat + echo "after \${x?}: ${PIPESTATUS[*]}" + f() { echo ${y?b}; } + f | cat + echo "after f: ${PIPESTATUS[*]}" + { echo ${y?b}; echo same-stage; } | cat + echo "after group: ${PIPESTATUS[*]}" + echo hi | { read v; echo ${y?b}; } + echo "last stage: ${PIPESTATUS[*]}" + set -u + echo $zz | cat + echo "after set -u: ${PIPESTATUS[*]}" + + - name: "An expansion error in a pipeline stage's prefix assignment ends only that stage" + ignore_stderr: true + stdin: | + true | X=${x?b} true + echo "last: ${PIPESTATUS[*]}" + X=${x?b} true | cat + echo "first: ${PIPESTATUS[*]}" + true | X=${x?b} + echo "assignment only: ${PIPESTATUS[*]}" + f() { :; } + true | X=${x?b} f + echo "function: ${PIPESTATUS[*]}" + + - name: "An error turned into a result in a pipeline stage ends only that stage" + ignore_stderr: true + stdin: | + true | eval 'echo ${x?boom}' + echo "eval: ${PIPESTATUS[*]}" + f() { X=${x?boom} true; } + true | f + echo "function: ${PIPESTATUS[*]}" + true | { X=${x?boom} true; } + echo "group: ${PIPESTATUS[*]}" + true | eval 'exit 8' + echo "eval exit: ${PIPESTATUS[*]}" + + - name: "Control flow in the last stage applies with lastpipe" + stdin: | + shopt -s lastpipe + f() { echo x | return 4; echo "f still running"; } + f + echo "f returned $?" + echo x | exit 5 + echo "not reached" + + # The subshell prints the report itself, so the report goes to the subshell's stderr. + - name: "time reports a subshell's timing on the subshell's stderr" + known_failure: true # TODO(pipeline): time reports on the shell's stderr, ignoring the subshell's redirection + stdin: | + time (exit 3) 2>/dev/null + echo "status: $?" + time (exec 2>/dev/null; true) + echo "status: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/process.yaml b/userland/brush/source/brush-shell/tests/cases/compat/process.yaml new file mode 100644 index 0000000..c09868c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/process.yaml @@ -0,0 +1,17 @@ +name: "Process" +common_test_files: + - path: "process-helpers.sh" + source_path: "../../utils/process-helpers.sh" + +cases: + - name: "Basic process" + stdin: | + # TODO(jobs): Figure out how to make this work elsewhere + if [[ "$(uname)" != "Linux" ]]; then + echo "Skipping test on non-Linux platform" + exit 0 + fi + + source process-helpers.sh + echo "pid != ppid: $(( $(get-pid) != $(get-ppid) ))" + echo "pid == pgrp: $(( $(get-pid) != $(get-pgrp) ))" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/prompt.yaml b/userland/brush/source/brush-shell/tests/cases/compat/prompt.yaml new file mode 100644 index 0000000..d9caaaa --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/prompt.yaml @@ -0,0 +1,372 @@ +name: "Prompt" +cases: + - name: "Simple prompts" + stdin: | + prompt='$ ' + echo "Prompt: '${prompt@P}'" + + prompt='a\r\n> ' + echo "Prompt: '${prompt@P}'" + + prompt='\\' + echo "Prompt: '${prompt@P}'" + + prompt='\?' + echo "Prompt: '${prompt@P}'" + + prompt='\81' + echo "Prompt: '${prompt@P}'" + + - name: "Working dir based prompts" + stdin: | + cd /usr + + prompt='\w ' + echo "Prompt: '${prompt@P}'" + + prompt='\W ' + echo "Prompt: '${prompt@P}'" + + arr=("\w" "\w") + echo "Prompt: '${arr@P}'" + echo "Prompt: '${arr[@]@P}'" + echo "Prompt: '${arr[*]@P}'" + + - name: "Non-printing chars" + stdin: | + prompt='\[\]Prompt>\[\]' + echo "Prompt: '${prompt@P}'" + + - name: "Hostname in prompts" + stdin: | + prompt='\h ' + echo "Prompt: '${prompt@P}'" + + prompt='\H ' + echo "Prompt: '${prompt@P}'" + + - name: "Shell name" + stdin: | + prompt='\s' + [[ "${prompt@P}" == "$(basename $0)" ]] + + - name: "Shell version info" + stdin: | + prompt='\v' + [[ "${prompt@P}" =~ ^\d+\.\d+$ ]] && echo "Version is well-formatted" + + prompt='\V' + [[ "${prompt@P}" == ^\d+\.\d+\.\d+$ ]] && echo "Release is correct" + + - name: "Simple date" + stdin: | + prompt='\d' + [[ "${prompt@P}" == $(date +'%a %b %d') ]] && echo '\d date matches' + + - name: "Date format with plain string" + stdin: | + prompt='\D{something}' + echo "Date format with plain string: '${prompt@P}'" + + - name: "Date format with year" + stdin: | + prompt='\d{%Y}' + echo "Date format with year: '${prompt@P}'" + + - name: "Time format: A" + stdin: | + prompt='\A' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%H:%M') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: @" + stdin: | + prompt='\@' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%I:%M %p') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: T" + stdin: | + prompt='\T' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%I:%M:%S') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: t" + stdin: | + prompt='\t' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%H:%M:%S') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Current history number" + known_failure: true + stdin: | + prompt='\!' + expanded=${prompt@P} + roundtripped=$(history | tail -n 1 | cut -d ' ' -f 1) + [[ ${expanded} == ${roundtripped} ]] && echo "History number matches" + + - name: "Current command number" + known_failure: true # bash advances the number before a command runs, brush after + stdin: | + prompt='\#' + expanded=${prompt@P} + echo "${expanded}" + + - name: "Current command number at each interactive prompt" + args: ["-i"] + ignore_stderr: true + env: + n: '\#' + PROMPT_COMMAND: 'echo "prompt: ${n@P}"' + stdin: | + true + true; true; true + if true; then + true + fi + cd . + env true + + - name: "Current command number skips blank and comment-only lines" + known_failure: true # brush counts every line its interactive loop runs + args: ["-i"] + ignore_stderr: true + env: + n: '\#' + PROMPT_COMMAND: 'echo "prompt: ${n@P}"' + stdin: | + true + + # comment only + true + + - name: "Current command number in a -c string" + args: + - "-c" + - | + n='\#' + echo "${n@P}" + true + echo "${n@P}" + + - name: "Escaped dollar-sign" + stdin: | + prompt='\$' + echo "Prompt: '${prompt@P}'" + + - name: "Var in prompt" + stdin: | + var=value + + prompt="\$var" + + shopt -s promptvars + echo "Prompt (dollar-sign not escaped, with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (dollar-sign not escaped, without promptvars): ${prompt@P}" + + prompt="\\\$var" + + shopt -s promptvars + echo "Prompt (dollar-sign escaped, with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (dollar-sign escaped, without promptvars): ${prompt@P}" + + - name: "Tilde in prompt" + stdin: | + HOME=$(pwd) + + prompt='~' + + shopt -s promptvars + echo "Prompt (with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (without promptvars): ${prompt@P}" + + - name: "Command substitution in prompt" + stdin: | + prompt='$(echo Hello)' + echo "Without spaces: ${prompt@P}" + + prompt=' $(echo Hello) ' + echo "With spaces: ${prompt@P}" + + - name: "Backquoted command substitution in prompt" + stdin: | + prompt='`echo Hello`' + echo "Without spaces: ${prompt@P}" + + prompt=' `echo Hello` ' + echo "With spaces: ${prompt@P}" + + - name: "Parameter expansion with non-printing chars" + stdin: | + var=value + prompt="\${var:+\[text\]}" + echo "Conditional (set): '${prompt@P}'" + + var= + prompt="\${var:+\[text\]}" + echo "Conditional (unset): '${prompt@P}'" + + - name: "Command substitution in prompt and exit code" + args: ["-i"] + ignore_stderr: true + stdin: | + PS1='$(echo Hello)> ' + false + echo $? + + - name: "Brackets and var" + stdin: | + prompt='\[$var\]\u > ' + echo "Prompt: ${prompt@P}" + + - name: "Brackets and var (interactive mode)" + args: ["-i"] + ignore_stderr: true + stdin: | + prompt='\[$var\]\u > ' + echo "Prompt: ${prompt@P}" + + # The following cases exercise double-quote escape semantics in the second + # pass of prompt expansion: a backslash only escapes (and is consumed by) + # one of the same characters it would escape inside a double-quoted string; + # for any other character, the backslash is preserved literally. + # + # We pipe the expansion result through hexdump -C to get a deterministic + # byte-level view, avoiding any divergence in `printf '%q'` formatting. + - name: "Backslash-CR survives prompt expansion (issue #1119)" + stdin: | + # OSC 133;C followed by ST (\e\\) and CR. The ST must end with a literal + # backslash byte (0x5c); without it, the OSC is malformed and terminals + # may eat the first character of subsequent output. + prompt='\e]133;C\e\\\r' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before non-special char preserves backslash" + stdin: | + # Under double-quote escape rules, a backslash before a non-special + # character is preserved literally. \\X (raw) = '\' + 'X' should expand + # to literal '\X'. + prompt='\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\Z' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\n' + printf '%s' "${prompt@P}" | hexdump -C + + prompt=$'\\\\\t' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before dollar escapes parameter expansion" + stdin: | + # `\\$VAR` (PS0 raw chars: \\, $, V, A, R) → step 1 produces \$VAR, then + # step 2's `\$` consumes the backslash and leaves a literal $, so $VAR + # remains literal (not expanded). + var=hello + prompt='\\$var' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Double backslash before dollar yields backslash + expansion" + stdin: | + # `\\\\$VAR` → step 1 produces \\$VAR, then step 2's `\\` reduces to a + # literal `\` and `$VAR` is expanded normally. + var=hello + prompt='\\\\$var' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before escaped dollar" + stdin: | + # `\\\$` → step 1 produces \$ (Backslash + DollarOrPound). Step 2 keeps + # the literal backslash and the literal $, so the result is `\$`. + prompt='\\\$' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Many backslashes before non-special" + stdin: | + # Each `\\` in step 1 produces a literal `\`. Step 2 reduces consecutive + # backslashes pairwise. + prompt='\\\\\\\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt=$'\\\\\\\\\r' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before newline is line continuation" + stdin: | + # `\\\n` produces (Backslash, Newline) → \ in formatted prompt. + # In double-quote escape rules, \ is line continuation and + # consumes both characters. + prompt='\\\n' + printf '%s' "${prompt@P}" | hexdump -C + + # Same expected behavior when the LF byte is supplied directly. + prompt=$'\\\\\n' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Trailing literal backslash survives" + stdin: | + # `\\` at end of prompt should produce a single literal backslash. + prompt='\\' + printf '%s' "${prompt@P}" | hexdump -C + + # `\\X\\` should produce `\X\`. + prompt='\\X\\' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash followed by working-dir prompt piece" + stdin: | + # Make sure prompt pieces whose formatted output happens to contain `\` + # (e.g., AsciiCharacter for octal `\134`) survive intact. + prompt='\134' + printf '%s' "${prompt@P}" | hexdump -C + + # `\\\w` should yield `\`. Use a fixed cwd so brush vs. oracle + # output is deterministic. + cd / + prompt='\\\w' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Bare \\$ leaves no protective backslash (issue #1167)" + stdin: | + # `\$` alone must format to a bare `$` or `#` — pass 1's protective `\` + # must not leak when the resulting char (e.g. `#` for root) isn't + # double-quote-escapable. + prompt='\$' + printf '%s' "${prompt@P}" | hexdump -C + + # Same in a context where promptvars would otherwise re-expand a bare $: + var=SHOULD_NOT_APPEAR + prompt='\$var' + shopt -s promptvars + printf '%s' "${prompt@P}" | hexdump -C + shopt -u promptvars + + - name: "Backslash adjacent to unrecognized escape sequence" + stdin: | + # `\\\X` (Backslash + EscapedSequence(\X)) must reduce to a single `\X`. + # An over-eager protective `\` on the EscapedSequence would leak (X + # isn't dq-escapable) and emit `\\X`. + prompt='\\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\\?' + printf '%s' "${prompt@P}" | hexdump -C + + # X here is dq-escapable, so pass 2 consumes the EscapedSequence's own + # `\` and emits just the literal char. + prompt='\"' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\`' + printf '%s' "${prompt@P}" | hexdump -C diff --git a/userland/brush/source/brush-shell/tests/cases/compat/redirection.yaml b/userland/brush/source/brush-shell/tests/cases/compat/redirection.yaml new file mode 100644 index 0000000..56a30ed --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/redirection.yaml @@ -0,0 +1,353 @@ +name: "Redirection" +cases: + - name: "Redirection to null" + stdin: | + echo hi >/dev/null + + - name: "Output redirection to file" + stdin: | + echo hi >out.txt + ls out.txt + cat out.txt + + - name: "Output redirection to file (append)" + stdin: | + echo hi >>out.txt + echo there >>out.txt + ls out.txt + cat out.txt + + - name: "Input redirection from file" + test_files: + - path: "in.txt" + contents: | + Something here. + stdin: | + cat &2 + + - name: "Redirecting stdout + stderr to file" + stdin: | + function write_both() { + echo "$1" + echo "$2" >&2 + } + + echo "[Writing]" + write_both stdout stderr >& file.txt + echo "[Contents of file.txt]" + cat file.txt + + - name: "Invalid stdout + stderr redir" + ignore_stderr: true + stdin: | + function write_both() { + echo "$1" + echo "$2" >&2 + } + + echo "[Writing]" + write_both stdout stderr 2>& file.txt + echo "[Contents of file.txt]" + cat file.txt + + - name: "Redirecting an fd" + stdin: | + echo hi-out 1>stdout.txt + echo hi-error 2>stderr.txt >&2 + + - name: "Almost redirecting but not actually redirecting" + stdin: | + # Checks to make sure the '1' isn't treated as a file descriptor + echo 1 >&2 + + - name: "Redirection with arbitrary fd number" + stdin: | + echo hi 4>file.txt >&4 + + - name: "Process substitution: basic" + ignore_stdout: true # Specific fd-based paths may vary across runs + stdin: | + shopt -u -o posix + echo <(:) >(:) + echo <(:) <(:) + echo >(:) >(:) + + - name: "Process substitution: not in simple commands" + known_failure: true # Known to fail because we are only handling them in simple commands now + stdin: | + shopt -u -o posix + for f in <(echo hi); do echo $f; done + + - name: "Process substitution: builtins" + stdin: | + source <(echo VAR=100) + echo "var: ${VAR}" + + - name: "Process substitution: input redirection" + stdin: | + shopt -u -o posix + cat < <(echo hi) + + - name: "Process substitution: output redirection" + skip: true # TODO(redirection): Test is flaky; needs work. + stdin: | + shopt -u -o posix + echo hi > >(wc -l) + echo hi >> >(wc -l) + + - name: "Process substitution: wait for an output consumer" + known_failure: true # TODO(redirection): wait doesn't wait for a process substitution, so the shell exits before the consumer prints anything + min_oracle_version: "5.0" # bash 4.4 and earlier don't wait for process substitutions either + stdin: | + shopt -u -o posix + echo hi > >(sleep 0.5; while read -r l; do echo "out:$l"; done) + wait + echo "after wait" + + - name: "Process substitution: input" + stdin: | + shopt -u -o posix + var="value" + cat <(var="updated"; echo ${var}) + echo "Done." + echo "${var}" + + - name: "Input redirection from /dev/null" + stdin: | + cat < /dev/null + echo "exit: $?" + + - name: "Redirect stderr to /dev/null" + stdin: | + ls /non-existent-path 2>/dev/null + echo "exit: $?" + + - name: "Append redirection to /dev/null" + stdin: | + echo hi >>/dev/null + echo "exit: $?" + + - name: "Redirect to /dev/null via variable" + stdin: | + target=/dev/null + echo hi >"$target" + echo "exit: $?" + + - name: "Read and write /dev/null" + stdin: | + cat <>/dev/null + echo "exit: $?" + + - name: "Redirect stdout and stderr" + stdin: | + ls -d . non-existent-dir &>/dev/null + ls -d . non-existent-dir &>>/dev/null + + - name: "Process substitution: input + output" + stdin: | + shopt -u -o posix + cp <(echo hi) >(cat) + + - name: "Redirection in command substitution" + stdin: | + echo $(echo hi >&2) 2>stderr.txt + + - name: "Redirection in command substitution to non-standard fd" + ignore_stderr: true + stdin: | + echo $(echo hi >&3) 3>output.txt + + - name: "Redirection in command substitution in braces to non-standard fd" + ignore_stderr: true + stdin: | + { : $(echo hi >&3); } 3>output.txt + + - name: "Redirection in command substitution in subshell to non-standard fd" + ignore_stderr: true + stdin: | + ( : $(echo hi >&3); ) 3>output.txt + + - name: "Expansions in a command don't see its redirections" + known_failure: true # TODO(expansion): redirections are set up as they're reached, so they apply to command substitutions in later words and in all assignments + stdin: | + f() { echo /non-existent-dir/file.txt; echo following-command + + - name: "Redirection for opening read and write" + stdin: | + echo hi >file.txt + cat <>file.txt + + - name: "Close stdout" + ignore_stderr: true + stdin: | + echo "This should not appear" 1>&- + echo "This should appear" + + - name: "Close stderr" + stdin: | + non-existent-command 2>&- + echo "This should appear" + + - name: "Close stdout of external command" + ignore_stderr: true + stdin: | + env echo "This should not appear" 1>&- + echo "This should appear" + + - name: "Close standard streams of external commands: exit status" + known_failure: true # TODO(redirection): a closed standard stream is opened on the null device, not closed + ignore_stderr: true + # sh, not a coreutils program run by env: uutils' programs, being Rust ones, reopen a closed + # standard stream on the null device as they start. + stdin: | + sh -c 'exec 3>&1' 1>&- + echo "stdout: $?" + sh -c 'exec 3<&0' <&- + echo "stdin: $?" + + - name: "Redirect for an external command with one file descriptor to spare" + known_failure: true # TODO(redirection): the shell duplicates a redirected file to hand it to the process, which fails with no descriptor left (bash doesn't need one) + ignore_stderr: true + stdin: | + ulimit -n 64 + # Open files until none are left, then free one, so that opening the + # redirection's target succeeds, but nothing past that can be opened. + fd=10 + while [[ $fd -lt 64 ]] && eval "exec $fd/dev/null; do fd=$((fd+1)); done + eval "exec $((fd-1))<&-" + env true 3&2 + env sh -c 'echo "to stdout" >&2' 2>&1 + + - name: "Redirect external command output to the shell's stdin" + known_failure_platforms: ["windows"] # TODO(redirection): on Windows, the shell's stdin as an external command's output stream is replaced by the shell's stdout + ignore_stderr: true + stdin: | + env echo "to stdin" >&0 + echo "status: $?" + + - name: "Close output file descriptor" + ignore_stderr: true + stdin: | + # Open a new output fd (3). + exec 3>test.txt + # Write to the file. + echo "test" >&3 + # Close the file. + exec 3>&- + # Make sure we can't write to it. + echo "after close" >&3 || echo "Cannot write to closed fd" + + - name: "Close input file descriptor" + ignore_stderr: true + test_files: + - path: "input.txt" + contents: | + line1 + line2 + stdin: | + # Open an input file descriptor (3). + exec 3/dev/null || echo "Cannot read from closed fd" + + - name: "Duplicate input file descriptor" + test_files: + - path: "input.txt" + contents: | + test input line + stdin: | + # Open an input file descriptor (3). + exec 3fd3.txt 4>fd4.txt 5>fd5.txt + # Write to the new fds + echo "fd3" >&3 + echo "fd4" >&4 + echo "fd5" >&5 + # Close the fds + exec 3>&- 4>&- 5>&- + # Read from the files to verify + echo "[3]" + cat fd3.txt + echo "[4]" + fd4.txt + echo "[5]" + fd5.txt + + - name: "High file descriptor numbers" + stdin: | + # Open high numbered file descriptor + echo "test" 10>fd10.txt >&10 + # Dump the file + echo "[10]" + cat fd10.txt + + - name: "Redirection with out-of-range io number falls back to a word" + stdin: | + # A run of digits too large to fit an io number isn't a valid redirection + # prefix, so it's treated as an ordinary word argument instead. + echo hi 299999999999999999999999999999999999999999999999999999>out.txt + cat out.txt + + - name: "Redirection with out-of-range io number duplicated to a valid fd" + stdin: | + echo 99999999999999999999>&1 + + - name: "Redirection with out-of-range io numbers on both sides of a dup" + ignore_stderr: true # Error message wording differs between shells. + stdin: | + echo hi 299999999999999999999999999999999999999999999999999999>&20000000000000000000000000000000000000000000 + echo "exit: $?" + + - name: "Inheritance of fds from parent" + test_files: + - path: "test.sh" + contents: | + echo hi >&3 + stdin: | + args="" + + # NOTE: For now, brush doesn't inherit arbitrary all open fds by default like bash does. + # We need to explicitly specify which fds to inherit. + if [[ $BRUSH_VERSION ]]; then + args+=" --inherit-fd=3" + fi + + $0 $args test.sh 3>output.txt diff --git a/userland/brush/source/brush-shell/tests/cases/compat/signals.yaml b/userland/brush/source/brush-shell/tests/cases/compat/signals.yaml new file mode 100644 index 0000000..50f8fca --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/signals.yaml @@ -0,0 +1,611 @@ +name: "Signals" +# Signals sent to the shell itself, how it acts on them, and the dispositions the programs +# it starts inherit. Cases signal the shell from a program it runs (`sh -c 'kill … $PPID'`) +# or with `kill … $$`, so that each arrives at a known point. +incompatible_platforms: ["windows", "wasi"] +cases: + # + # When traps run + # + - name: "trap runs once for signals that arrive while a program runs" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo got' USR1 + sh -c 'kill -USR1 $PPID; kill -USR1 $PPID; kill -USR1 $PPID; echo child-done' + echo done + + - name: "trap runs for each signal that arrives between commands" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo got' USR1 + kill -USR1 $$ + kill -USR1 $$ + echo done + + - name: "trap runs again for its signal arriving during the trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + n=0 + trap 'n=$((n + 1)); echo "in $n"; [ "$n" -lt 3 ] && kill -USR1 $$; echo "out $n"' USR1 + kill -USR1 $$ + echo done + + # By job spec: brush's `$!` doesn't name a job that runs shell code (tracked in + # background_jobs.yaml). No `wait` follows the `kill`: under load, bash 5.3's can loop, + # reporting a job's process as not its child. + # brush nests a signal's trap 8 deep, and runs the rest in turn at the eighth level. (One + # line, so that the shell doesn't reach a safe point before reading the next.) + - name: "trap runs again for its signal, nested many deep" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + n=0; trap 'n=$((n + 1)); [ $n -lt 20 ] && kill -USR1 $$' USR1; kill -USR1 $$; echo "n=$n" + + - name: "trapped signal interrupts wait" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo got' USR1 + sleep 3 & + sh -c 'sleep 0.3; kill -USR1 $PPID' & + wait %1 + echo "wait=$?" + kill %1 + echo done + + # The process substitution signals the shell while `read` waits for its line. + - name: "trapped signal doesn't interrupt read" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo got' USR1 + exec 3< <(sleep 0.5; kill -USR1 $$; sleep 0.3; echo line) + read -r -u 3 x + echo "read=$? x=$x" + + - name: "trapped signal interrupts read in POSIX mode" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + set -o posix + trap 'echo got' USR1 + exec 3< <(sleep 0.5; kill -USR1 $$; sleep 0.3; echo line) + read -r -u 3 x + echo "read=$? x=$x" + + # + # Default actions, non-interactive + # + - name: "SIGTERM ends a non-interactive shell, after its EXIT trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo exit-trap' EXIT + kill -TERM $$ + echo after + + - name: "SIGINT ends a non-interactive shell, after its EXIT trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo exit-trap' EXIT + kill -INT $$ + echo after + + - name: "SIGHUP ends a non-interactive shell, after its EXIT trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo exit-trap' EXIT + kill -HUP $$ + echo after + + - name: "SIGUSR1 ends a non-interactive shell, after its EXIT trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo exit-trap' EXIT + kill -USR1 $$ + echo after + + # Sent from outside, through the kernel; it doesn't wait for the program to end. + - name: "SIGTERM ends a shell waiting for a program at once" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap '[ -e late ] && echo late || echo early' EXIT + sh -c 'kill -TERM $PPID; sleep 0.5; : > late' + echo after + + - name: "SIGTERM ends a shell blocked in read" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo "x=[$x]"' EXIT + exec 3< <(sleep 1; echo line) + sh -c 'sleep 0.3; kill -TERM $PPID' & + read -r -u 3 x + echo after + + # Each line is longer than a pipe holds: `head` must read all of the first before it exits, + # and the second can't be written without a reader, so it fails however the two are timed. + - name: "SIGPIPE from a builtin's write ends a pipeline stage" + known_failure: true # TODO(signals): EPIPE doesn't end the shell, as SIGPIPE ends bash + stdin: | + i=0; while [ "$i" -lt 3 ]; do printf '%065536d\n' 0; i=$((i + 1)); done | head -n 1 > /dev/null + echo "status=${PIPESTATUS[0]}" + + - name: "SIGPIPE from a builtin's write ends a loop writing to a pipeline" + skip: true # TODO(signals): hangs: the loop's failed writes don't end it + stdin: | + while :; do echo y; done | head -n 1 + echo "status=${PIPESTATUS[*]}" + + # The write errors go to /dev/null: brush words them differently. Each line is longer than + # what `head` and the pipe can hold between them, so that only the first write succeeds, + # however soon `head` exits. + - name: "SIGPIPE from a builtin's write runs the stage's PIPE trap" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + { trap 'echo t >> log' PIPE; i=0; while [ "$i" -lt 3 ]; do printf '%0262144d\n' 0; echo "printf=$?" >> log; i=$((i + 1)); done; } 2>/dev/null | head -n 1 > /dev/null + echo "status=${PIPESTATUS[0]}" + cat log; rm log + + # As above. + - name: "trap '' PIPE leaves a builtin's write failing" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap '' PIPE + { i=0; while [ "$i" -lt 3 ]; do printf '%0262144d\n' 0; echo "printf=$?" >> log; i=$((i + 1)); done; } 2>/dev/null | head -n 1 > /dev/null + echo "status=${PIPESTATUS[0]}" + cat log; rm log + + - name: "trap '' ignores SIGTERM" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap '' TERM + kill -TERM $$ + echo after + + # The inner shell starts with SIGUSR1 ignored, as programs of a shell that ignores it do. + - name: "a signal ignored when a non-interactive shell started can't be trapped" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap '' USR1 + "$BASH" -c 'trap "echo trapped" USR1; trap -p USR1; trap - USR1; kill -USR1 $$; echo after' + + - name: "SIGTERM ends the shell again after its trap is reset" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo t' TERM + trap - TERM + trap 'echo exit-trap' EXIT + kill -TERM $$ + echo after + + # + # A program that handles SIGINT + # + # The program signals the shell only once it has surely begun to wait for it: before then, + # a non-interactive bash, which catches SIGINT only while it waits, would die of it. + - name: "SIGINT a program handled doesn't end the shell" + skip: true # TODO(signals): doesn't pass reliably; the shell sometimes gets the SIGINT before it handles it + stdin: | + sh -c 'trap "" INT; sleep 0.1; kill -INT $PPID; echo child-done' + echo "after=$?" + + - name: "SIGINT a program run by command handled doesn't end the shell" + stdin: | + command sh -c 'trap "" INT; sleep 0.1; kill -INT $PPID; echo child-done' + echo "after=$?" + + - name: "SIGINT a program died of ends the shell" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo exit-trap' EXIT + sh -c 'kill -INT $PPID; kill -INT $$' + echo "after=$?" + + - name: "INT trap runs though the program handled SIGINT" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo int-trap' INT + sh -c 'trap "" INT; sleep 0.1; kill -INT $PPID; echo child-done' + echo "after=$?" + + - name: "INT trap runs when the program died of SIGINT" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo int-trap' INT + sh -c 'kill -INT $PPID; kill -INT $$' + echo "after=$?" + + - name: "Programs inherit an ignored SIGINT after the shell first waits for one" + skip: true # TODO(signals): flaky; tokio's SIGINT listener replaces the inherited ignore, but a wait for a program sets it up only once its select! polls it (in random order), so the ignore survives when the first program has already exited + # The shell starts another one ignoring SIGINT; that one waits for one program, and then + # another sends itself SIGINT. + stdin: | + sh -c 'trap "" INT; exec "$0" -c "$1"' "$BASH" 'sh -c "exit 0"; sh -c "kill -INT \$\$; echo survived"' + + - name: "SIGINT during a command substitution ends the shell" + stdin: | + x=$(kill -INT $$; echo inner) + echo "x=$x" + + # The shell traps the signal, so the outcome doesn't depend on whether the signal arrives + # before the shell begins to wait for the subshell or while it waits: either way the trap + # runs once the subshell is done. (Untrapped, a non-interactive shell dies of a SIGINT + # that arrives before it begins to wait.) + - name: "signal sent to the shell isn't the subshell's" + known_failure: true # TODO(signals): a subshell shares its parent's signals + stdin: | + trap 'echo T' INT + ( trap 'echo sub-int' INT; kill -INT $$; echo sub-after ) + echo "after=$?" + + - name: "a subshell resets the signal traps it inherits" + known_failure: true # TODO(signals): a subshell's own process isn't addressable apart from its parent's + ignore_stderr: true # bash also reports the subshell's death; that's job reporting + stdin: | + trap 'echo T' USR1 + ( kill -USR1 $BASHPID; echo sub-alive ) + echo "after=$?" + + - name: "a subshell's own trap handles a signal sent to it" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + ( trap 'echo sub' USR1; kill -USR1 $BASHPID ) + echo "after=$?" + + # Another program sends it, so it reaches the shell through the kernel. + - name: "a signal sent to the shell's process isn't its command substitution's" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo trapped' USR2 + x=$(/bin/kill -USR2 $$; echo sub) + echo "x=$x" + + - name: "a trapped signal sent to the shell waits for its subshell" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo T' TERM + ( kill -TERM $$; echo sub-after ) + echo "after=$?" + + # bash dies at once, and its subshell goes on without it. + - name: "a subshell goes on when the shell dies of a signal sent to it" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + ( kill -TERM $$; echo sub-after ) + echo "after=$?" + + # The inner subshell writes to a file, shown once it's there: bash's outer subshell dies at + # once, and the shell's report would otherwise race the orphaned inner one's output. + - name: "a signal sent to a subshell waits for the subshell it runs" + known_failure: true # TODO(signals): signals aren't delivered to the shell + ignore_stderr: true # bash also reports the subshell's death; that's job reporting + stdin: | + ( x=$BASHPID; ( kill -TERM $x; echo inner-after > inner.txt ); echo mid-after ) + status=$? + until [ -s inner.txt ]; do :; done + cat inner.txt; rm inner.txt + echo "after=$status" + + # bash ignores SIGQUIT, and an interactive bash SIGTERM; its subshells don't. + - name: "SIGQUIT ends a subshell, though the shell ignores it" + known_failure: true # TODO(signals): signals aren't delivered to the shell + ignore_stderr: true # bash also reports the subshell's death; that's job reporting + stdin: | + ( kill -QUIT $BASHPID; echo not-reached ) + echo "subshell=$?" + x=$(kill -QUIT $BASHPID; echo not-reached) + echo "cmdsubst=$? x=[$x]" + { kill -QUIT $BASHPID; echo not-reached; } | cat + echo "stage=${PIPESTATUS[*]}" + trap '' QUIT + ( kill -QUIT $BASHPID; echo ignored-reached ) + echo "ignored=$?" + + # A stage runs on a thread of its own, and the kernel may run the process's handler on + # another, after `kill` returns. + - name: "kill 0 in a pipeline stage ends the stage at once" + known_failure: true # TODO(signals): signals aren't delivered to the shell + ignore_stderr: true # bash may report the death of `cat`, which the signal reaches too, or not + stdin: | + trap 'echo T' USR1 + f() { kill -USR1 0; echo reached > reached.txt; } + f | cat + echo "stage=${PIPESTATUS[0]}" + [ -e reached.txt ] && echo "reached" + + - name: "a program's signal to the shell's group reaches its subshells" + known_failure: true # Deliberate (docs/explanation/signals.md, "Known differences"): it looks like a signal to the shell's process alone, which reaches no subshell + ignore_stderr: true # bash also reports the subshell's death; that's job reporting + args: + - "-i" + - "-c" + - | + f() { sh -c 'kill 0'; echo not-reached; } + f | cat + echo "status=${PIPESTATUS[*]}" + + - name: "SIGTERM ends an interactive shell's subshell, though the shell ignores it" + known_failure: true # TODO(signals): signals aren't delivered to the shell + ignore_stderr: true # bash also reports the subshell's death; that's job reporting + args: + - "-i" + - "-c" + - | + ( kill -TERM $BASHPID; echo not-reached ) + echo "subshell=$?" + { kill -TERM $BASHPID; echo not-reached; } | cat + echo "stage=${PIPESTATUS[*]}" + kill -TERM $$ + echo "shell survives" + + - name: "a subshell has a BASHPID of its own" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + [ "$BASHPID" = "$$" ] && echo same + ( [ "$BASHPID" != "$$" ] && echo differ ) + x=$(echo "$BASHPID") + [ "$x" != "$$" ] && echo differ + echo "$BASHPID" | { read -r y; [ "$y" != "$$" ] && echo differ; } + + - name: "a foreground program's death by a signal is reported" + known_failure: true # TODO(jobs): bash reports, on stderr, a foreground program or subshell that died of a signal other than SIGINT or SIGPIPE + stdin: | + sh -c 'kill -TERM $$' + echo after + + # Read with builtins only: a program would be one the shell waits for, which it catches + # SIGINT for. + - name: "a signal is caught only while the shell needs it" + incompatible_platforms: ["macos"] + known_failure: true # Deliberate (docs/explanation/signals.md, "Known differences"): brush's process collects the signals it catches, blocked, where bash's installs handlers, so /proc shows them blocked, not caught + stdin: | + cgt() { + local k v + while read -r k v; do + [ "$k" = SigCgt: ] && { local m=$((16#$v)); echo "$1 $2: $(( (m >> ($(kill -l $2) - 1)) & 1 ))"; } + done < /proc/$$/status + } + cgt start INT + /bin/true + cgt after-program INT + trap 'echo x' USR1 + cgt trapped USR1 + trap - USR1 + cgt reset USR1 + trap 'echo again' USR1 + /bin/kill -USR1 $$ + cgt retrapped USR1 + + # Another program sends it to the shell alone, while it waits for the pipeline's last stage. + - name: "a signal sent to the shell alone leaves its pipeline stages running" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo T' USR2 + ( sleep 0.2; /bin/kill -USR2 $$ ) & + echo in | { /bin/sleep 0.6; echo stage-done; } + echo "status=$?" + wait + + - name: "a subshell keeps the signals its parent ignores" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap '' USR1 + ( kill -USR1 $BASHPID; echo sub-alive ) + echo "after=$?" + + - name: "kill signals a background job running shell code" + known_failure: true # TODO(signals): a job running shell code can't be signalled + stdin: | + # Bounded, so that a shell that can't kill it doesn't wait forever. + n=0; while [ "$n" -lt 20 ]; do sleep 0.1; n=$((n + 1)); done & + kill %1 + wait %1 + echo "status=$?" + + # + # SIGCHLD traps + # + - name: "CHLD trap runs for each program that ends" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo chld' CHLD + /bin/true + /bin/true + echo done + + - name: "CHLD trap runs for each background job that ends" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo chld' CHLD + sleep 0.1 & + sleep 0.2 & + wait + echo done + + - name: "CHLD trap runs for each background job that ends, with job control" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + set -m + trap 'echo chld' CHLD + sleep 0.1 & + sleep 0.2 & + wait + echo done + + - name: "CHLD trap interrupts wait in POSIX mode" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + set -o posix + trap 'echo chld' CHLD + sleep 0.1 & + sleep 0.5 & + wait + echo "wait=$?" + + - name: "CHLD trap runs for each subshell and command substitution" + known_failure: true # TODO(signals): signals aren't delivered to the shell + stdin: | + trap 'echo chld' CHLD + (true) + echo $(echo a) $(echo b) + ( /bin/true; /bin/true ) + true + echo done + + # + # Dispositions programs inherit (read from /proc, so Linux only) + # + - name: "programs inherit the signals the shell ignores" + known_failure: true # TODO(signals): programs inherit dispositions from the process + incompatible_platforms: ["macos"] + stdin: | + show() { + local hex=${1#*:}; hex=${hex//[[:space:]]/} + local m=$((16#$hex)) out="" s n + for s in INT QUIT USR1 PIPE TSTP TTIN TTOU; do + n=$(kill -l "$s"); out+="$s=$(( (m >> (n - 1)) & 1 )) " + done + echo "$2: $out" + } + grep SigIgn /proc/self/status > fg.txt; show "$( sub.txt); show "$( tr.txt; show "$( bg.txt & wait; show "$( bg.txt & wait; show "$(p; echo "R[$((1 + 1))]"; mapfile -t a <&3; } | cat; echo "unreached" + #send:Enter + #expect:R[2] + #send:Ctrl+C + #expect-prompt + echo "S[$?]" + #send:Enter + #expect:S[130] + #expect-prompt + #send:Ctrl+D + + - name: "an interactive shell ignores SIGTERM and SIGQUIT" + known_failure: true # TODO(signals): signals aren't delivered to the shell + pty: true + ignore_stdout: true + ignore_whitespace: true + stdin: | + #expect-prompt + kill -TERM $$; kill -QUIT $$; echo "A[$((1 + 1))]" + #send:Enter + #expect:A[2] + #expect-prompt + #send:Ctrl+D + + # With job control, Ctrl+C reaches only the program; the shell acts on its dying of it. + # The loop is bounded, and the shell ends after it, so that a shell that goes on with the + # loop fails at once rather than when the harness gives up waiting. + - name: "Ctrl+C ends a loop running a program" + known_failure: true # TODO(signals): signals aren't delivered to the shell + pty: true + ignore_stdout: true + ignore_whitespace: true + stdin: | + #expect-prompt + echo "L[$((1 + 1))]"; for i in 1 2 3 4 5 6 7 8 9 10; do sleep 0.3; done; exit 7 + #send:Enter + #expect:L[2] + #send:Ctrl+C + #expect-prompt + echo "S[$?]" + #send:Enter + #expect:S[130] + #expect-prompt + #send:Ctrl+D diff --git a/userland/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml b/userland/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml new file mode 100644 index 0000000..46942e8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml @@ -0,0 +1,92 @@ +name: "Simple commands" +cases: + - name: "Simple command" + stdin: | + echo 1 + + - name: "Simple command with reserved word args" + stdin: | + echo then + + - name: "Command that's a directory" + ignore_stderr: true + stdin: | + mkdir test-dir + ./test-dir + echo "Result: $?" + + - name: "Non-existent command" + ignore_stderr: true + stdin: | + ./non-existent-command + echo "Result: $?" + + - name: "Redirection of errors" + stdin: | + ./non-existent-command 2>/dev/null + echo "Result: $?" + + - name: "Simple command with non-existent cwd" + # N.B. We intentionally fail here because there's no safe way to pick an + # alternate working directory that we are okay with. To our knowledge, + # there's no easy way to hold onto the current working directory past + # its deletion. + known_failure: true + stdin: | + mkdir test-dir + cd test-dir + rmdir $(pwd) + ls + + - name: "Command found in PATH is a directory" + ignore_stderr: true + stdin: | + mkdir -p pdir/prog + PATH="$PWD/pdir:$PATH" + prog + echo "Result: $?" + + - name: "Command found in PATH is not executable" + ignore_stderr: true + stdin: | + mkdir -p pdir pdir2 + touch pdir/only + PATH="$PWD/pdir:$PWD/pdir2:$PATH" + + # The shell resolves the name to the file and fails to run it, rather than + # reporting the name as not found. + only + echo "Result: $?" + + # An executable later in PATH still beats a non-executable earlier one. + touch pdir/both + printf '#!/bin/sh\necho HIT\n' > pdir2/both + chmod +x pdir2/both + both + echo "Result: $?" + + - name: "Command found in PATH is a directory shadowing a later executable" + stdin: | + mkdir -p pdir/prog pdir2 + printf '#!/bin/sh\necho HIT\n' > pdir2/prog + chmod +x pdir2/prog + PATH="$PWD/pdir:$PWD/pdir2:$PATH" + + # The directory is skipped, not treated as a command that shadows the executable. + prog + echo "Result: $?" + + - name: "Command found in PATH is not executable: search order and hashing" + ignore_stderr: true + stdin: | + mkdir -p d1 d2 + touch d1/only d2/only + PATH="$PWD/d1:$PWD/d2:$PATH" + + # The first non-executable file in PATH order is the one the name resolves to... + only + echo "Result: $?" + + # ...and the shell remembers it, even though it could not be run. + hash -t only | sed "s|^$PWD/||" + echo "Result: ${PIPESTATUS[0]}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml b/userland/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml new file mode 100644 index 0000000..fb55c14 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml @@ -0,0 +1,309 @@ +name: "Special parameters" +cases: + - name: "$_ - initial value matches $0" + stdin: | + [[ "$0" == "$_" ]] && echo "matches on start" + + - name: "$_ - last argument tracked across simple commands" + stdin: | + : one + echo "[$_]" + : two three four + echo "[$_]" + echo last_echo_arg + echo "[$_]" + + - name: "$_ - zero-arg command uses command name" + stdin: | + echo seed1 seed2 + : + echo "after colon: [$_]" + true + echo "after true: [$_]" + echo + echo "after bare echo: [$_]" + + - name: "$_ - args with spaces and special chars" + stdin: | + echo "seed last" + echo "[$_]" + echo "arg with spaces" 'arg-with-dashes' "arg:with:colons" + echo "[$_]" + echo "" trailing + echo "[$_]" + + - name: "$_ - builtin dispatch path" + stdin: | + echo seed last + : bu1 bu2 bu3 + echo "[$_]" + printf '%s\n' pf1 pf2 + echo "[$_]" + + - name: "$_ - declaration assignment uses expanded last argument" + stdin: | + value=world + declare declared=$value + echo "[$_]" + + - name: "$_ - wrapper assignments use expanded last argument" + stdin: | + value=world + builtin echo builtin=$value + echo "builtin: [$_]" + command echo command=$value + echo "command: [$_]" + + - name: "$_ - external command dispatch path" + stdin: | + echo seed last + $(which true) ext1 ext2 + echo "[$_]" + env ENV=1 $(which true) external_final + echo "[$_]" + + - name: "$_ - external command exports resolved path via env" + stdin: | + # When invoking an external command, it should see _ set + # to the resolved pathname of the command (not "last arg"). + $(which env) | grep '^_=' + + - name: "$_ - function dispatch sets to call-site last arg" + stdin: | + f() { :; } + echo seed_last + f call_a call_b + echo "[$_]" + + - name: "$_ - function body sees pre-invocation value on entry" + stdin: | + f() { + echo "entry: [$_]" + echo new_inner + echo "after inner echo: [$_]" + } + echo pre_seed + f call_arg + echo "after return: [$_]" + + - name: "$_ - nested function dispatch" + stdin: | + inner() { echo innerdone; } + outer() { inner ia ib; } + echo seed + outer oa ob + echo "[$_]" + + - name: "$_ - assignment-only function body" + stdin: | + f() { a=1; b=2; } + echo seed_last + f foo bar + echo "[$_]" + + - name: "$_ - cleared by assignment-only statements" + stdin: | + echo seed_last + x=assigned + echo "after assign: [$_]" + y=another + echo "after second assign: [$_]" + : colonarg + echo "after colon: [$_]" + + - name: "$_ - cleared by assignment with command substitution" + stdin: | + echo seed_last + z=$(echo subst_inner) + echo "after assignment: [$_]" + + - name: "$_ - single-stage pipeline does not update parent" + stdin: | + echo prev_a prev_b + echo pipe_a pipe_b | cat + echo "[$_]" + + - name: "$_ - multi-stage pipeline does not update parent" + stdin: | + echo prev_a prev_b + echo pipe_a pipe_b | cat | cat + echo "[$_]" + + - name: "$_ - and-chain tracks last arg of last executed command" + stdin: | + echo seed_last + true && echo a1 a2 + echo "[$_]" + true && true t1 t2 + echo "[$_]" + + - name: "$_ - or-chain tracks last arg of last executed command" + stdin: | + echo seed_last + false || echo or1 or2 + echo "[$_]" + + - name: "$_ - command-not-found update" + ignore_stderr: true + stdin: | + echo seed_last + nosuchcmd_xyzzy fail_a fail_b + echo "[$_]" + + - name: "$_ - for loop body" + stdin: | + echo seed_last + for i in 1 2 3; do :; done + echo "[$_]" + for i in a b c; do echo "loop $i"; done + echo "[$_]" + + - name: "$_ - while loop body" + stdin: | + echo seed_last + i=0 + while [ $i -lt 2 ]; do echo "iter $i"; i=$((i+1)); done + echo "[$_]" + + - name: "$_ - until loop body" + stdin: | + echo seed_last + i=0 + until [ $i -ge 2 ]; do i=$((i+1)); done + echo "[$_]" + + - name: "$_ - if/then body" + stdin: | + echo seed_last + if true; then echo then_a then_b; fi + echo "[$_]" + + - name: "$_ - case body" + stdin: | + echo seed_last + case x in + x) echo match_a match_b ;; + esac + echo "[$_]" + + - name: "$_ - eval tracks eval's last arg" + stdin: | + echo seed_last + eval "echo evaled one two" + echo "[$_]" + + - name: "$_ - source tracks sourced script's commands" + test_files: + - path: sourced.sh + contents: | + echo sourced_a sourced_b + stdin: | + echo seed_last + . ./sourced.sh + echo "[$_]" + + - name: "$_ - subshell does not leak into parent" + stdin: | + echo outer_a outer_b + (echo inner1 inner2; echo "sub: [$_]") + echo "parent: [$_]" + + - name: "$_ - background job + wait" + stdin: | + echo seed_last + (:) & + wait + echo "[$_]" + + - name: "$_ - exported attribute after update" + stdin: | + export _=initial_value + echo first last + # After commands have run, does _ still have the -x attribute? + declare -p _ | grep -c '^declare -x' + + - name: "$_ - declare -p reflects updated value" + stdin: | + export _=initial_value + echo first last + declare -p _ + + - name: "$_ - readonly is honored" + ignore_stderr: true + stdin: | + readonly _=frozen + echo hi there + echo "[$_]" + + - name: "$_ - exec does not override _ in child env" + known_failure: true # TODO(exec): exec sets _ to the program's path, as for any external command + stdin: | + # bash does not set _ in the child's env for exec; the child inherits + # the parent's _ value. + (exec $(which env)) | grep '^_=' + + - name: "$_ - exec at the top level does not override _ in child env" + stdin: | + # Unlike in a subshell, exec here replaces the shell itself. What _ the child + # inherits differs between shells (it's the shell's own path), so check only + # that exec didn't set it to the program's. + printenv_path=$(which printenv) + underscore=$("$0" -c 'exec "$(which printenv)" _') + [[ "${underscore}" == "${printenv_path}" ]] && echo "exec set _" || echo "exec left _ alone" + + - name: "$_ - exec passes on the _ the shell inherited" + stdin: | + # The shell running "$0" sets its _ to the path it ran it by; exec passes that on. + underscore=$("$0" -c 'exec printenv _') + [[ "${underscore}" == "$0" ]] && echo "inherited _" || echo "other _: ${underscore}" + + - name: "$_ - without one to inherit, a shell starts with an unexported $0" + stdin: | + env -u _ "$0" -c '[[ $_ == "$0" ]] && echo "_ is \$0"; exec printenv _' + echo "printenv: $?" + + - name: "$_ - exec after a command passes no _" + stdin: | + # Updating $_ unexports it, so a command run before exec leaves none to pass on. + "$0" -c 'true; exec printenv _' + echo "printenv: $?" + + - name: "$@ and $*" + stdin: | + function myfunc() { + echo "--myfunc called--" + + echo "COUNT: $#" + echo "AT-SIGN VALUE: '$@'" + echo "STAR VALUE: '$*'" + + for arg in $@; do + echo "AT-SIGN ELEMENT: '${arg}'" + done + + for arg in $*; do + echo "STAR ELEMENT: '${arg}'" + done + + for arg in "$@"; do + echo "DOUBLE-QUOTED AT-SIGN ELEMENT: '${arg}'" + done + + for arg in "$*"; do + echo "DOUBLE-QUOTED STAR ELEMENT: '${arg}'" + done + } + + myfunc + myfunc 1 + myfunc 1 2 + myfunc "a b c" 2 + + - name: "$_ - command and builtin query forms" + stdin: | + echo seed > /dev/null + command -v echo > /dev/null; printf 'command -v: [%s]\n' "$_" + echo seed > /dev/null + builtin > /dev/null; printf 'builtin bare: [%s]\n' "$_" + echo seed > /dev/null + command > /dev/null; printf 'command bare: [%s]\n' "$_" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/status.yaml b/userland/brush/source/brush-shell/tests/cases/compat/status.yaml new file mode 100644 index 0000000..7c5dce8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/status.yaml @@ -0,0 +1,131 @@ +name: "Status tests" +cases: + - name: "Basic status" + stdin: | + cat /non/existent >/dev/null + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Parse error status" + ignore_stderr: true + stdin: | + # Generate parse error + for f done + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Pipeline status" + stdin: | + /non/existent/program 2>/dev/null | cat + echo "Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Command substitution status" + stdin: | + x=$(echo hi | wc -l) + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + x=$(cat /non/existent 2>/dev/null) + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Subshell status" + stdin: | + (echo hi | wc -l) + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + (cat /non/existent 2>/dev/null) + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Stored status" + stdin: | + false + status=$? + echo "Stored status from false: $status" + + true + status=$? + echo "Stored status from true: $status" + + - name: "Status from assignment" + stdin: | + false + false_status=$? + assign_status=$? + + echo "Status after assignment: $assign_status" + + - name: "Status from assignment with xtrace" + stdin: | + set -x + false + scalar=value + echo "Status after scalar assignment: $?" + + false + arr=(1 2) + echo "Status after array assignment: $?" + + false + appended+=more + echo "Status after appending assignment: $?" + + false + first=1 second=2 + echo "Status after multiple assignments: $?" + + false + expanded=${undefined:-fallback} + echo "Status after expanded assignment: $?" + + - name: "Status from assignment with command substitution and xtrace" + stdin: | + set -x + f() { + echo "out$1" + return $1 + } + + captured=$(f 42) + echo "Status after substitution assignment: $?" + + true + captured=$(f 42) + echo "Status after substitution assignment: $?" + + - name: "Status within assignments" + stdin: | + f() { + return $1 + } + + x=$(f 42) y=0 z=$? w=$? + echo "x: $x; y: $y; z: $z; w: $w" + + - name: "Status in for loop" + stdin: | + function ret() { + return $1 + } + + for f in a b; do + echo "Loop entry: $?" + ret 1 + echo "Loop 1: $?" + ret 2 + echo "Loop 2: $?" + done + + echo "After loop: $?" + + - name: "Status in arithmetic for loop" + stdin: | + function ret() { + return $1 + } + + for ((i = 0; i < 2; i++)); do + echo "Loop entry: $?" + ret 1 + echo "Loop 1: $?" + ret 2 + echo "Loop 2: $?" + done + + echo "After loop: $?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/variables.yaml b/userland/brush/source/brush-shell/tests/cases/compat/variables.yaml new file mode 100644 index 0000000..9d682b8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/variables.yaml @@ -0,0 +1,169 @@ +name: "Variable tests" +cases: + - name: "Appending to a variable" + stdin: | + x=something + x+=here + echo "x: ${x}" + + - name: "Appending an integer to an integer variable" + stdin: | + declare -i x=10 + echo "x: ${x}" + x+=5 + echo "x: ${x}" + x+=value + echo "x: ${x}" + + y=value + declare -i y + echo "y: ${y}" + y+=5 + echo "y: ${y}" + + - name: "Append to an unset variable" + stdin: | + declare -a myvar + myvar+=abc + echo "myvar: ${myvar}" + + declare -i myint + myint+=abc + echo "myint: ${myint}" + + - name: "Parameter length of unset variable with nounset" + ignore_stderr: true + stdin: | + set -u + echo "${#unset_var}" 2>&1 || true + arr=() + echo "${#arr[0]}" + echo "${#arr[@]}" + arr2=(hello world) + echo "${#arr2[5]}" + echo "${#arr2[0]}" + + - name: "local -I inherits from enclosing scope" + stdin: | + g=global-value + f() { + local -I g + echo "[$g]" + } + f + echo "[$g]" + + - name: "local -I with append" + stdin: | + v=base + f() { + local -I v+=" +more" + echo "[$v]" + } + f + echo "[$v]" + + - name: "local -I combined flags (-Ix)" + stdin: | + f() { + local -Ix e + declare -p e + } + f + + - name: "local -I without an enclosing variable falls through to normal creation" + stdin: | + f() { + local -I nosuch="fresh" + echo "[$nosuch]" + } + f + + - name: "local -I with a matching explicit array kind inherits value and attributes" + stdin: | + declare -ax a=(one two) + declare -A m=([k]=v) + f() { + local -I -a a; declare -p a + local -I -A m; declare -p m + } + f + + - name: "local -I when a same-name local already exists updates it in place" + stdin: | + v=outer + f() { local v=first; local -I v+=suffix; declare -p v; } + f + declare -p v + + - name: "local without -I does not inherit the enclosing array kind" + stdin: | + declare -A m=([foo]=x) + f() { local m[foo]=y; declare -p m; } + f + declare -p m + + # + # `set -a` gives every variable a declaration creates or modifies the export attribute. + # That includes a `local -I` that updates a same-name local already in the current scope. + # + - name: "local -I over a same-scope local honors set -a auto-export" + stdin: | + v=outer + f() { local v=first; set -a; local -I v+=suffix; declare -p v; } + f + + # + # Dynamic variables (DIRSTACK and friends) resolve to real values when inherited. The element + # count is compared to its own pre-inheritance value rather than pinned; the count itself is + # covered separately (see "DIRSTACK includes the current directory"). + # + - name: "local -I -a over a dynamic variable inherits its resolved contents" + stdin: | + mkdir sub + pushd sub > /dev/null + before=${#DIRSTACK[@]} + f() { local -I -a DIRSTACK; echo "inherited_all=$(( ${#DIRSTACK[@]} == before ))"; } + f + + # + # `local -g` declares into the global scope instead of the function's own, the way `declare -g` + # does. brush's `local` always creates a local, so `-g` is silently ignored: the variable + # disappears when the function returns, and a same-name local is written instead of the global. + # + - name: "local -g creates a global rather than a local" + known_failure: true # TODO(local): `local -g` is ignored; the declaration is always local + stdin: | + f() { local -g g=1; } + f + declare -p g + + - name: "local -g updates the global while a same-name local shadows it" + known_failure: true # TODO(local): `local -g` is ignored; the declaration is always local + stdin: | + h=outer + f() { + local h=inner + local -g h=global + echo "inside: $h" + } + f + echo "outside: $h" + + - name: "local -g carries its attributes and array kind to the global" + known_failure: true # TODO(local): `local -g` is ignored; the declaration is always local + stdin: | + f() { + local -gx s=str + local -g -a arr=(x y) + } + f + declare -p s arr + + - name: "local -g with a bare name declares an unset global" + known_failure: true # TODO(local): `local -g` is ignored; the declaration is always local + stdin: | + f() { local -g bare; } + f + declare -p bare + echo "status=$?" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml b/userland/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml new file mode 100644 index 0000000..dbbb725 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml @@ -0,0 +1,293 @@ +name: "Well-known variable tests" +cases: + - name: "Basic defaulted PATH var" + stdin: | + if [[ -z "$PATH" ]]; then + echo "PATH is not set or is empty" + else + echo "PATH is set and non-empty" + fi + + - name: "Basic variables" + known_failure: true # Not fully implemented + stdin: | + declare -p > ./vars.txt + # Filter out variables set by test infrastructure. + # Filter out brush-specific variables. + cat vars.txt | grep '^declare ' | grep -v LLVM | grep -v BRUSH | sed -e 's/=.*//' + rm vars.txt + + - name: "Static well-known variables" + stdin: | + echo "EUID: $EUID" + echo "UID: $UID" + + [[ $BASHPID == $$ ]] + echo 'BASHPID == \$$: ' $? + + [[ $BASH_ARGV0 == $0 ]] + echo 'BASH_ARGV0 == $0: ' $? + + [[ $MACHTYPE == ${BASH_VERSINFO[5]} ]] + echo 'MACHTYPE == ${BASH_VERSINFO[5]: ' $? + + - name: "OSTYPE pattern match for current platform" + stdin: | + case "$OSTYPE" in + linux*) echo "prefix=linux" ;; + darwin*) echo "prefix=darwin" ;; + freebsd*) echo "prefix=freebsd" ;; + netbsd*) echo "prefix=netbsd" ;; + openbsd*) echo "prefix=openbsd" ;; + dragonfly*) echo "prefix=dragonfly" ;; + solaris*) echo "prefix=solaris" ;; + cygwin*|msys*|windows*) + echo "prefix=windows-like" ;; + *) echo "prefix=other:$OSTYPE" ;; + esac + + - name: "dollar-_" + stdin: | + # Check on start + [[ "$0" == "$_" ]] && echo '$_ matches $0 on start' + + # Now do something + echo first middle last >/dev/null + echo "\$_ after first echo command: $_" + echo + echo "\$_ after second echo command: $_" + + # See if a child process sees _ set to its resolved pathname. + echo "Spawning child process..." + env -v | grep '^_=' + echo "\$_ after child process exited: $_" + + - name: "BASH_ALIASES" + stdin: | + echo "Initial BASH_ALIASES: " ${BASH_ALIASES[@]} + alias x=y + echo "Updated BASH_ALIASES: " ${BASH_ALIASES[@]} + unalias x + echo "Final BASH_ALIASES: " ${BASH_ALIASES[@]} + + - name: "BASHOPTS" + min_oracle_version: "5.3" # Changed in bash 5.3 + stdin: | + # Workaround for brush forcing on extglob + shopt -u extglob + + echo "BASHOPTS: $BASHOPTS" + + - name: "BASH_SOURCE" + max_oracle_version: "5.2" # Behavior changed in bash 5.3; still needs investigation + stdin: | + echo "Input entry"; declare -p BASH_SOURCE + + function myfunc() { + echo "In function: \$1: $1" + declare -p BASH_SOURCE + + if [[ $1 -gt 0 ]]; then + myfunc $(( $1 - 1 )) + fi + } + + myfunc 4 + + echo "Input exit"; declare -p BASH_SOURCE + + - name: "FUNCNAME" + stdin: | + echo "Input entry"; declare -p FUNCNAME + + function myfunc() { + echo "In function: \$1: $1" + declare -p FUNCNAME + + if [[ $1 -gt 0 ]]; then + myfunc $(( $1 - 1 )) + fi + } + + myfunc 4 + + echo "Input exit"; declare -p FUNCNAME + + # Regression test for https://github.com/reubeno/brush/issues/1212: BASH_ARGV + # and BASH_ARGC are populated for script-level frames (the top-level invocation + # plus sourced scripts) even without extended debugging mode. A sourced script + # invoked with no positional arguments contributes its own name. + - name: "BASH_ARGV/BASH_ARGC - sourced script without arguments" + test_files: + - path: "argv.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + . ./argv.sh + + - name: "BASH_ARGV/BASH_ARGC - nested sourced scripts without arguments" + test_files: + - path: "outer.sh" + contents: | + . ./inner.sh + - path: "inner.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + . ./outer.sh + + - name: "BASH_ARGV/BASH_ARGC - empty inside function without extdebug" + stdin: | + myfunc() { + declare -p BASH_ARGV BASH_ARGC + } + myfunc a b c + + - name: "BASH_ARGV/BASH_ARGC - sourced script without arguments with extdebug" + test_files: + - path: "argv.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + shopt -s extdebug + . ./argv.sh + + - name: "BASH_SUBSHELL" + stdin: | + echo "Initial BASH_SUBSHELL: $BASH_SUBSHELL" + (echo "Subshell BASH_SUBSHELL: $BASH_SUBSHELL") + ( + ( echo "Nested subshell BASH_SUBSHELL: $BASH_SUBSHELL" ) + ) + + echo $(echo "Command substitution BASH_SUBSHELL: $BASH_SUBSHELL") + + - name: "BASH_SUBSHELL counts a subshell in a pipeline stage once" + known_failure: true # TODO(pipeline): a subshell that is a pipeline stage counts as two levels + stdin: | + : | ( echo "last stage: $BASH_SUBSHELL" ) + ( echo "first stage: $BASH_SUBSHELL" ) | cat + : | { echo "group stage: $BASH_SUBSHELL"; } + echo "substitution: $(: | ( echo $BASH_SUBSHELL ))" + + - name: "BASH_VERSINFO" + stdin: | + shopt -s extglob + + echo "len(BASH_VERSINFO): " ${#BASH_VERSINFO[@]} + + # Only inspect the first 4 elements. + for ((i = 0; i < 4; i++)); do + cleaned=${BASH_VERSINFO[i]//+([0-9])/d} + echo "Cleaned BASH_VERSINFO[$i]: $cleaned" + done + + - name: "BASH_VERSION" + stdin: | + shopt -s extglob + + # Replace specific numbers with placeholder text. + cleaned=${BASH_VERSION//+([0-9])/d} + echo "Cleaned BASH_VERSION: $cleaned" + + - name: "COMP_WORDBREAKS" + stdin: | + dump_var() { + echo -n "COMP_WORDBREAKS:" + for ((i=0; i<${#COMP_WORDBREAKS}; i++)); do + c="${COMP_WORDBREAKS:i:1}" + echo " ${c@Q}" + done | sort + } + + dump_var + + - name: "EPOCHSECONDS" + stdin: | + external=$(date +%s) + + es1=${EPOCHSECONDS} + [[ $es1 =~ ^[0-9]+$ ]] && echo "EPOCHSECONDS is a number" + + es2=${EPOCHSECONDS} + [[ $es2 =~ ^[0-9]+$ ]] && echo "EPOCHSECONDS is still a number" + + (( es1 >= external )) && echo "Time is moving forward" + (( es1 >= es2 )) && echo "Time is moving forward within the shell" + + - name: "GROUPS" + skip: true # TODO(well-known-vars): macOS failure needs investigation (passes elsewhere) + stdin: | + (for group in ${GROUPS[@]}; do + echo $group + done) | sort + + - name: "LINENO" + stdin: | + echo "LINENO: $LINENO" + echo "LINENO: $LINENO" + echo "LINENO: $LINENO" + + - name: "LINENO with multi-line input" + stdin: | + for f in 1 2 3; do + echo "LINENO: $LINENO" + done + + - name: "RANDOM" + stdin: | + first=${RANDOM} + second=${RANDOM} + + [[ $first != $second ]] && echo "Confirmed RANDOM at least isn't static" + [[ $first -ge 0 && $first -lt 32768 ]] && echo "RANDOM value is within expected range" + [[ $second -ge 0 && $second -lt 32768 ]] && echo "RANDOM value is within expected range" + + - name: "SHELL" + stdin: | + declare -p SHELL + echo "SHELL: $SHELL" + + - name: "SHELLOPTS" + stdin: | + echo "SHELLOPTS: $SHELLOPTS" + + - name: "SHLVL" + stdin: | + echo "SHLVL: $SHLVL" + bash -c 'echo "bash SHLVL: $SHLVL"' + $0 -c 'echo "nested SHLVL: $SHLVL"' + + - name: "SRANDOM" + stdin: | + first=${SRANDOM} + second=${SRANDOM} + + [[ $first != $second ]] && echo "Confirmed SRANDOM at least isn't static" + + - name: "EPOCHREALTIME has fixed six-digit microsecond precision" + stdin: | + # Tools strip the dot and do integer math, so the fractional part must always be + # exactly six digits (like bash), regardless of trailing zeros. + for i in 1 2 3 4 5; do + [[ $EPOCHREALTIME =~ ^[0-9]+\.[0-9]{6}$ ]] || echo "bad format: $EPOCHREALTIME" + done + echo "done" + + - name: "Appending to SECONDS adds to its value" + known_failure: true # appending to a dynamic variable does nothing + stdin: | + SECONDS+=100 + echo "$((SECONDS >= 100))" + f() { echo "$((SECONDS >= 200))"; } + SECONDS+=100 f + + - name: "BASH_SOURCE, BASH_LINENO, and BASH_ARGV can't be unset" + known_failure: true # TODO(variables): unset removes them with status 0 + ignore_stderr: true + stdin: | + unset BASH_SOURCE; echo "BASH_SOURCE: $?" + unset BASH_LINENO; echo "BASH_LINENO: $?" + unset BASH_ARGV; echo "BASH_ARGV: $?" + unset FUNCNAME; echo "FUNCNAME: $?" + diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml new file mode 100644 index 0000000..94b78fa --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml @@ -0,0 +1,134 @@ +name: "Array expansion" +cases: + - name: "Array access" + stdin: | + y=(a b c) + + echo ${y[@]} + echo ${y[*]} + + echo "\${y[@]}: ${y[@]}" + echo "\${y[*]}: ${y[*]}" + + var1="${y[@]}" + echo "var1: ${var1}" + echo $var1 + + var2=${y[@]} + echo "var2: ${var2}" + echo $var2 + + var3="${y[*]}" + echo "var3: ${var3}" + echo $var3 + + var4=${y[*]} + echo "var4: ${var4}" + echo $var4 + + for f in "${y[@]}"; do + echo "quoted-@ => Element: $f" + done + + for f in ${y[@]}; do + echo "@ => Element: $f" + done + + for f in "${y[*]}"; do + echo "quoted-* => Element: $f" + done + + for f in ${y[*]}; do + echo "* => Element: $f" + done + + - name: "Empty arrays" + stdin: | + myarray=() + + for f in "${myarray[@]}"; do + echo "Quoted at-sign element: |$f|" + done + + for f in ${myarray[@]}; do + echo "Unquoted at-sign element: |$f|" + done + + for f in "${myarray[*]}"; do + echo "Quoted at-sign element: |$f|" + done + + for f in ${myarray[*]}; do + echo "Unquoted at-sign element: |$f|" + done + + - name: "Mixing array elements and other words" + stdin: | + myarray=(a b c) + + for f in "${myarray[@]} d e f"; do + echo "ELEMENT: $f" + done + + for f in ${myarray[@]} d e f; do + echo "ELEMENT: $f" + done + + - name: "Passing array elements to func" + stdin: | + myfunc() { + echo "In function" + for arg in $@; do + echo "ARG: |${arg}|" + done + } + + arr=(a b c "" "e f") + myfunc ${arr[@]} + myfunc "${arr[@]}" + + - name: "Array length" + stdin: | + y=(a b c) + echo "len(y) = ${#y}" + echo "len(y[*]) = ${#y[*]}" + echo "len(y[@]) = ${#y[@]}" + + - name: "Array length: single item" + stdin: | + z=("something here") + echo "len(z) = ${#z}" + echo "len(z[*]) = ${#z[*]}" + echo "len(z[@]) = ${#z[@]}" + + - name: "Array keys: indexed array" + stdin: | + arr=("element1" "element2" "element3") + echo "@: ${!arr[@]}" + echo "*: ${!arr[*]}" + + echo "Dumping [@]" + for i in "${!arr[@]}"; do + echo "@i: $i" + done + + echo "Dumping [*]" + for i in "${!arr[*]}"; do + echo "*i: $i" + done + + - name: "Array keys: empty array" + stdin: | + arr=() + echo "@: ${!arr[@]}" + echo "*: ${!arr[*]}" + + echo "Dumping [@]" + for i in "${!arr[@]}"; do + echo "@i: $i" + done + + echo "Dumping [*]" + for i in "${!arr[*]}"; do + echo "*i: $i" + done diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml new file mode 100644 index 0000000..73a6ac9 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml @@ -0,0 +1,371 @@ +name: "Brace expansion" +cases: + - name: "Expansion with curly braces: commas" + stdin: | + echo "{a,b} =>" + echo {a,b} + echo + + echo "{a} =>" + echo {a} + echo + + echo "{a} =>" + echo {a} + echo + + echo "a{,}b =>" + echo a{,}b + echo + + echo "{} =>" + echo {} + echo + + echo "1{a,b}2 =>" + echo 1{a,b}2 + echo + + echo "1{a,b}2{c,d}3 =>" + echo 1{a,b}2{c,d}3 + echo + + echo "{a,b}{1,2} =>" + echo {a,b}{1,2} + echo + + echo 'W\{1,2}X =>' + echo W\{1,2}X + echo + + echo 'W{1,2\}X =>' + echo W{1,2\}X + echo + + echo '"W{1,2}X" =>' + echo "W{1,2}X" + echo + + var=value + bar=other + echo '{$var,$ba}r =>' + echo {$va,$ba}r + echo + + echo '\${a,b} =>' + echo \${a,b} + echo + + - name: "Expansion with curly braces: alpha ranges" + stdin: | + echo "{a..f} =>" + echo {a..f} + echo + + echo "{a..f..2} =>" + echo {a..f..2} + echo + + echo "{a..f..0} =>" + echo {a..f..0} + echo + + echo "{m..f} =>" + echo {m..f} + echo + + - name: "Expansion with curly braces: numeric ranges" + stdin: | + echo "{2..9} =>" + echo {2..9} + echo + + echo "{+2..+9} =>" + echo {+2..+9} + echo + + echo "{2..2} =>" + echo {2..2} + echo + + echo "{2..1} =>" + echo {2..1} + echo + + echo "{10..2..0} =>" + echo {10..2..0} + echo + + echo "{10..2..2} =>" + echo {10..2..2} + echo + + echo "{10..2..-2} =>" + echo {10..2..-2} + echo + + echo "{2..9..2} =>" + echo {2..9..2} + echo + + echo "{2..9..0} =>" + echo {2..9..0} + echo + + echo "{-1..-10} =>" + echo {-1..-10} + echo + + echo "{-10..-1} =>" + echo {-10..-1} + echo + + echo "{-1..-10..2} =>" + echo {-1..-10..2} + echo + + - name: "Expansion with curly braces: nested" + stdin: | + echo "W{{1..3},a,x}Y =>" + echo W{{1..3},a,x}Y + echo + + - name: "Iterate through modified array" + stdin: | + array=("aa" "ba" "ca") + + echo "With quotes:" + for i in "${array[@]%a}"; do + echo "Element: '$i'" + done + + echo "Without quotes:" + for i in ${array[@]%a}; do + echo "Element: '$i'" + done + + - name: "Expansion with curly braces: zero-padded numeric sequences" + stdin: | + echo {01..05} + echo {1..05} + echo {001..3} + echo {09..11} + echo {0..03} + echo {0009..11} + echo {05..01} + echo {010..1..3} + echo {01..10..2} + echo x{01..03}y + + - name: "Expansion with curly braces: a lone zero does not pad" + stdin: | + echo {0..3} + echo {0..10} + echo {10..0} + echo {-0..3} + echo {1..5} + + - name: "Expansion with curly braces: zero padding with signs" + stdin: | + echo {-01..01} + echo {005..-2} + echo {-1..01} + echo {-001..2} + echo {01..-1} + echo {-01..-05} + echo {00..-3} + echo {+01..3} + echo {+01..05} + + - name: "Expansion with curly braces: numeric bound too large for the sequence" + stdin: | + echo {99999999999999999999..1} + echo {1..99999999999999999999} + echo {-9223372036854775809..-9223372036854775807} + + - name: "Expansion with curly braces: increment too large for the sequence" + stdin: | + echo {1..5..99999999999999999999} + echo {1..5..-99999999999999999999} + echo {1..5..9223372036854775808} + echo {a..e..99999999999999999999} + echo x{1..5..99999999999999999999}y + echo "still running" + + - name: "Expansion with curly braces: increment at the edge of the range" + stdin: | + echo {1..5..9223372036854775807} + echo {5..1..9223372036854775807} + echo {1..5..-9223372036854775808} + echo {a..e..9223372036854775807} + echo {a..e..-9223372036854775808} + + - name: "Expansion with curly braces: decreasing sequence with an increment of i64::MIN" + min_oracle_version: "5.3" # 5.2 still expanded these + stdin: | + echo {5..1..-9223372036854775808} + echo {3..3..-9223372036854775808} + echo {e..a..-9223372036854775808} + + - name: "Expansion with curly braces: decreasing sequence stepping past the end of the range" + stdin: | + echo {-9223372036854775807..-9223372036854775808..2} + echo {-9223372036854775806..-9223372036854775808..5} + echo {z..a..200} + echo {z..a..4294967297} + echo {Z..A..30} + + - name: "Expansion with curly braces: increasing sequence stepping past the end of the range" + stdin: | + echo {9223372036854775800..9223372036854775807..5} + echo {9223372036854775806..9223372036854775807..2} + echo {1..5..4294967296} + echo {1..5..4294967297} + echo {a..e..4294967296} + echo {a..z..200} + + - name: "Expansion with curly braces: numeric bounds at the edge of the range" + stdin: | + echo {-9223372036854775808..-9223372036854775806} + echo {9223372036854775805..9223372036854775807} + + - name: "Expansion with curly braces: unquoted array[@] inside the list" + stdin: | + arr=(1 2) + set -- x{${arr[@]},y} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Expansion with curly braces: quoted array[@] inside the list" + stdin: | + arr=("1 1" 2) + set -- x{"${arr[@]}",y} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Expansion with curly braces: empty array inside the list" + stdin: | + arr=() + set -- x{${arr[@]},y} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Expansion with curly braces: empty array and positional elements survive" + stdin: | + IFS=: + arr=("" 2) + set -- ${arr[@]}{a,b} + echo "Array count: $#" + for arg in "$@"; do echo "[$arg]"; done + + arr=(2 "") + set -- {a,b}${arr[@]} + echo "Trailing empty count: $#" + for arg in "$@"; do echo "[$arg]"; done + + f() { + set -- $@{a,b} + echo "Positional count: $#" + for arg in "$@"; do echo "[$arg]"; done + } + f "" 2 + + - name: "Expansion with curly braces: quoted $@ inside the list" + stdin: | + f() { set -- pre{"$@",z}; echo "Count: $#"; for arg in "$@"; do echo "[$arg]"; done; } + f "p p" q + + - name: "Expansion with curly braces: unquoted $* inside the list" + stdin: | + f() { set -- {$*,z}; echo "Count: $#"; for arg in "$@"; do echo "[$arg]"; done; } + f "p p" q + + # An alternative that expands to nothing at all yields no word, exactly as an + # unquoted empty expansion would; it does not become an empty argument. + + - name: "Expansion with curly braces: empty alternative yields no word" + stdin: | + set -- {a,} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + set -- {,} + echo "Count: $#" + set -- {,,}q + echo "Count: $#" + + - name: "Expansion with curly braces: empty alternative with a prefix keeps the prefix" + stdin: | + set -- x{,} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Expansion with curly braces: quoted empty string in the list survives" + stdin: | + set -- ""{,} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + set -- {"",a} + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + - name: "Expansion with curly braces: empty alternative next to an empty expansion" + stdin: | + z= + set -- {,}$z + echo "Count: $#" + set -- {a,}$z + echo "Count: $#" + for arg in "$@"; do echo "[$arg]"; done + + # Brace expansion is performed on command words (including redirection + # targets and `for` lists), but not in these scalar contexts. + + - name: "No brace expansion in assignments" + stdin: | + x=a{b,c} + echo "[$x]" + x[0]={a,b} + echo "[${x[0]}]" + x+={c,d} + echo "[$x]" + declare -A m + m[{k,l}]=v + echo "${!m[@]}" + IFS=: + y=a{b,c} + echo "[$y]" + + - name: "No brace expansion in here-strings" + stdin: | + cat <<< {a,b} + IFS=: + cat <<< {a,b} + + - name: "No brace expansion in case words or patterns" + stdin: | + case {a,b} in + "{a,b}") echo "word literal";; + *) echo "word expanded";; + esac + case "{a,b}" in + {a,b}) echo "pattern literal";; + *) echo "pattern expanded";; + esac + case a in + {a,b}) echo "pattern matched a";; + *) echo "pattern is literal";; + esac + + - name: "No brace expansion in [[ ]] operands" + stdin: | + [[ {a,b} == "{a,b}" ]] && echo "lhs literal" || echo "lhs expanded" + [[ "{a,b}" == {a,b} ]] && echo "rhs literal" || echo "rhs expanded" + [[ -n {a,} ]] && echo "unary literal" || echo "unary expanded" + [[ {a,b} =~ ^\{a,b\}$ ]] && echo "regex lhs literal" || echo "regex lhs expanded" + + - name: "Brace expansion still applies in command words and for lists" + stdin: | + for w in {a,b}; do echo "[$w]"; done + printf '[%s]\n' x{1,2} + arr=({a,b}c) + echo "${#arr[@]}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml new file mode 100644 index 0000000..5517ece --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml @@ -0,0 +1,410 @@ +name: "Command substitution" +cases: + - name: "Command substitution" + stdin: | + var="value" + + echo "1:" + echo $(echo hi) + echo "2:" + echo "$(echo hi)" + echo "3:" + echo "$(echo "hi")" + echo "4:" + echo "$(var="updated"; echo ${var})" + echo "var=${var}" + + - name: "Command substitution with embedded parens" + stdin: | + x=$(echo foo | (wc -l; echo hi)) + echo "\$x: $x" + + - name: "Command substitution with subshell" + stdin: | + x=$( (echo hi) ) + echo "\$x: $x" + + - name: "Command substitution exit code" + stdin: | + x=$(false) && echo "1. Made it past false" + y=$(true) && echo "2. Made it past true" + + - name: "Command substitution with exec" + stdin: | + x=$(exec echo hi) + echo "x: $x" + + - name: "Backtick command substitution" + stdin: | + echo `echo hi` + + - name: "Backtick command substitution with escaping" + stdin: | + echo `echo \`echo hi\`` + + - name: "Backtick command substitution with backslashes" + stdin: | + echo `echo \\` + + - name: "Backtick command substitution with trailing backslashes" + stdin: | + y=`printf %s \\` + printf 'two: <%s> len=%s\n' "$y" "${#y}" + y=`printf %s \\\\` + printf 'four: <%s> len=%s\n' "$y" "${#y}" + y=`printf %s \\\\\\` + printf 'six: <%s> len=%s\n' "$y" "${#y}" + + - name: "Backtick command substitution escape rules" + stdin: | + HOME=abc + printf '<%s>\n' `printf '%s\n' \\\\2` + echo `echo \$HOME` + echo `echo \\$HOME` + echo `echo \"q\"` + echo `echo 'a\"b'` + + - name: "Backtick command substitution escape rules in double quotes" + stdin: | + HOME=abc + echo "`echo \\\\a`" + echo "`echo \$HOME \\$HOME`" + y= + echo "${y:-`echo \"q\"`}" + + - name: "Backtick command substitution unescaping double quotes in double quotes" + known_failure: true # TODO(parser): in backquotes directly within double quotes (not in a `${...}` within them), a backslash before `"` is removed + stdin: | + echo "`echo \"q\"`" + echo "`echo 'a\"b'`" + + - name: "Backtick command substitution line continuations" + stdin: | + echo `echo e\ + cho` + echo `echo 'a\ + b'` + echo "`echo 'a\ + b'`" + + - name: "Backtick command substitution in double quotes" + stdin: | + echo "`echo First line`" + echo " `echo Second line` " + echo "Third`echo line`here" + + - name: "Ignore single quote in comment in command substitution" + stdin: | + var=$( + # I'm + echo "Batman" + ) + echo $var + + - name: "Ignore double quote in comment in command substitution" + stdin: | + var=$( + # This " is not being + echo "parsed" + ) + echo $var + + - name: "Ignore parentheses in comment in command substitution" + stdin: | + var=$( + # :( + echo "Sad" + ) + echo $var + + - name: "Ignore dollar in comment in command substitution" + stdin: | + var=$( + # $ + echo "Mr. Crabs ^" + ) + echo $var + + - name: "Positional parameter count not mistaken for comment" + stdin: | + echo $(echo $#) + + - name: "Comment after blanks in command substitution" + stdin: | + echo "0 blanks: $(# I'm a comment + echo hi)" + echo "1 blank: $( # I'm a comment + echo hi)" + echo "2 blanks: $( # I'm a comment + echo hi)" + echo "3 blanks: $( # I'm a comment + echo hi)" + + - name: "Comment after tabs in command substitution" + stdin: | + echo "1 tab: $( # I'm a comment + echo hi)" + echo "2 tabs: $( # I'm a comment + echo hi)" + echo "blank and tab: $( # I'm a comment + echo hi)" + + - name: "Hash after blanks that is not a comment in command substitution" + stdin: | + echo "1: $( echo a#b )" + echo "2: $( echo 'a # b' )" + echo "3: $( echo one # I'm a trailing comment + echo two)" + echo "4: $(cat <file.txt + x=$(file.txt + x=` file.txt + x=$(' "${empty[@]+ALT}" x; echo + printf '<%s>' "${empty[@]:+ALT}" x; echo + printf '<%s>' "${unset_arr[@]+ALT}" x; echo + set -- + printf '<%s>' "${@+ALT}" x; echo + printf '<%s>' "${@:+ALT}" x; echo + + # Unchanged: [*] still yields one empty word; inside a word nothing is added. + printf '<%s>' "${empty[*]+ALT}" x; echo + printf '<%s>' a"${empty[@]+ALT}"b; echo + + - name: "Parameter expression: index-listing combined with conditional operator" + # brush currently fails to parse the ${!arr[@]} index-listing form when it is + # combined with a conditional operator (+/-/:+/:-). bash accepts it; for an + # empty array the indices are unset, so these forms behave like an unset value. + # (Non-empty arrays intentionally omitted: there bash reinterprets `!` as + # indirection, which is a separate behavior.) + known_failure: true + stdin: | + empty=() + echo "[${!empty[@]+ALT}]" + echo "[${!empty[@]-DEF}]" + echo "[${!empty[*]+ALT}]" + echo "[${!empty[@]:+ALT}]" + echo "[${!empty[@]:-DEF}]" + + - name: "Parameter expression: error on condition (interactive)" + ignore_stderr: true + args: ["-i"] + stdin: | + value="value" + empty="" + declare declared + unset undeclared + + # ? + echo "\${value?error message} : ${value?error message}" + echo " -> result: $?" + echo "\${empty?error message} : ${empty?error message}" + echo " -> result: $?" + echo "\${declared?error message} : ${declared?error message}" + echo " -> result: $?" + echo "\${undeclared?error message} : ${undeclared?error message}" + echo " -> result: $?" + + echo "\${value?} : ${value?}" + echo " -> result: $?" + echo "\${empty?} : ${empty?}" + echo " -> result: $?" + echo "\${declared?} : ${declared?}" + echo " -> result: $?" + echo "\${undeclared?} : ${undeclared?}" + echo " -> result: $?" + + # :? + echo "\${value:?error message} : ${value:?error message}" + echo " -> result: $?" + echo "\${empty:?error message} : ${empty:?error message}" + echo " -> result: $?" + echo "\${declared:?error message} : ${declared:?error message}" + echo " -> result: $?" + echo "\${undeclared:?error message} : ${undeclared:?error message}" + echo " -> result: $?" + + echo "\${value:?} : ${value:?}" + echo " -> result: $?" + echo "\${empty:?} : ${empty:?}" + echo " -> result: $?" + echo "\${declared:?} : ${declared:?}" + echo " -> result: $?" + echo "\${undeclared:?} : ${undeclared:?}" + echo " -> result: $?" + + - name: "Parameter expression: error on condition (non-interactive)" + ignore_stderr: true + stdin: | + echo "${non_existent_var?error message}" + echo "This should never execute" + + - name: "Parameter expression: expanded array as alternate value" + stdin: | + declare -a var=("abc" "def" "ghi" "") + + for item in "${var[@]}"; do + echo "Item: '${item}'" + done + + echo "Expression 1: '${var+"${var[@]}"}'" + for item in "${var+"${var[@]}"}"; do + echo " -> '${item}'" + done + + echo "Expression 2: '${var+${var[@]}}'" + for item in "${var+${var[@]}}"; do + echo " -> '${item}'" + done + + echo "Expression 3: '${var+"${var[@]}"}'" + for item in ${var+"${var[@]}"}; do + echo " -> '${item}'" + done + + echo "Expression 4: '${var+${var[@]}}'" + for item in ${var+${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 5: '${var+"${var[*]}"}'" + for item in "${var+"${var[*]}"}"; do + echo " -> '${item}'" + done + + echo "Expression 6: '${var+${var[*]}}'" + for item in "${var+${var[*]}}"; do + echo " -> '${item}'" + done + + echo "Expression 7: '${var+"${var[*]}"}'" + for item in ${var+"${var[*]}"}; do + echo " -> '${item}'" + done + + echo "Expression 8: '${var+${var[*]}}'" + for item in ${var+${var[*]}}; do + echo " -> '${item}'" + done + + - name: "Parameter expression: expanded array as default value" + stdin: | + declare -a var=("abc" "def" "ghi" "") + + for item in "${var[@]}"; do + echo "Item: '${item}'" + done + + echo "Expression 1: ${nonexistent-${var[@]}}" + for item in ${nonexistent-${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 2: ${nonexistent-"${var[@]}"}" + for item in ${nonexistent-"${var[@]}"}; do + echo " -> '${item}'" + done + + echo "Expression 3: ${nonexistent2=${var[@]}}" + for item in ${nonexistent3=${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 4: ${nonexistent4="${var[@]}"}" + for item in ${nonexistent5-"${var[@]}"}; do + echo " -> '${item}'" + done + + - name: "Remove prefix/suffix" + stdin: | + var="prepre-abc-sufsuf" + + # Smallest suffix + shopt -u nocasematch + echo "\${var%}: ${var%}" + echo "\${var%pre}: ${var%pre}" + echo "\${var%suf}: ${var%suf}" + echo "\${var%SUF}: ${var%SUF}" + shopt -s nocasematch + echo "\${var%SUF}(nocasematch): ${var%SUF}" + + # Largest suffix + shopt -u nocasematch + echo "\${var%%}: ${var%%}" + echo "\${var%%pre}: ${var%%pre}" + echo "\${var%%suf}: ${var%%suf}" + echo "\${var%%SUF}: ${var%%SUF}" + shopt -s nocasematch + echo "\${var%%SUF}(nocasematch): ${var%%SUF}" + + # Smallest prefix + shopt -u nocasematch + echo "\${var#}: ${var#}" + echo "\${var#pre}: ${var#pre}" + echo "\${var#suf}: ${var#suf}" + echo "\${var#PRE}: ${var#PRE}" + shopt -s nocasematch + echo "\${var#PRE}(nocasematch): ${var#PRE}" + + # Largest prefix + shopt -u nocasematch + echo "\${var##}: ${var##}" + echo "\${var##pre}: ${var##pre}" + echo "\${var##suf}: ${var##suf}" + echo "\${var##PRE}: ${var##PRE}" + shopt -s nocasematch + echo "\${var##PRE}(nocasematch): ${var##PRE}" + + - name: "Indirect variable references" + stdin: | + var="Hello" + ref="var" + echo "${!ref}" + echo "${!ref//l/o}" + + - name: "Indirect variable references with special parameters" + stdin: | + set a b c + + ref="2" + echo "${!ref}" + + - name: "Indirect variable references with array" + stdin: | + arr=("element1" "element2" "element3") + + ref="arr[1]" + echo "${!ref}" + + ref="arr[10]" + echo "${!ref}" + + - name: "Variable prefix match" + stdin: | + var1="Hello" + var2="World" + + echo "${!var*}" + echo "${!var@}" + + echo "Dumping *" + for i in "${!var*}"; do + echo "i: $i" + done + + echo "Dumping @" + for i in "${!var@}"; do + echo "i: $i" + done + + - name: "Uppercase first character" + stdin: | + var="hello" + + shopt -u nocasematch + echo "\${var^}: ${var^}" + echo "\${var^h}: ${var^h}" + echo "\${var^H}: ${var^H}" + echo "\${var^l}: ${var^l}" + echo "\${var^h*}: ${var^h*}" + echo "\${var^he}: ${var^he}" + echo "\${var^?}: ${var^?}" + echo "\${var^*}: ${var^*}" + + shopt -s nocasematch + echo "\${var^H}(nocasematch): ${var^H}" + + arr=("hello" "world") + echo "\${arr^}: ${arr^}" + echo "\${arr[@]^}: ${arr[@]^}" + echo "\${arr[*]^}: ${arr[*]^}" + + - name: "Uppercase matching pattern" + stdin: | + var="hello" + + shopt -u nocasematch + echo "\${var^^}: ${var^^}" + echo "\${var^^l}: ${var^^l}" + echo "\${var^^L}: ${var^^L}" + echo "\${var^^m}: ${var^^m}" + + shopt -s nocasematch + echo "\${var^^L}(nocasematch): ${var^^L}" + + arr=("hello" "world") + echo "\${arr^^}: ${arr^^}" + echo "\${arr[@]^^}: ${arr[@]^^}" + echo "\${arr[*]^^}: ${arr[*]^^}" + + - name: "Lowercase first character" + stdin: | + var="HELLO" + + shopt -u nocasematch + echo "\${var,}: ${var,}" + echo "\${var,h}: ${var,h}" + echo "\${var,H}: ${var,H}" + echo "\${var,L}: ${var,L}" + echo "\${var,H*}: ${var,H*}" + echo "\${var,HE}: ${var,HE}" + echo "\${var,?}: ${var,?}" + echo "\${var,*}: ${var,*}" + + shopt -s nocasematch + echo "\${var,h}(nocasematch): ${var,h}" + + arr=("HELLO" "WORLD") + echo "\${arr,}: ${arr,}" + echo "\${arr[@],}: ${arr[@],}" + echo "\${arr[*],}: ${arr[*],}" + + - name: "Lowercase matching pattern" + stdin: | + var="HELLO" + + shopt -u nocasematch + echo "\${var,,}: ${var,,}" + echo "\${var,,M}: ${var,,M}" + echo "\${var,,L}: ${var,,L}" + echo "\${var,,l}: ${var,,l}" + + shopt -s nocasematch + echo "\${var,,l}(nocasematch): ${var,,l}" + + arr=("HELLO" "WORLD") + echo "\${arr,,}: ${arr,,}" + echo "\${arr[@],,}: ${arr[@],,}" + echo "\${arr[*],,}: ${arr[*],,}" + + - name: "Substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/world/WORLD}: ${var/world/WORLD}" + + arr=("world" "world") + echo "\${arr/world/WORLD}: ${arr/world/WORLD}" + echo "\${arr[@]/world/WORLD}: ${arr[@]/world/WORLD}" + echo "\${arr[*]/world/WORLD}: ${arr[*]/world/WORLD}" + + - name: "Substring replacement with slashes" + stdin: | + var="Hello, world!" + echo "\${var/world//world}: ${var/world//world}" + + - name: "Substring replacement with patterns" + stdin: | + var="a[bc]d" + pattern="[bc]" + echo "Replacement 1: ${var/${pattern}/}" + echo "Replacement 2: ${var/"${pattern}"/}" + + - name: "Prefix substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/#world/WORLD}: ${var/#world/WORLD}" + echo "\${var/#Hello/HELLO}: ${var/#Hello/HELLO}" + + arr=("world" "world") + echo "\${arr/#world/WORLD}: ${arr/#world/WORLD}" + echo "\${arr[@]/#world/WORLD}: ${arr[@]/#world/WORLD}" + echo "\${arr[*]/#world/WORLD}: ${arr[*]/#world/WORLD}" + + - name: "Suffix substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/%Hello/HELLO}: ${var/%Hello/HELLO}" + echo "\${var/%world!/WORLD!}: ${var/%world!/WORLD!}" + + arr=("world" "world") + echo "\${arr/%world/WORLD}: ${arr/%world/WORLD}" + echo "\${arr[@]/%world/WORLD}: ${arr[@]/%world/WORLD}" + echo "\${arr[*]/%world/WORLD}: ${arr[*]/%world/WORLD}" + + - name: "Global substring replacement" + stdin: | + var="Hello, world, world!" + echo "\${var//world/WORLD}: ${var//world/WORLD}" + + arr=("world world" "world world") + echo "\${arr//world/WORLD}: ${arr//world/WORLD}" + echo "\${arr[@]//world/WORLD}: ${arr[@]//world/WORLD}" + echo "\${arr[*]//world/WORLD}: ${arr[*]//world/WORLD}" + + - name: "Global substring removal" + stdin: | + var="That is not all" + + shopt -u nocasematch + echo "\${var//not }: ${var//not}" + + shopt -s nocasematch + echo "\${var//NOT }: ${var//NOT }" + + - name: "Substring from offset" + stdin: | + var="Hello, world!" + echo "\${var:0}: ${var:0}" + echo "\${var:7}: ${var:7}" + echo "\${var: 7 }: ${var: 7 }" + echo "\${var:50}: ${var:50}" + echo "\${var:-1}: ${var:-1}" + echo "\${var: -1}: ${var: -1}" + echo "\${var: 2 + 3 }: ${var: 2 + 3 }" + + - name: "Substring with length" + stdin: | + var="Hello, world!" + echo "\${var:0:1}: ${var:0:1}" + echo "\${var:0:0}: ${var:0:0}" + echo "\${var:0:50}: ${var:0:50}" + echo "\${var:0:-1}: ${var:0:-1}" + echo "\${var:0:-3}: ${var:0:-3}" + echo "\${var:7:3}: ${var:7:3}" + echo "\${var:50:2}: ${var:50:2}" + echo "\${var:-1:1}: ${var:-1:1}" + echo "\${var:-3:1}: ${var:-3:1}" + + - name: "Substring with negative length" + stdin: | + var="abcdefgh" + echo "\${var:2:-2}: ${var:2:-2}" + echo "\${var:0:-1}: ${var:0:-1}" + echo "\${var:0:-8}: [${var:0:-8}]" + echo "\${var: -4:-1}: ${var: -4:-1}" + echo "\${var: -4:-4}:[${var: -4:-4}]" + echo "\${var:2:-6}: [${var:2:-6}]" + echo "\${var:9:-1}: [${var:9:-1}]" + n=2 + echo "\${var:1:-n}: ${var:1:-n}" + echo "\${var:1:(-2)}: ${var:1:(-2)}" + elem=(abcdefgh) + echo "\${elem[0]:2:-2}: ${elem[0]:2:-2}" + + - name: "Substring with a negative length reaching past the offset" + ignore_stderr: true + stdin: | + var="abcdefgh" + ( echo "[${var:0:-9}]" ); echo "rc=$?" + ( echo "[${var:2:-7}]" ); echo "rc=$?" + ( echo "[${var:8:-1}]" ); echo "rc=$?" + empty= + ( echo "[${empty:0:-1}]" ); echo "rc=$?" + + - name: "Substring with negative length past the end of an array" + stdin: | + arr=(a b c) + echo "\${arr[@]:3:-1}: [${arr[@]:3:-1}]" + echo "\${arr[@]:9:-1}: [${arr[@]:9:-1}]" + echo "\${arr[*]:9:-1}: [${arr[*]:9:-1}]" + set -- p q r + echo "\${@:5:-1}: [${@:5:-1}]" + + - name: "Substring with negative length on arrays" + # bash rejects a negative length for every array form. The offset at which it stops + # doing so and yields an empty slice instead is not the same one it uses for strings, + # and differs between indexed arrays (past the highest subscript) and the forms + # addressed by position (past the element count). The associative cases only pin that + # the length is still rejected: bash documents substring expansion of an associative + # array as producing undefined results, so its selection is not something to match. + ignore_stderr: true + stdin: | + arr=(a b c) + ( echo "[${arr[@]:0:-1}]" ); echo "rc=$?" + ( echo "[${arr[@]:2:-1}]" ); echo "rc=$?" + ( echo "[${arr[@]:3:-1}]" ); echo "rc=$?" + ( echo "[${arr[*]:0:-1}]" ); echo "rc=$?" + ( echo "[${arr[@]: -1:-1}]" ); echo "rc=$?" + set -- p q r + ( echo "[${@:1:-1}]" ); echo "rc=$?" + ( echo "[${@:4:-1}]" ); echo "rc=$?" + ( echo "[${@:5:-1}]" ); echo "rc=$?" + ( echo "[${*:3:-1}]" ); echo "rc=$?" + declare -A assoc=([x]=1) + ( echo "[${assoc[@]:0:-1}]" ); echo "rc=$?" + ( echo "[${assoc[@]:1:-1}]" ); echo "rc=$?" + ( echo "[${assoc[@]:2:-1}]" ); echo "rc=$?" + + - name: "Substring with an out-of-range offset" + stdin: | + var="abcdefgh" + echo "\${var:99:-1}: [${var:99:-1}]" + echo "\${var: -99:-1}: [${var: -99:-1}]" + echo "\${var: -99:2}: [${var: -99:2}]" + echo "\${var:99:2}: [${var:99:2}]" + unset nosuch + echo "\${nosuch:0:-1}: [${nosuch:0:-1}]" + echo "\${nosuch: -1:-1}: [${nosuch: -1:-1}]" + arr=(a b c) + echo "\${arr[@]:99:-1}: [${arr[@]:99:-1}]" + echo "\${arr[@]: -99:-1}: [${arr[@]: -99:-1}]" + empty_arr=() + echo "\${empty_arr[@]:0:-1}: [${empty_arr[@]:0:-1}]" + declare -A empty_assoc=() + echo "\${empty_assoc[@]:0:-1}: [${empty_assoc[@]:0:-1}]" + + - name: "Substring operator on a scalar referenced as an array" + ignore_stderr: true + stdin: | + s=hello + echo "\${s[@]:0:2}: ${s[@]:0:2}" + echo "\${s[@]:1:2}: ${s[@]:1:2}" + echo "\${s[@]:1}: ${s[@]:1}" + echo "\${s[@]:0:-1}: ${s[@]:0:-1}" + echo "\${s[*]:1:-1}: ${s[*]:1:-1}" + echo "\${#s[@]}: ${#s[@]}" + ( echo "[${s[@]:2:-9}]" ); echo "rc=$?" + + - name: "Substring operator on a dynamic scalar referenced as an array" + # LINENO stands in for the dynamic scalars (RANDOM, EPOCHSECONDS, ...) because it's + # the deterministic one. The `:` padding keeps it at two digits so the slices below + # can tell string addressing apart from element addressing. + stdin: | + : + : + : + : + : + : + : + : + : + echo "\${LINENO[@]:1}: ${LINENO[@]:1}" + echo "\${LINENO[@]:0:1}: ${LINENO[@]:0:1}" + echo "\${#LINENO[@]}: ${#LINENO[@]}" + ( echo "[${LINENO[@]:0:-1}]" ); echo "rc=$?" + + - name: "Substring on string with multi-byte chars" + skip: true # TODO(expansion): succeeds on macOS, fails on Linux + stdin: | + var="7❌🖌️✅😂🔴⛔ABC" + echo "${var:2}" | hexdump -C + + - name: "Substring operator on arrays" + stdin: | + set abcde fghij klmno pqrst uvwxy z + echo "\${@:2:2}: ${@:2:2}" + echo "\${@:2}: ${@:2}" + + myarray=(abcde fghij klmno pqrst uvwxy z) + echo "\${myarray[@]:2:2}: ${myarray[@]:2:2}" + echo "\${myarray[@]:2}: ${myarray[@]:2}" + + - name: "Substring operator on arrays with negative index" + stdin: | + set abcde fghij klmno pqrst uvwxy z + echo "\${@: -2:2}: ${@: -2:2}" + echo "\${@: -2}: ${@: -2}" + + myarray=(abcde fghij klmno pqrst uvwxy z) + echo "\${myarray[@]: -2:2}: ${myarray[@]: -2:2}" + echo "\${myarray[@]: -2}: ${myarray[@]: -2}" + + - name: "Substring operator on single-element array" + stdin: | + myarray=("abcde fghij klmno pqrst uvwxy z") + echo "\${myarray[@]:0:1}: ${myarray[@]:0:1}" + echo "\${myarray[@]:1}: ${myarray[@]:1}" + + - name: "Substring operator past end of array" + stdin: | + set a b c + declare -a result1=("${@:3}") + declare -p result1 + + myarray=(a b c) + declare -a result2=("${myarray[@]:3}") + declare -p result2 + + - name: "Substring operator on arrays with out-of-range offsets and lengths" + stdin: | + arr=(a b c d e) + echo "\${arr[@]:1:99}: ${arr[@]:1:99}" + echo "\${arr[@]:0:0}: [${arr[@]:0:0}]" + echo "\${arr[@]: -2:99}: ${arr[@]: -2:99}" + echo "\${arr[@]: -9:2}: [${arr[@]: -9:2}]" + echo "\${arr[@]:4:1}: ${arr[@]:4:1}" + echo "\${arr[@]:5:1}: [${arr[@]:5:1}]" + echo "\${arr[*]:1:2}: ${arr[*]:1:2}" + echo "\${arr[*]: -2:99}: ${arr[*]: -2:99}" + set -- p q r + echo "\${@:1:99}: ${@:1:99}" + echo "\${@: -1:99}: ${@: -1:99}" + + - name: "Substring operator on sparse indexed arrays" + stdin: | + sparse=([1]=one [5]=five [9]=nine) + echo "\${sparse[@]:0}: ${sparse[@]:0}" + echo "\${sparse[@]:1}: ${sparse[@]:1}" + echo "\${sparse[@]:2}: ${sparse[@]:2}" + echo "\${sparse[@]:6}: ${sparse[@]:6}" + echo "\${sparse[@]:9}: ${sparse[@]:9}" + echo "\${sparse[@]:10}: [${sparse[@]:10}]" + echo "\${sparse[@]:1:1}: ${sparse[@]:1:1}" + echo "\${sparse[@]:1:2}: ${sparse[@]:1:2}" + echo "\${sparse[@]:5:0}: [${sparse[@]:5:0}]" + echo "\${sparse[@]:2:99}: ${sparse[@]:2:99}" + echo "\${sparse[@]: -1}: ${sparse[@]: -1}" + echo "\${sparse[@]: -5}: ${sparse[@]: -5}" + echo "\${sparse[@]: -10}: ${sparse[@]: -10}" + echo "\${sparse[@]: -11}: [${sparse[@]: -11}]" + echo "\${sparse[*]:2}: ${sparse[*]:2}" + echo "\${sparse[*]:1:2}: ${sparse[*]:1:2}" + echo "\${sparse[*]: -5}: ${sparse[*]: -5}" + echo "\${sparse[*]: -11}: [${sparse[*]: -11}]" + declare -a solo; solo[7]=x + echo "\${solo[@]:0}: ${solo[@]:0}" + echo "\${solo[@]:7}: ${solo[@]:7}" + echo "\${solo[@]:8}: [${solo[@]:8}]" + + - name: "Substring with negative length on a sparse indexed array" + ignore_stderr: true + stdin: | + sparse=([1]=one [5]=five [9]=nine) + ( echo "[${sparse[@]:0:-1}]" ); echo "rc=$?" + ( echo "[${sparse[@]:5:-1}]" ); echo "rc=$?" + ( echo "[${sparse[@]:9:-1}]" ); echo "rc=$?" + ( echo "[${sparse[@]:10:-1}]" ); echo "rc=$?" + ( echo "[${sparse[@]:11:-1}]" ); echo "rc=$?" + + - name: "Substring with length (with nested expressions)" + stdin: | + var="Hello, world!" + offset=7 + length=5 + echo "\${var:\$offset:\${length}}: ${var:$offset:${length}}" + + - name: "String length counts characters, not bytes" + # brush counts UTF-8 bytes. Under the suite's default LC_ALL=C bash agrees, so + # the oracle needs a UTF-8 locale for the difference to show at all. + known_failure: true + env: + LC_ALL: "C.UTF-8" + stdin: | + var="héllo" + echo "${#var}" + var="테스트" + echo "${#var}" + + - name: "Extglob-like text in parameter expansion default" + stdin: | + shopt -s extglob + echo ${u:-a@(} + + - name: "Doubled dollar sign before brace" + stdin: | + echo $${u | sed "s/$$/PID/g" + echo "rc=$?" + + - name: "Doubled dollar sign before parenthesis" + ignore_stderr: true + stdin: | + echo $$(echo hi) + + - name: "ANSI-C quoting in parameter expansion in double quotes" + known_failure: true # TODO(expansion): with extquote (on by default), $'...' inside "${...}" is decoded; brush keeps it as is + stdin: | + echo "[${x:-$'a\tb'}]" + + - name: "Escaped closing brace in parameter expansion in double quotes" + known_failure: true # TODO(expansion): bash removes the backslash from '\}' inside "${...}"; brush keeps it + stdin: | + echo "[${x:-\}}]" + + - name: "Tabs in parameter expansion default" + stdin: | + echo "[${x:-a b}]" "[${x:-a b}]" + + - name: "Here-doc operator as parameter expansion default" + stdin: | + echo ${u:-<<} ${u:-<<-} + echo "${u:-<<}" "${u:-<<-}" + + - name: "Bad substitution in braced parameter expansion" + known_failure: true # TODO(expansion): #540: a malformed '${...}' is silently treated as literal text + ignore_stderr: true # error wording differs + stdin: | + echo before + echo ${x!} + echo "rc=$?" + x="a ${y!} b" + echo "rc=$? x=$x" + + - name: "Braced last background process ID" + known_failure: true # TODO(expansion): '${!}' (not followed by a name) is taken as indirection, so it doesn't parse + stdin: | + echo "[${!:-none}]" + true & + wait + [ "${!}" = "$!" ] && echo same + echo "[${!:+set}]" + b=x x=y + echo "${!b}" + + - name: "Case-toggling parameter expansion" + known_failure: true # TODO(expansion): ${x~} and ${x~~} (undocumented in bash) are not implemented + stdin: | + x=aBc + echo "${x~}" "${x~~}" + + - name: "An escaped dollar sign in a pattern substitution's replacement is literal" + stdin: | + x=abc y=Y + echo "1 ${x/b/\$0}" + echo "2 ${x/b/\$1}" + echo "3 ${x/b/\$y}" + echo 4 ${x/b/\$0} + echo "5 ${x//[ac]/\$1}" "${x/#a/\$1}" "${x/%c/\$1}" + echo "6 ${x/b/\$\$}" "${x/b/\${y}}" + set -- P + echo "7 ${x/b/\\\$1}" ${x/b/\\\$1} + + - name: "A dollar sign the replacement's expansion yields is literal" + stdin: | + x=abc + r='$1' + echo "1 ${x/b/$r}" ${x/b/$r} + r='\$1' + echo "2 ${x/b/$r}" ${x/b/$r} + echo "3 ${x/b/$(echo '$0')}" + echo "4 ${x/b/'$1'}" ${x/b/"\$1"} + shopt -s extglob + echo "5 ${x/@(b)/\$1}" "${x//@(b|c)/[\$1]}" + arr=(ab cb) + echo "6 ${arr[@]/b/\$1}" + set -- ab cb + echo "7 ${@/b/\$1}" + + - name: "Expansions in a pattern substitution's replacement are performed" + stdin: | + x=abc y=Y + set -- P + echo "1 ${x/b/$1}" ${x/b/$1} "${x/b/$y}" "${x/b/${y}}" + echo "2 ${x/b/$(echo C)}" "${x/b/`echo B`}" "${x/b/$((1+2))}" + echo "3 ${x/b/\\$1}" ${x/b/\\$1} + echo "4 ${x//[ac]/$1}" "${x/#a/$1}" "${x/%c/$1}" + echo "5 ${x/b/$}" "${x/b/\$}" "${x/b/\q}" "${x/b/\\q}" + + - name: "An escaped or quoted ampersand in a pattern substitution's replacement is literal" + stdin: | + x=abc + echo "1 ${x/b/<\&>}" ${x/b/<\&>} + echo "2 ${x/b/'&'}" ${x/b/"&"} "${x/b/"&"}" + echo "3 ${x/b/\\\&}" ${x/b/\\\&} "${x/b/$'\\&'}" + shopt -u patsub_replacement + echo "4 ${x/b/<&>}" ${x/b/<&>} "${x/b/<\&>}" + r='\\' + echo "5 ${x/b/$r}" + + - name: "An ampersand in a pattern substitution's replacement stands for the match" + known_failure: true # TODO(expansion): patsub_replacement is not implemented + stdin: | + x=abc + echo "1 ${x/b/<&>}" ${x/b/<&>} + echo "2 ${x/b/&&}" "${x//?/<&>}" + echo "3 ${x/b/<\\&>}" ${x/b/<\\&>} + r='&' + echo "4 ${x/b/$r}" ${x/b/$r} "${x/b/$(echo '&')}" + echo "5 ${x/b/x\$1&}" + + - name: "A backslash an unquoted replacement expansion yields escapes an ampersand or backslash" + known_failure: true # TODO(expansion): patsub_replacement is not implemented + stdin: | + x=abc + r='\&' + echo "1 ${x/b/$r}" ${x/b/$r} + r='\\' + echo "2 ${x/b/$r}" ${x/b/$r} diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml new file mode 100644 index 0000000..cb584b2 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml @@ -0,0 +1,84 @@ +name: "Quotes" +cases: + - name: "Single quotes" + stdin: | + var=xyz + echo 'Quoted string' + echo 'abc ${var} def' + + - name: "Double quotes" + stdin: | + echo "\"" + + - name: "Double quotes: escaping" + stdin: | + echo "1. \'" | hexdump -C + echo "2. \x" | hexdump -C + echo "\n" + + - name: "ANSI-C quotes" + stdin: | + single_quoted='\n' + echo "Single quoted len: ${#single_quoted}" + echo -n '\n' | hexdump -C + + ansi_c_quoted=$'\n' + echo "ANSI-C quoted len: ${#ansi_c_quoted}" + echo -n $'\n' | hexdump -C + + - name: "ANSI-C quotes with escape sequences" + stdin: | + echo -n "0. "$'\x' | hexdump -C + echo -n "1. "$'\x65' | hexdump -C + echo -n "2. "$'\xgg' | hexdump -C + echo -n "3. "$'\x{65}' | hexdump -C + echo -n "4. "$'\x{65' | hexdump -C + echo -n "5. "$'\x{65R}' | hexdump -C + echo -n "6. "$'\x{}R' | hexdump -C + echo -n "7. "$'\x{ }' | hexdump -C + echo -n "8. "$'\x{965}' | hexdump -C + echo -n "9. "$'\'\"' | hexdump -C + echo -n "10. "$'🐢' | hexdump -C + echo -n "11. "$'\101' | hexdump -C + echo -n "12. "$'\10' | hexdump -C + echo -n "13. "$'\1' | hexdump -C + echo -n "14. "$'\0' | hexdump -C + echo -n "15. "$'\001' | hexdump -C + echo -n "16. "$'abc \0 def' | hexdump -C + echo -n "17. "$'\ca' | hexdump -C + echo -n "18. "$'\cz' | hexdump -C + echo -n "19. "$'\cA' | hexdump -C + echo -n "20. "$'\cZ' | hexdump -C + echo -n "21. "$'\c ' | hexdump -C + echo -n "22. "$'\c!' | hexdump -C + echo -n "23. "$'\c/' | hexdump -C + echo -n "24. "$'\c0' | hexdump -C + echo -n "25. "$'\c9' | hexdump -C + echo -n "26. "$'\c:' | hexdump -C + echo -n "27. "$'\c@' | hexdump -C + echo -n "28. "$'\c[' | hexdump -C + echo -n "29. "$'\c]' | hexdump -C + echo -n "30. "$'\c`' | hexdump -C + echo -n "31. "$'\c{' | hexdump -C + echo -n "32. "$'\c?' | hexdump -C + echo -n "33. "$'\c*' | hexdump -C + echo -n "34. "$'\c^' | hexdump -C + echo -n "35. "$'\c_' | hexdump -C + echo -n "36. "$'\c' | hexdump -C + echo -n "37. "$'\c\\' | hexdump -C + echo -n "38. "$'\c\n' | hexdump -C + echo -n "39. "$'\\' | hexdump -C + + - name: "ANSI-C quote syntax inside double quotes (literal, not processed)" + stdin: | + # When $'...' appears inside double quotes, it should be treated as literal text + echo "$'\t'" + echo "$'\'abcd\''" + x="$'\n'" + echo "len: ${#x}" + echo "$x" + + - name: "gettext style quotes" + stdin: | + quoted=$"Hello, world" + echo "Content: [${quoted}]" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml new file mode 100644 index 0000000..c64cd2f --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml @@ -0,0 +1,188 @@ +name: "Tilde expansion" +cases: + - name: "Tilde expansion: basic" + home_dir: . + stdin: | + test_root=$(realpath $(pwd)) + + mkdir subdir + cd subdir + + D=~ + D=$(realpath $D) + echo "~: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: explicit user" + home_dir: . + stdin: | + test_root=$(pwd) + + D=~root + echo "~root: $D" + + D=~nonexistentuserwhodoesnotexist + echo "~: $D" + + - name: "Tilde expansion: working dirs" + home_dir: . + stdin: | + test_root=$(pwd) + + mkdir subdir + cd subdir + + D=~+ + echo "~+: ${D//${test_root}/TEST_ROOT}" + + D=~- + echo "~-: ${D//${test_root}/TEST_ROOT}" + + unset OLDPWD + D=~- + echo "~- (without OLDPWD): ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: dir stack" + stdin: | + test_root=$(pwd) + + for name in a b c d e f g; do + mkdir $name + pushd $name >/dev/null 2>&1 + done + + D=~2 + echo "~2: ${D//${test_root}/TEST_ROOT}" + + D=~9 + echo "~9: ${D//${test_root}/TEST_ROOT}" + + D=~+2 + echo "~+2: ${D//${test_root}/TEST_ROOT}" + + D=~+9 + echo "~+9: ${D//${test_root}/TEST_ROOT}" + + D=~-2 + echo "~-2: ${D//${test_root}/TEST_ROOT}" + + D=~-9 + echo "~-9: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: after colons" + home_dir: . + stdin: | + test_root=~ + + D=something:~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something":"~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something:"~" + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something:~:another:~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: unexpanded tildes" + home_dir: . + stdin: | + test_root=$(pwd) + + D="~" + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=x~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: colon context sensitivity" + env: + HOME: /testhome + stdin: | + # Tilde at word start always expands (in both contexts) + echo "echo tilde: $(echo ~)" + var=~ + echo "assignment tilde: ${var}" + + # Tilde after colon: should NOT expand in non-assignment context + echo "echo with colon: $(echo ~:~/bin)" + echo ~:~/bin + + # But SHOULD expand in assignment context + var=~:~/bin + echo "assignment with colon: ${var}" + + # Complex PATH-like example + PATH=/usr/bin:~:/bin:~/local + echo "PATH: ${PATH}" + + - name: "Tilde expansion: parameter defaults" + env: + HOME: /testhome + stdin: | + echo "Use default value with tilde" + unset myvar + echo "Default: ${myvar:-~}" + echo "myvar after :-: ${myvar}" + + echo "Assign default value with tilde" + unset myvar + echo "Assign default: ${myvar:=~}" + echo "myvar after :=: ${myvar}" + + echo "Use default with colon-tilde" + unset myvar2 + echo "Default colon-tilde: ${myvar2:-~:~/bin}" + + echo "Assign default with colon-tilde" + unset myvar3 + echo "Assign colon-tilde: ${myvar3:=~:~/bin}" + echo "myvar3 after := ${myvar3}" + + - name: "Tilde expansion: OLDPWD declared without a value" + stdin: | + printf "<%s>\n" ~- ~-/x + + unset OLDPWD + declare OLDPWD + printf "<%s>\n" ~- + + unset OLDPWD + export OLDPWD + printf "<%s>\n" ~- + + OLDPWD= + printf "<%s>\n" ~- + + - name: "Tilde expansion: out-of-range dir stack index" + stdin: | + # A run of digits too large to fit the dir-stack index type isn't a valid + # index, so the tilde word falls back to (failed) username lookup and is + # left unexpanded, same as an ordinary nonexistent user. + D=~99999999999999999999 + echo "D: $D" + + D=~-99999999999999999999 + echo "D: $D" + + D=~+99999999999999999999 + echo "D: $D" + + - name: "Tilde expansion in list" + env: + HOME: . + test_files: + - path: "file.txt" + - path: "file.txt.bak" + stdin: | + echo ~/{file.txt,file.txt.bak} + + - name: "Tilde expansion: after the = of an assignment-shaped argument" + known_failure: true # TODO(expansion): ~ after the = of an assignment-shaped argument to an ordinary command isn't expanded + home_dir: . + stdin: | + test_root=~ + echo y=~/z | sed "s|${test_root}|ROOT|" + builtin export by=~/z; echo "${by//${test_root}/ROOT}" + command export cz=~/z; echo "${cz//${test_root}/ROOT}" diff --git a/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml new file mode 100644 index 0000000..1953fdf --- /dev/null +++ b/userland/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml @@ -0,0 +1,77 @@ +name: "Variable expansion" +cases: + - name: "Undefined variables" + stdin: | + echo "Undefined: ${undefined}" + echo "Undefined: $undefined" + + - name: "Defined variables" + stdin: | + var=xyz + echo "Defined: ${var}" + echo "Defined: var" + echo "Defined: prefix${var}suffix" + echo "Defined: prefix$var" + + - name: "Undefined positional params" + stdin: | + echo "Param: $9" + echo "Param: ${9}" + + - name: "High-numbered positional params" + stdin: | + echo "Param: $99" + echo "Param: ${99}" + + - name: "Nameref indirect expansion returns target name" + known_failure: true + stdin: | + target="the_value" + declare -n ref=target + echo "indirect: ${!ref}" + echo "direct: $ref" + + - name: "Nameref with string operations" + known_failure: true + stdin: | + target="Hello World" + declare -n ref=target + echo "upper: ${ref^^}" + echo "lower: ${ref,,}" + echo "length: ${#ref}" + echo "substring: ${ref:0:5}" + echo "replace: ${ref/World/Bash}" + + - name: "Nameref with default value expansion" + known_failure: true + stdin: | + declare -n ref=unset_var + echo "default: ${ref:-default_value}" + echo "assign default: ${ref:=assigned}" + echo "unset_var: $unset_var" + + - name: "Nameref with error expansion" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=unset_var + echo "${ref:?should error}" 2>/dev/null || echo "caught error" + + - name: "Nameref with alternative expansion" + known_failure: true + stdin: | + target="exists" + declare -n ref=target + echo "${ref:+alternative}" + unset target + echo "'${ref:+alternative}'" + + - name: "Nameref with pattern removal" + known_failure: true + stdin: | + target="/usr/local/bin/bash" + declare -n ref=target + echo "## */: ${ref##*/}" + echo "%% /*: ${ref%%/*}" + echo "# */: ${ref#*/}" + echo "% /*: ${ref%/*}" diff --git a/userland/brush/source/brush-shell/tests/compat_tests.rs b/userland/brush/source/brush-shell/tests/compat_tests.rs new file mode 100644 index 0000000..58380e8 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/compat_tests.rs @@ -0,0 +1,152 @@ +//! Compatibility test harness for brush shell. +//! +//! This test harness runs YAML-based test cases comparing brush output against +//! bash (the oracle shell) to validate compatibility. + +#![cfg(any(unix, windows))] + +use anyhow::Result; +use brush_test_harness::{ + OracleConfig, RunnerConfig, ShellConfig, TestMode, TestOptions, TestRunner, WhichShell, +}; +use clap::Parser; +use std::path::{Path, PathBuf}; + +const BASH_CONFIG_NAME: &str = "bash"; +const SH_CONFIG_NAME: &str = "sh"; + +fn get_bash_version_str(bash_path: &Path) -> Result { + brush_test_harness::util::get_bash_version_str(bash_path) +} + +fn create_bash_oracle(options: &TestOptions) -> Result { + let bash_version_str = get_bash_version_str(&options.bash_path)?; + if options.verbose { + eprintln!("Detected bash version: {bash_version_str}"); + } + + Ok(OracleConfig { + name: String::from(BASH_CONFIG_NAME), + shell: ShellConfig { + which: WhichShell::NamedShell(options.bash_path.clone()), + default_args: vec![String::from("--norc"), String::from("--noprofile")], + default_path_var: options.test_path_var.clone(), + launcher: None, + }, + version_str: Some(bash_version_str), + }) +} + +fn create_sh_oracle(options: &TestOptions) -> OracleConfig { + OracleConfig { + name: String::from(SH_CONFIG_NAME), + shell: ShellConfig { + which: WhichShell::NamedShell(PathBuf::from("sh")), + default_args: vec![], + default_path_var: options.test_path_var.clone(), + launcher: None, + }, + version_str: None, + } +} + +fn create_test_shell_config(options: &TestOptions, oracle_name: &str) -> Result { + let mut config = options.create_test_shell_config()?; + + // Add --sh flag when testing against sh oracle. + if oracle_name == SH_CONFIG_NAME { + config.default_args.insert(0, "--sh".into()); + } + + Ok(config) +} + +async fn run_compat_tests(mut options: TestOptions) -> Result { + // Resolve path to the shell-under-test. + if options.brush_path.is_empty() { + options.brush_path = assert_cmd::cargo::cargo_bin!("brush") + .to_string_lossy() + .to_string(); + } + if !Path::new(&options.brush_path).exists() { + return Err(anyhow::anyhow!( + "brush binary not found: {}", + options.brush_path + )); + } + + // Resolve bash path to absolute path to avoid issues when env is cleared. + if options.bash_path.is_relative() || options.bash_path.as_path() == Path::new("bash") { + if let Ok(resolved) = which::which(&options.bash_path) { + options.bash_path = resolved; + } + } + + // Resolve test cases directory (now under compat/). + let test_cases_dir = options.test_cases_path.as_deref().map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases/compat"), + |p| p.to_owned(), + ); + + let mut all_passed = true; + + // Run tests for each enabled config + if options.should_enable_config(BASH_CONFIG_NAME, &[BASH_CONFIG_NAME]) { + let oracle = create_bash_oracle(&options)?; + let test_shell = create_test_shell_config(&options, &oracle.name)?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir.clone()) + .with_oracle(oracle) + .with_mode(TestMode::Oracle) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options.clone()); + if !runner.run().await? { + all_passed = false; + } + } + + if options.should_enable_config(SH_CONFIG_NAME, &[BASH_CONFIG_NAME]) { + let oracle = create_sh_oracle(&options); + let test_shell = create_test_shell_config(&options, &oracle.name)?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir.clone()) + .with_oracle(oracle) + .with_mode(TestMode::Oracle) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options.clone()); + if !runner.run().await? { + all_passed = false; + } + } + + Ok(all_passed) +} + +fn main() -> Result<()> { + let unparsed_args: Vec<_> = std::env::args().collect(); + let options = TestOptions::parse_from(unparsed_args); + + let success = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(32) + .build()? + .block_on(run_compat_tests(options))?; + + if !success { + std::process::exit(1); + } + + Ok(()) +} diff --git a/userland/brush/source/brush-shell/tests/completion_tests.rs b/userland/brush/source/brush-shell/tests/completion_tests.rs new file mode 100644 index 0000000..c0be45d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/completion_tests.rs @@ -0,0 +1,1221 @@ +//! Completion integration tests for brush shell. + +// For now, only compile this for Linux. +#![cfg(target_os = "linux")] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Result; +use assert_fs::prelude::*; +use brush_builtins::ShellBuilderExt; +use std::path::PathBuf; + +/// Returns each candidate in `completions`. +fn candidate_texts(completions: &brush_core::completion::Completions) -> Vec { + completions.candidates.clone() +} + +/// Returns the range of the line that the only candidate in `completions` replaces. +fn replaced_range( + completions: &brush_core::completion::Completions, +) -> Result> { + match completions.candidates.as_slice() { + [_] => { + let start = completions.insertion_index; + Ok(start..start + completions.delete_count) + } + candidates => Err(anyhow::anyhow!( + "expected one candidate, got {candidates:?}" + )), + } +} + +/// A shell to complete in, with a temporary working directory. +struct TestShell { + shell: brush_core::Shell, + temp_dir: assert_fs::TempDir, +} + +const DEFAULT_BASH_COMPLETION_SCRIPT: &str = "/usr/share/bash-completion/bash_completion"; + +impl TestShell { + /// Returns a shell with native completion only. + async fn new() -> Result { + let mut shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + // Don't leave external commands running when a test drops a completion. + .kill_external_commands_on_drop(true) + .build() + .await?; + + let temp_dir = assert_fs::TempDir::new()?; + shell.set_working_dir(temp_dir.path())?; + + Ok(Self { shell, temp_dir }) + } + + /// Returns a shell with bash-completion loaded. + async fn with_bash_completion() -> Result { + let mut test_shell = Self::new().await?; + + let exec_params = test_shell.shell.default_exec_params(); + let source_result = test_shell + .shell + .source_script( + Self::find_bash_completion_script()?.as_path(), + std::iter::empty::(), + &exec_params, + ) + .await?; + if !source_result.is_success() { + return Err(anyhow::anyhow!("failed to source bash completion script")); + } + + Ok(test_shell) + } + + fn find_bash_completion_script() -> Result { + // See if an environmental override was provided. + let script_path = std::env::var("BASH_COMPLETION_PATH").map_or_else( + |_| PathBuf::from(DEFAULT_BASH_COMPLETION_SCRIPT), + PathBuf::from, + ); + + if script_path.exists() { + Ok(script_path) + } else { + Err(anyhow::anyhow!( + "bash completion script not found: {}", + script_path.display() + )) + } + } + + /// Completes `line` at `pos`. + async fn complete( + &mut self, + line: &str, + pos: usize, + ) -> Result { + Ok(self.shell.complete(line, pos).await?) + } + + /// Completes `input` at its `|`, which marks the cursor (or at its end, if it has none). + async fn complete_at_marker( + &mut self, + input: &str, + ) -> Result { + let cursor = input.find('|').unwrap_or(input.len()); + let line = input.replacen('|', "", 1); + self.complete(&line, cursor).await + } + + /// Completes `line` at its end. + async fn complete_end_of_line_full( + &mut self, + line: &str, + ) -> Result { + self.complete(line, line.len()).await + } + + /// Completes `line` at its end, returning the text of each candidate. + async fn complete_end_of_line(&mut self, line: &str) -> Result> { + let completions = self.complete_end_of_line_full(line).await?; + Ok(candidate_texts(&completions)) + } + + async fn run(&mut self, script: &str) -> Result<()> { + let exec_params = self.shell.default_exec_params(); + // Setup scripts may end in a failure on purpose (e.g. to set `$?`); their effects + // are what the tests check. + let _ = self + .shell + .run_string( + script.to_owned(), + &brush_core::SourceInfo::default(), + &exec_params, + ) + .await?; + Ok(()) + } + + fn get_var(&self, name: &str) -> Option { + self.shell + .env() + .get(name) + .map(|(_, v)| v.value().to_cow_str(&self.shell).into_owned()) + } + + fn set_var(&mut self, name: &str, value: &str) -> Result<()> { + self.shell + .env_mut() + .set_global(name, brush_core::ShellVariable::new(value))?; + Ok(()) + } +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_file_path() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + test_shell.temp_dir.child(".dot_item1").touch()?; + test_shell.temp_dir.child("..dot_item2").touch()?; + + // Complete; expect to see the two files. + let mut results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, ["item1", "item2"]); + + results = test_shell.complete_end_of_line("ls .").await?; + // Some versions of bash-completion filter out "." and ".." via + // `-X '?(*/)@(.|..)'`; others don't. Accept either outcome. + assert!( + results == ["..dot_item2", ".dot_item1"] + || results == [".", "..", "..dot_item2", ".dot_item1"], + "unexpected completions for 'ls .': {results:?}" + ); + + results = test_shell.complete_end_of_line("ls ..").await?; + assert_eq!(results, ["..", "..dot_item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_file_path_ignoring_case() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + test_shell + .shell + .options_mut() + .case_insensitive_pathname_expansion = true; + + // Create file and dir. + test_shell.temp_dir.child("ITEM1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, ["ITEM1", "item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_dir_path() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see just the dir. + let results = test_shell.complete_end_of_line("cd item").await?; + + assert_eq!(results, ["item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_under_empty_dir() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("empty").create_dir_all()?; + + // Complete; expect to see nothing. + let results = test_shell.complete_end_of_line("ls empty/").await?; + + assert_eq!(results, Vec::::new()); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_nonexistent_relative_path() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Complete; expect to see nothing. + let results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, Vec::::new()); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_absolute_paths() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see just the dir. + let input = std::format!("ls {}", test_shell.temp_dir.path().join("item").display()); + let results = test_shell.complete_end_of_line(input.as_str()).await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_with_var() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls $PWD/item").await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_with_tilde() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Set HOME to the temp dir so we can use ~ to reference it. + test_shell.set_var( + "HOME", + test_shell + .temp_dir + .path() + .to_string_lossy() + .to_string() + .as_str(), + )?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls ~/item").await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_variable_names() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Set a few vars. + test_shell.set_var("TESTVAR1", "")?; + test_shell.set_var("TESTVAR2", "")?; + + // Complete. + let results = test_shell.complete_end_of_line("echo $TESTVAR").await?; + assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_variable_names_with_braces() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Set a few vars. + test_shell.set_var("TESTVAR1", "")?; + test_shell.set_var("TESTVAR2", "")?; + + // Complete. + let results = test_shell.complete_end_of_line("echo ${TESTVAR").await?; + assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_help_topic() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Complete. + let results = test_shell.complete_end_of_line("help expor").await?; + assert_eq!(results, ["export"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_command_option() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Complete. + let results = test_shell.complete_end_of_line("ls --hel").await?; + assert_eq!(results, ["--help"]); + + Ok(()) +} + +/// Tests completion with some well-known programs that have been good manual test cases +/// for us in the past. +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_args_to_well_known_programs() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete. + let results = test_shell.complete_end_of_line("tar tvf ./item").await?; + + assert_eq!(results, ["./item2"]); + + Ok(()) +} + +/// Tests some 'find' completion. +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_find_command() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + // Complete. + let results = test_shell.complete_end_of_line("find . -na").await?; + + assert_eq!(results, ["-name"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_quoted_filenames() -> Result<()> { + let mut test_shell = TestShell::with_bash_completion().await?; + + test_shell.temp_dir.child("item1 item2").touch()?; + test_shell.temp_dir.child("item1'item2").touch()?; + + let mut results = test_shell.complete_end_of_line("ls item1\\ ").await?; + assert_eq!(results, ["item1 item2"]); + + results = test_shell.complete_end_of_line("ls item1'").await?; + assert_eq!(results, ["item1 item2", "item1'item2"]); + + results = test_shell.complete_end_of_line("ls item1").await?; + assert_eq!(results, ["item1 item2", "item1'item2"]); + + results = test_shell.complete_end_of_line("ls 'item1 ").await?; + assert_eq!(results, ["item1 item2"]); + + results = test_shell.complete_end_of_line("ls \"item1 ").await?; + assert_eq!(results, ["item1 item2"]); + + Ok(()) +} + +/// Tests that interactive completion sets `COMP_KEY` and `COMP_TYPE` to 9 (TAB). +#[tokio::test(flavor = "multi_thread")] +async fn interactive_completion_sets_comp_key_and_comp_type() -> Result<()> { + let mut shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + // Don't leave external commands running when a test drops a completion. + .kill_external_commands_on_drop(true) + .build() + .await?; + + // Register a completion function that captures COMP_KEY and COMP_TYPE. + let exec_params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::default(); + let result = shell + .run_string( + r" +_test_comp() { + CAPTURED_COMP_KEY=$COMP_KEY + CAPTURED_COMP_TYPE=$COMP_TYPE + COMPREPLY=(done) +} +complete -F _test_comp mycmd +" + .to_string(), + &source_info, + &exec_params, + ) + .await?; + anyhow::ensure!(result.is_success()); + + // Trigger interactive completion. + let _completions = shell.complete("mycmd ", 6).await?; + + // Check the captured values. + let comp_key = shell + .env() + .get("CAPTURED_COMP_KEY") + .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); + let comp_type = shell + .env() + .get("CAPTURED_COMP_TYPE") + .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); + + assert_eq!(comp_key.as_deref(), Some("9"), "COMP_KEY should be 9 (TAB)"); + assert_eq!( + comp_type.as_deref(), + Some("9"), + "COMP_TYPE should be 9 (TAB)" + ); + + Ok(()) +} + +/// Tests completing right after a word-break char (e.g. `--opt=`): like readline, the +/// word being completed is the empty one after the `=`, even though `COMP_WORDS` +/// still sees `=` as its own word. +#[tokio::test(flavor = "multi_thread")] +async fn complete_after_word_break_char() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run( + r#" +_test_comp() { + CAPTURED="[$2][$3][${COMP_WORDS[COMP_CWORD]}]" + COMPREPLY=(root) +} +complete -F _test_comp mycmd +complete -W "root" mycmd2 +"#, + ) + .await?; + + let line = "mycmd --owner="; + let completions = test_shell.complete(line, line.len()).await?; + assert_eq!(replaced_range(&completions)?, line.len()..line.len()); + assert_eq!(candidate_texts(&completions), ["root"]); + + assert_eq!( + test_shell.get_var("CAPTURED").as_deref(), + Some("[][--owner][=]") + ); + + let line = "mycmd2 a:"; + let completions = test_shell.complete(line, line.len()).await?; + assert_eq!(replaced_range(&completions)?, line.len()..line.len()); + assert_eq!(candidate_texts(&completions), ["root"]); + + // Cursor inside a run of word-break chars: still the empty word at the cursor. + let line = "mycmd2 a:=val"; + let cursor = "mycmd2 a:".len(); + let completions = test_shell.complete(line, cursor).await?; + assert_eq!(replaced_range(&completions)?, cursor..cursor); + assert_eq!(candidate_texts(&completions), ["root"]); + + let line = "mycmd --owner:=val"; + let cursor = "mycmd --owner:".len(); + test_shell.complete(line, cursor).await?; + assert_eq!( + test_shell.get_var("CAPTURED").as_deref(), + Some("[][--owner][:=]") + ); + + Ok(()) +} + +/// Checks what a completion function sees for the word being completed (`$2`), the one +/// before it (`$3`), and `COMP_WORDS`/`COMP_CWORD`, for each input with the cursor at +/// its `|`. +async fn check_completion_function_args(cases: &[(&str, &str)]) -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run( + r#" +_test_comp() { + local IFS=, + CAPTURED="\$2=<$2> \$3=<$3> CWORD=$COMP_CWORD WORDS=<${COMP_WORDS[*]}>" +} +complete -F _test_comp mycmd +"#, + ) + .await?; + + for (input, expected) in cases { + test_shell.set_var("CAPTURED", "")?; + test_shell.complete_at_marker(input).await?; + assert_eq!( + test_shell.get_var("CAPTURED").as_deref(), + Some(*expected), + "{input}" + ); + } + + Ok(()) +} + +// Expected values in the tests below were captured from bash 5.3 by pressing Tab at the +// cursor. + +#[tokio::test(flavor = "multi_thread")] +#[allow(clippy::too_many_lines)] +async fn completion_function_args_match_bash() -> Result<()> { + check_completion_function_args(&[ + ("mycmd aa|", "$2= $3= CWORD=1 WORDS="), + ("mycmd aa |", "$2=<> $3= CWORD=2 WORDS="), + ("mycmd a|b", "$2= $3= CWORD=1 WORDS="), + ("mycmd |aa", "$2=<> $3= CWORD=1 WORDS="), + ( + "mycmd aa| bb", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd aa | bb", + "$2=<> $3= CWORD=2 WORDS=", + ), + ("mycmd aa |bb", "$2=<> $3= CWORD=2 WORDS="), + ( + "mycmd --owner=|", + "$2=<> $3=<--owner> CWORD=2 WORDS=", + ), + ( + "mycmd --owner=|val", + "$2=<> $3=<=> CWORD=3 WORDS=", + ), + ( + "mycmd --owner|=val", + "$2=<--owner> $3=<--owner> CWORD=2 WORDS=", + ), + ( + "mycmd a:|=val", + "$2=<> $3= CWORD=2 WORDS=", + ), + ("mycmd a:|", "$2=<> $3= CWORD=2 WORDS="), + ( + "mycmd \"a b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd 'a b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd a'b c|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd 'a b'|", + "$2=<'a b'> $3= CWORD=1 WORDS=", + ), + ( + "mycmd 'a b'c|", + "$2=<'a b'c> $3= CWORD=1 WORDS=", + ), + ( + r"mycmd a\ b|", + r"$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd \"a:b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd x=\"a b|", + "$2= $3=<=> CWORD=3 WORDS=", + ), + ( + "mycmd --opt='a b|", + "$2= $3=<=> CWORD=3 WORDS=", + ), + ( + r"mycmd a\:b|", + r"$2= $3= CWORD=1 WORDS=", + ), + ( + r#"mycmd "a\"b c|"#, + r#"$2= $3= CWORD=1 WORDS="#, + ), + ( + "mycmd 'a:b' c:|", + "$2=<> $3= CWORD=3 WORDS=", + ), + ( + "mycmd a\"b\"c:d|", + "$2= $3=<:> CWORD=3 WORDS=", + ), + ("mycmd a@b|", "$2=<@b> $3=<@> CWORD=3 WORDS="), + ( + "mycmd a:@b|", + "$2=<@b> $3=<:@> CWORD=3 WORDS=", + ), + ("mycmd a@|", "$2=<@> $3= CWORD=2 WORDS="), + ( + r"mycmd a\@b|", + r"$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd \"a@b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd $'a b|", + "$2= $3= CWORD=1 WORDS=", + ), + ("mycmd a >b|", "$2= $3=<>> CWORD=3 WORDS=,b>"), + ("mycmd a>b|", "$2= $3=<>> CWORD=3 WORDS=,b>"), + ]) + .await +} + +/// Like bash, `COMP_WORDS` should keep a command or parameter substitution together as +/// one word, even though readline's word to complete (`$2`) doesn't. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "TODO(completions): group $(...), ${...}, and `...` in COMP_WORDS"] +#[allow( + clippy::literal_string_with_formatting_args, + reason = "the inputs are shell syntax" +)] +async fn completion_function_args_match_bash_in_substitutions() -> Result<()> { + check_completion_function_args(&[ + ( + "mycmd $(a b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd $(a b)|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd $(a b) c|", + "$2= $3=<$(a b)> CWORD=2 WORDS=", + ), + ( + "mycmd x$(a b)y|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd ${a:b|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd ${a:b}|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "mycmd ${a:b} c|", + "$2= $3=<${a:b}> CWORD=2 WORDS=", + ), + ( + "mycmd `a b|", + "$2= $3= CWORD=1 WORDS=", + ), + ]) + .await +} + +/// Like bash, completion should only see the simple command containing the cursor: after +/// the last command separator, and past any leading variable assignments. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "TODO(completions): scope completion to the current simple command "] +async fn completion_function_args_match_bash_in_command_sequences() -> Result<()> { + check_completion_function_args(&[ + ( + "echo x; mycmd a|", + "$2= $3= CWORD=1 WORDS=", + ), + ( + "echo x && mycmd a|", + "$2= $3= CWORD=1 WORDS=", + ), + ("(mycmd a|", "$2= $3= CWORD=1 WORDS="), + ("X=1 mycmd a|", "$2= $3= CWORD=1 WORDS="), + ]) + .await +} + +/// The cursor position is a byte offset, which must be a char boundary in the line. +#[tokio::test(flavor = "multi_thread")] +async fn completion_position_must_be_char_boundary() -> Result<()> { + let mut test_shell = TestShell::new().await?; + assert!(test_shell.complete_end_of_line_full("ls é").await.is_ok()); + // Inside the multi-byte `é`, or past the end of the line. + assert!(test_shell.complete("ls é", 4).await.is_err()); + assert!(test_shell.complete("ls", 3).await.is_err()); + + Ok(()) +} + +/// Tests the range completions replace when the word being completed is in an open quote: +/// the candidates replace that quote too. +#[tokio::test(flavor = "multi_thread")] +async fn completion_range_in_open_quote() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell.run("complete -W 'ab' mycmd").await?; + + for (line, start) in [ + ("mycmd a", 6), + ("mycmd 'a", 6), + ("mycmd --x=\"a", 10), + ("mycmd x'a", 7), + ] { + let completions = test_shell.complete_end_of_line_full(line).await?; + assert_eq!(replaced_range(&completions)?, start..line.len(), "{line}"); + assert_eq!(candidate_texts(&completions), ["ab"], "{line}"); + } + + Ok(()) +} + +/// Like bash, a completion command (`complete -C`) gets the `COMP_*` variables exported to +/// it, and they aren't left set afterwards. Running it doesn't change `$?`. +#[tokio::test(flavor = "multi_thread")] +async fn completion_command_gets_comp_vars() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + // The command exits 0 (its arguments go to `true`), unlike the `false` before it. + .run("complete -C 'printenv COMP_LINE COMP_POINT; true' cmd; false") + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(candidate_texts(&completions), ["5", "cmd x"]); + + test_shell + .run("status=$?; [[ -v COMP_LINE ]] && line=set") + .await?; + assert_eq!(test_shell.get_var("status").as_deref(), Some("1")); + assert_eq!(test_shell.get_var("line"), None); + + Ok(()) +} + +/// Like bash, a completion function's `COMP_*` variables aren't left set afterwards, but +/// what it leaves in other variables is. +#[tokio::test(flavor = "multi_thread")] +async fn completion_function_vars_are_not_left_set() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + .run(r#"_f() { seen="$COMP_LINE|${COMP_WORDS[*]}"; COMPREPLY=(xy); }; complete -F _f cmd"#) + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(candidate_texts(&completions), ["xy"]); + assert_eq!(test_shell.get_var("seen").as_deref(), Some("cmd x|cmd x")); + + for var in [ + "COMP_LINE", + "COMP_POINT", + "COMP_WORDS", + "COMP_CWORD", + "COMPREPLY", + ] { + assert_eq!(test_shell.get_var(var), None, "{var}"); + } + + Ok(()) +} + +/// Like bash, the empty-line spec (`complete -E`) applies when there's nothing before the +/// cursor and it isn't at the start of a word; otherwise, at the start of a line, the +/// initial-word spec (`complete -I`) does. Expected values were captured from bash 5.3. +#[tokio::test(flavor = "multi_thread")] +async fn empty_line_spec_completes_empty_line() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run("complete -E -W empty; complete -I -W initial") + .await?; + + for (line, cursor, expected) in [ + ("", 0, "empty"), + (" cmd", 0, "empty"), + (" ", 2, "initial"), + ("cmd", 0, "initial"), + ] { + let completions = test_shell.complete(line, cursor).await?; + assert_eq!( + candidate_texts(&completions), + [expected], + "{line:?} at {cursor}" + ); + } + + Ok(()) +} + +/// Completion gives up, with no candidates, if a completion function keeps asking for it to +/// restart. +#[tokio::test(flavor = "multi_thread")] +async fn completion_that_keeps_restarting_gives_up() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run("_restart() { return 124; }; complete -F _restart -D") + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd xy").await?; + assert_eq!(candidate_texts(&completions), [] as [String; 0]); + + Ok(()) +} + +/// Like bash, a completion function for a special spec gets bash's name for it as `$1`; +/// with no words on the line, `COMP_WORDS` is empty and `COMP_CWORD` is -1; and when +/// completing the first word, `$3` is that word. Tests each input with the cursor at its +/// `|`; expected values were captured from bash 5.3. +#[tokio::test(flavor = "multi_thread")] +async fn special_spec_function_args_match_bash() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run( + r#" +_test_comp() { + local IFS=, + CAPTURED="\$1=<$1> \$2=<$2> \$3=<$3> CWORD=$COMP_CWORD NWORDS=${#COMP_WORDS[@]} WORDS=<${COMP_WORDS[*]}>" +} +complete -E -F _test_comp +complete -I -F _test_comp +complete -D -F _test_comp +"#, + ) + .await?; + + for (input, expected) in [ + ( + "|", + "$1=<_EmptycmD_> $2=<> $3=<> CWORD=-1 NWORDS=0 WORDS=<>", + ), + ( + "| cmd", + "$1=<_EmptycmD_> $2=<> $3=<> CWORD=0 NWORDS=2 WORDS=<,cmd>", + ), + ( + " |", + "$1=<_InitialWorD_> $2=<> $3=<> CWORD=-1 NWORDS=0 WORDS=<>", + ), + ( + "cm|", + "$1=<_InitialWorD_> $2= $3= CWORD=0 NWORDS=1 WORDS=", + ), + ( + "|cmd", + "$1=<_InitialWorD_> $2=<> $3= CWORD=0 NWORDS=1 WORDS=", + ), + ( + "foo x|", + "$1= $2= $3= CWORD=1 NWORDS=2 WORDS=", + ), + ] { + test_shell.set_var("CAPTURED", "")?; + test_shell.complete_at_marker(input).await?; + assert_eq!( + test_shell.get_var("CAPTURED").as_deref(), + Some(expected), + "{input}" + ); + } + + Ok(()) +} + +/// Like bash, the `file` action makes a completion's candidates file names, and so does the +/// `directory` action if it finds any. Expected values were captured from bash 5.3. +#[tokio::test(flavor = "multi_thread")] +async fn file_and_directory_actions_complete_file_names() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell.temp_dir.child("a b").touch()?; + test_shell.temp_dir.child("dir x").create_dir_all()?; + test_shell + .run( + r"complete -f filecmd; complete -d dircmd + complete -W 'q\ r' -f wfilecmd; complete -W 'q\ r' -d wdircmd", + ) + .await?; + + for (line, expected, file_names) in [ + ("filecmd a", "a b", true), + ("dircmd di", "dir x", true), + // Even with no file names found, `-f` makes the other candidates file names... + ("wfilecmd q", "q r", true), + // ...but `-d` doesn't, if it finds no directories. + ("wdircmd q", "q r", false), + ] { + let completions = test_shell.complete_end_of_line_full(line).await?; + assert_eq!(candidate_texts(&completions), [expected], "{line}"); + assert_eq!(completions.options.treat_as_filenames, file_names, "{line}"); + } + + Ok(()) +} + +/// Like bash, `compopt` changes the options of the completion in progress, which a +/// `compgen` call from the completion function doesn't disturb. +#[tokio::test(flavor = "multi_thread")] +async fn compopt_survives_compgen_in_completion_function() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + .run( + "_f() { compopt -o nospace; compgen -W x >/dev/null; COMPREPLY=(xy); }; complete -F _f cmd", + ) + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(completions.candidates, ["xy"]); + assert!(completions.options.no_trailing_space_at_end_of_line); + + Ok(()) +} + +/// Like bash, a subshell of a completion function is in the completion too (so `compopt` +/// succeeds there), but `compopt` there changes only the subshell's options, not the +/// completion's. +#[tokio::test(flavor = "multi_thread")] +async fn compopt_in_subshell_does_not_change_completion() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + .run("_f() { (compopt -o nospace) && COMPREPLY=(xy); }; complete -F _f cmd") + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(completions.candidates, ["xy"]); + assert!(!completions.options.no_trailing_space_at_end_of_line); + + Ok(()) +} + +/// A completion that's cancelled (e.g. by Ctrl-C) while its completion function runs leaves +/// nothing of it behind: the function's call, its variables (and the `COMP_*` ones set for +/// it), the block on traps, or the completion in progress (so `compopt` acts as it does +/// outside one). +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_completion_leaves_nothing_behind() -> Result<()> { + let mut test_shell = TestShell::new().await?; + test_shell + .run("_slow() { local x=1; FOO=1 _block; }; _block() { sleep 10 >/dev/null 2>&1; }; complete -F _slow mycmd") + .await?; + + let completion = test_shell.complete_end_of_line_full("mycmd x"); + let result = tokio::time::timeout(std::time::Duration::from_millis(500), completion).await; + assert!(result.is_err(), "the completion should have been cancelled"); + + let shell = &test_shell.shell; + assert!(!shell.in_function()); + assert!(shell.env_str("x").is_none()); + assert!(shell.env_str("FOO").is_none()); + + test_shell.run("compopt -o nospace; rc=$?").await?; + assert_eq!(test_shell.get_var("rc").as_deref(), Some("1")); + + test_shell + .run("leaked=0; [[ -v COMP_LINE ]] && leaked=1") + .await?; + assert_eq!(test_shell.get_var("leaked").as_deref(), Some("0")); + assert!(!test_shell.shell.call_stack().is_trap_delivery_suppressed()); + + Ok(()) +} + +/// Tests native variable completion without braces (e.g., $VAR) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_variable_names() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + // Set test variables. + test_shell.set_var("TESTVAR1", "value1")?; + test_shell.set_var("TESTVAR2", "value2")?; + + // Complete. + let completions = test_shell + .complete_end_of_line_full("echo $TESTVAR") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); + + // Variable completions should not be treated as filenames (to avoid escaping $) + assert!( + !completions.options.treat_as_filenames, + "variable completions should not be treated as filenames" + ); + + Ok(()) +} + +/// Tests native variable completion with braces (e.g., ${VAR}) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_variable_names_with_braces() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + // Set test variables. + test_shell.set_var("TESTVAR1", "value1")?; + test_shell.set_var("TESTVAR2", "value2")?; + + // Complete. + let completions = test_shell + .complete_end_of_line_full("echo ${TESTVAR") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); + + // Variable completions should not be treated as filenames (to avoid escaping $) + assert!( + !completions.options.treat_as_filenames, + "variable completions should not be treated as filenames" + ); + + Ok(()) +} + +/// Tests that file completion works after a variable (e.g., $VAR/path) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_path_after_variable() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + // Create files in temp dir. + test_shell.temp_dir.child("file1.txt").touch()?; + test_shell.temp_dir.child("file2.txt").touch()?; + + // Set a variable pointing to the temp dir. + let temp_path = test_shell.temp_dir.path().to_str().unwrap().to_owned(); + test_shell.set_var("MYDIR", &temp_path)?; + + // Complete files after variable expansion. + // The completion system expands variables before completing, so results use expanded paths. + let completions = test_shell + .complete_end_of_line_full("ls $MYDIR/file") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + let expected = [ + std::format!("{temp_path}/file1.txt"), + std::format!("{temp_path}/file2.txt"), + ]; + assert_eq!(results, expected); + + // Path completions should be treated as filenames + assert!( + completions.options.treat_as_filenames, + "path completions should be treated as filenames" + ); + + Ok(()) +} + +/// Like bash, `compopt -o nosort` in a completion function keeps the candidates in the order +/// generated. +#[tokio::test(flavor = "multi_thread")] +async fn compopt_nosort_applies_to_completion_in_progress() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + .run("_f() { compopt -o nosort; COMPREPLY=(xb xa xc); }; complete -F _f cmd") + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(completions.candidates, ["xb", "xa", "xc"]); + + Ok(()) +} + +/// A `compgen` call in a completion function doesn't leak its options (e.g. its own +/// `nosort`) into the completion: like bash, the candidates are still sorted. +#[tokio::test(flavor = "multi_thread")] +async fn compgen_in_completion_function_keeps_sorting() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + test_shell + .run("_f() { compgen -W x >/dev/null; COMPREPLY=(xb xa); }; complete -F _f cmd") + .await?; + + let completions = test_shell.complete_end_of_line_full("cmd x").await?; + assert_eq!(completions.candidates, ["xa", "xb"]); + + Ok(()) +} + +/// Like bash, a `complete -X` pattern's leading `!` is interpreted when completing, not +/// when the spec is registered: with extglob on by then, `!(...)` is an extglob pattern. +#[tokio::test(flavor = "multi_thread")] +async fn completion_filter_is_interpreted_when_completing() -> Result<()> { + let mut test_shell = TestShell::new().await?; + + let exec_params = test_shell.shell.default_exec_params(); + let result = test_shell + .shell + .run_string( + "shopt -u extglob; complete -W 'foo bar fab' -X '!(f*)' mycmd; shopt -s extglob" + .to_owned(), + &brush_core::SourceInfo::default(), + &exec_params, + ) + .await?; + anyhow::ensure!(result.is_success()); + + // `!(f*)` matches what doesn't start with `f`, so those are removed. + let completions = test_shell.complete_end_of_line_full("mycmd ").await?; + assert_eq!(completions.candidates, ["fab", "foo"]); + + Ok(()) +} diff --git a/userland/brush/source/brush-shell/tests/images/.gitignore b/userland/brush/source/brush-shell/tests/images/.gitignore new file mode 100644 index 0000000..58cddeb --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/.gitignore @@ -0,0 +1,17 @@ +# mkosi build outputs +mkosi.output/ +mkosi.builddir/ +mkosi.cache/ +mkosi.pkgcache/ + +# mkosi local overrides and secrets +mkosi.local.conf +mkosi.local/ +mkosi.rootpw +mkosi.passphrase +mkosi.key +mkosi.crt +mkosi.nspawn + +# stale top-level image from before OutputDirectory was configured +*.raw diff --git a/userland/brush/source/brush-shell/tests/images/README.md b/userland/brush/source/brush-shell/tests/images/README.md new file mode 100644 index 0000000..c3c477c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/README.md @@ -0,0 +1,39 @@ +# Brush Test Images + +mkosi configs for building minimal Fedora images with brush as `/bin/sh` and the default login shell. + +## Prerequisites + +Build brush first: + +```bash +cargo build --release +``` + +## Container (systemd-nspawn) + +```bash +mkosi --profile=container build +sudo mkosi --profile=container boot +``` + +## VM (KVM-accelerated) + +```bash +mkosi --profile=vm build +mkosi --profile=vm vm +``` + +## Options + +Use a debug build: + +```bash +mkosi --environment=BRUSH_PROFILE=debug --profile=container build +``` + +Force rebuild: + +```bash +mkosi -f --profile=container build +``` diff --git a/userland/brush/source/brush-shell/tests/images/mkosi.conf b/userland/brush/source/brush-shell/tests/images/mkosi.conf new file mode 100644 index 0000000..86c44c1 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/mkosi.conf @@ -0,0 +1,26 @@ +[Distribution] +Distribution=fedora +Release=43 + +[Output] +OutputDirectory=mkosi.output +CompressOutput=no + +[Content] +Packages=systemd + util-linux + dnf5 +Autologin=yes +RootPassword=hashed: +RootShell=/usr/bin/brush +WithDocs=no +CleanPackageMetadata=no +RemoveFiles=/usr/share/locale + +[Build] +BuildSources=.:, + ../../../target:brush-target +Environment=BRUSH_PROFILE=release + +[Validation] +SecureBoot=no diff --git a/userland/brush/source/brush-shell/tests/images/mkosi.postinst b/userland/brush/source/brush-shell/tests/images/mkosi.postinst new file mode 100755 index 0000000..8144dd5 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/mkosi.postinst @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +BRUSH_BINARY="${SRCDIR}/brush-target/${BRUSH_PROFILE}/brush" + +if [[ ! -f "${BRUSH_BINARY}" ]]; then + echo "error: brush binary not found at: ${BRUSH_BINARY}" >&2 + echo "hint: run 'cargo build --${BRUSH_PROFILE}' first, or override with:" >&2 + echo " mkosi --environment=BRUSH_PROFILE=debug build" >&2 + exit 1 +fi + +# Install the pre-built brush binary +install -Dm755 "${BRUSH_BINARY}" "${BUILDROOT}/usr/bin/brush" + +# Register brush as a valid login shell +grep -qxF /usr/bin/brush "${BUILDROOT}/etc/shells" 2>/dev/null \ + || echo /usr/bin/brush >> "${BUILDROOT}/etc/shells" + +# Make brush available as /bin/sh and as /bin/bash +ln -sf brush "${BUILDROOT}/usr/bin/sh" +ln -sf brush "${BUILDROOT}/usr/bin/bash" diff --git a/userland/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf b/userland/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf new file mode 100644 index 0000000..5e6392d --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf @@ -0,0 +1,6 @@ +[Output] +Format=directory +ImageId=brush-container + +[Content] +Bootable=no diff --git a/userland/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf b/userland/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf new file mode 100644 index 0000000..f5edafe --- /dev/null +++ b/userland/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf @@ -0,0 +1,21 @@ +[Output] +Format=disk +ImageId=brush-vm + +[Content] +Bootable=yes +Bootloader=systemd-boot +Packages=systemd-udev + systemd-boot + kernel-core + +KernelCommandLine=console=hvc0 + quiet + systemd.show_status=error + +[Runtime] +Firmware=linux +KVM=auto +CPUs=2 +RAM=1G +Console=interactive diff --git a/userland/brush/source/brush-shell/tests/integration_tests.rs b/userland/brush/source/brush-shell/tests/integration_tests.rs new file mode 100644 index 0000000..f2de5c4 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/integration_tests.rs @@ -0,0 +1,63 @@ +//! Brush-only test harness. +//! +//! This test harness runs YAML-based test cases with inline expectations +//! or insta snapshots, without comparing against an oracle shell. + +#![cfg(any(unix, windows))] + +use anyhow::Result; +use brush_test_harness::{RunnerConfig, TestMode, TestOptions, TestRunner}; +use clap::Parser; +use std::path::{Path, PathBuf}; + +async fn run_brush_tests(mut options: TestOptions) -> Result { + // Resolve path to the shell-under-test. + if options.brush_path.is_empty() { + options.brush_path = assert_cmd::cargo::cargo_bin!("brush") + .to_string_lossy() + .to_string(); + } + if !Path::new(&options.brush_path).exists() { + return Err(anyhow::anyhow!( + "brush binary not found: {}", + options.brush_path + )); + } + + // Resolve test cases directory (in cases/brush/). + let test_cases_dir = options.test_cases_path.as_deref().map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases/brush"), + |p| p.to_owned(), + ); + + let test_shell = options.create_test_shell_config()?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir) + .with_mode(TestMode::Expectation) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options); + runner.run().await +} + +fn main() -> Result<()> { + let unparsed_args: Vec<_> = std::env::args().collect(); + let options = TestOptions::parse_from(unparsed_args); + + let success = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(32) + .build()? + .block_on(run_brush_tests(options))?; + + if !success { + std::process::exit(1); + } + + Ok(()) +} diff --git a/userland/brush/source/brush-shell/tests/interactive_tests.rs b/userland/brush/source/brush-shell/tests/interactive_tests.rs new file mode 100644 index 0000000..ae579b1 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/interactive_tests.rs @@ -0,0 +1,706 @@ +//! Interactive integration tests for brush shell + +// Only compile this for platforms supported by expectrl's pty backend. +#![cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd" +))] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Context; +use std::os::unix::process::CommandExt as _; + +use expectrl::{ + Expect, Session, + process::unix::{PtyStream, UnixProcess}, + repl::ReplSession, + stream::log::LogStream, +}; + +#[test_with::executable(ping)] +#[test] +fn run_suspend_and_fg() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Ping localhost in a loop; wait for at least one response. + session.expect_prompt()?; + session.send_line("ping -c 1000000 127.0.0.1")?; + session + .expect("bytes from") + .context("Initial ping invocation output")?; + + // Suspend and resume a handful of times to make sure it pauses and + // resumes reliably. + for _ in 0..5 { + // Suspend. + session.suspend()?; + session.expect_prompt()?; + + // Run `jobs` to see the suspended job. + let jobs_output = session.exec_output("jobs")?; + assert!(jobs_output.contains("ping")); + + // Bring the job to the foreground. + session.send_line("fg")?; + session.expect("ping").context("Foregrounded ping")?; + } + + // Ctrl+C to cancel the ping. + session.interrupt()?; + session.expect("loss")?; + session.expect_prompt()?; + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test_with::executable(ping)] +#[test] +fn run_in_bg_then_fg() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Ping localhost in a loop; wait for at least one response. + session.expect_prompt()?; + session.send_line("ping -c 1000000 127.0.0.1")?; + session.expect("bytes from")?; + + // Suspend and send to background. + session.suspend()?; + session.expect_prompt()?; + + // Run `jobs` to see the suspended job. + let jobs_output = session.exec_output("jobs")?; + assert!(jobs_output.contains("ping")); + + // Send the job to the background. + session.send_line("bg")?; + session.expect_prompt()?; + + // Make sure ping is still running asynchronously. + session.expect("bytes from")?; + + // Kill the job; make sure it's done. + session.send_line("kill %1")?; + session.expect_prompt()?; + session.send_line("wait")?; + session.expect_prompt()?; + + // Make sure the jobs are gone. + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), ""); + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, sleep)] +#[test] +fn resume_suspended_pipeline_in_foreground() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + // `fg` must continue every process of the pipeline, and give the terminal to their group, + // or Ctrl+C can't end the pipeline. + session.send_line("sleep 30 | sh -c 'echo ready-$((1 + 1)); exec sleep 31'")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending pipeline")?; + + // (`fg` shows the job's command once it has continued it.) + session.send_line("fg")?; + session.expect("exec sleep 31")?; + session.interrupt()?; + session + .expect_prompt() + .context("Prompt after interrupting the resumed pipeline")?; + + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), "", "the pipeline should be gone"); + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, sleep)] +#[test] +fn resume_suspended_pipeline_after_its_leader_exits() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + // The pipeline's first program leads its process group, which outlives it: `fg` must give + // the terminal to the group, not to the first program left. (The second program waits for + // the first to exit before it's ready.) + session.send_line("sh -c 'exit 0' | sh -c 'cat; echo ready-$((1 + 1)); exec sleep 32'")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending pipeline")?; + + // (`fg` shows the job's command once it has continued it.) + session.send_line("fg")?; + session.expect("exec sleep 32")?; + session.interrupt()?; + session + .expect_prompt() + .context("Prompt after interrupting the resumed pipeline")?; + + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), "", "the pipeline should be gone"); + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, sleep, pgrep)] +#[test] +fn kill_suspended_pipeline() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + session.send_line("sleep 1017 | sh -c 'echo ready-$((1 + 1)); exec sleep 1018'")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending pipeline")?; + + // Killing the job kills all of its pipeline's processes. + session.send_line("kill -9 %1; wait")?; + session.expect_prompt()?; + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), "", "the job should be gone"); + assert_no_process_left(&mut session, "^sleep 101[78]$")?; + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, sleep, pgrep)] +#[test] +fn terminate_suspended_pipeline() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + session.send_line("sleep 1027 | sh -c 'echo ready-$((1 + 1)); exec sleep 1028'")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending pipeline")?; + + // A stopped job sent a request to terminate is continued, so that it acts on it. + session.send_line("kill -TERM %1; wait")?; + session.expect_prompt()?; + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), "", "the job should be gone"); + assert_no_process_left(&mut session, "^sleep 102[78]$")?; + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, head, sort)] +#[test] +fn resume_pipeline_stopped_in_background() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + // In the background, `head` reads from the terminal, which stops the pipeline; `fg` must + // give it the terminal, and continue it, without its stopping again. + session.send_line("sh -c 'echo ready-$((1 + 1)) >&2; exec head -n 2' | sort")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending pipeline")?; + session.send_line("bg")?; + session.expect_prompt()?; + + // (`fg` shows the job's command once it has continued it.) + session.send_line("fg")?; + session.expect("exec head -n 2")?; + session.send("zebra\napple\n")?; + session + .expect(expectrl::Regex("apple\\s+zebra")) + .context("sorted input after resuming the pipeline")?; + session.expect_prompt()?; + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sh, seq, sed, less, stty, ps)] +#[test] +fn resume_pager_of_suspended_program() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + let shell_pid = session.exec_output("echo $$")?.trim().parse()?; + let initial_modes = terminal_modes(&mut session)?; + + // A program that runs a pager (as `git log` does) is stopped along with it, and its other + // processes, which `fg` must all continue, giving them the terminal. + // + // (The pager acts on Ctrl+Z only between reads from the terminal, by putting the terminal + // back and stopping itself, so the test waits for it to be reading before suspending it, + // and for it to have stopped before typing again.) + session.send_line("sh -c 'seq 1 100 | sed s/^/line-/ | less'")?; + session.expect("line-1")?; + wait_for_state(shell_pid, "less", ProcessState::Sleeping)?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending the pager")?; + wait_for_state(shell_pid, "less", ProcessState::Stopped)?; + + // The pager redraws and reads keys from the terminal once continued. + session.send_line("fg")?; + session.expect("line-1").context("pager redrawn")?; + session.send("G")?; + session.expect("line-100").context("pager's last line")?; + + // And it can be suspended and continued again. + wait_for_state(shell_pid, "less", ProcessState::Sleeping)?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending the pager again")?; + wait_for_state(shell_pid, "less", ProcessState::Stopped)?; + session.send_line("fg")?; + session.expect("line-100").context("pager redrawn again")?; + session.send("q")?; + session + .expect_prompt() + .context("Prompt after quitting the pager")?; + + // The pager leaves the terminal as it found it. + assert_eq!(terminal_modes(&mut session)?, initial_modes); + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(less)] +#[test] +fn run_pipeline_interactively() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Run a pipeline interactively. + session.expect_prompt()?; + session.send_line("echo hello | TERM=linux less")?; + session + .expect("hello") + .context("Echoed text didn't show up")?; + session.send("h")?; + session + .expect("SUMMARY") + .context("less help didn't show up")?; + session.send("q")?; + session.send("q")?; + session + .expect_prompt() + .context("Final prompt didn't show up")?; + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test] +fn login_shell_via_argv0_shows_prompt() -> anyhow::Result<()> { + let mut session = start_shell_session_with(|cmd| { + cmd.arg("--norc").arg0("-brush"); + })?; + + session.expect_prompt()?; + let login_shell_output = session.exec_output("shopt -q login_shell && echo login")?; + assert!(login_shell_output.contains("login")); + + session.exit()?; + + Ok(()) +} + +#[test] +fn dash_s_with_positional_args_is_interactive() -> anyhow::Result<()> { + // With `-s`, trailing words are positional parameters and commands still come from + // stdin -- so at a terminal the shell is interactive, and `PROMPT_COMMAND` and the + // zsh-style hooks (which are gated on that) run. + let mut session = start_shell_session_with(|cmd| { + cmd.args(["--norc", "-s", "myarg"]); + })?; + + session.expect_prompt()?; + + // N.B. The markers are computed, so the echoed command line can't satisfy the assertion. + let output = session.exec_output(r#"echo "FLAGS[${-//[!i]/}] ARG[$1]""#)?; + assert!( + output.contains("FLAGS[i] ARG[myarg]"), + "expected an interactive shell with $1 set; got: {output}" + ); + + session.exec_output("PROMPT_COMMAND='echo PC-RAN'")?; + let output = session.exec_output("echo done")?; + assert!( + output.contains("PC-RAN"), + "PROMPT_COMMAND didn't run: {output}" + ); + + session.exit()?; + + Ok(()) +} + +#[test] +fn zsh_style_hook_state_is_visible_to_rc_files() -> anyhow::Result<()> { + // The generic YAML harness always passes --norc, so this ordering check must use a PTY + // session that can load a dedicated rc file. + let rc_file = temp_script( + r#"echo "RC-GUARD[${bash_preexec_imported:-unset}] RC-ARRAYS[${precmd_functions[*]:-unset}|${preexec_functions[*]:-unset}]" +rc_precmd() { echo RC-PRECMD; } +precmd_functions+=(rc_precmd) +"#, + )?; + + let mut session = start_shell_session_with(|cmd| { + cmd.arg("--enable-zsh-hooks") + .arg("--rcfile") + .arg(rc_file.path()); + })?; + + session.expect("RC-GUARD[defined] RC-ARRAYS[precmd|preexec]")?; + session.expect("RC-PRECMD")?; + session.expect_prompt()?; + + let output = session.exec_output("echo trigger")?; + assert!(output.contains("RC-PRECMD")); + + session.exit()?; + + Ok(()) +} + +/// A key bound with `bind -x` runs its command, which sees the line being edited. +#[test] +fn runs_commands_bound_to_keys() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + session.send_line(r#"bind -x '"\C-x": echo "bound:[$READLINE_LINE]"'"#)?; + session.expect_prompt()?; + + // Type a line, and press Ctrl-X. + session.send("abc\x18")?; + session + .expect("bound:[abc]") + .context("bound command didn't run with the line")?; + + // The line comes back afterward; clear it with Ctrl-U before exiting. + session.send("\x15")?; + session.exit()?; + + Ok(()) +} + +/// Enter sends Ctrl-M, which the terminal passes on as is, so a key binding can +/// tell it from Ctrl-J (which is what `send_line` sends). +#[test] +fn tells_enter_from_ctrl_j() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + session.send_line(r#"bind -x '"\C-m": echo "enter:[$READLINE_LINE]"'"#)?; + session.expect_prompt()?; + + session.send("abc\r")?; + session + .expect("enter:[abc]") + .context("Enter didn't run the command bound to Ctrl-M")?; + + // The line comes back afterward; clear it with Ctrl-U before exiting. + session.send("\x15")?; + session.exit()?; + + Ok(()) +} + +/// Terminal shell integration brackets each command with a matched pair of OSC 633 markers: +/// `E`/`C` before it runs, `D` after. A `preexec` hook that exits the shell means the command +/// never runs, so neither marker may be emitted -- a lone `D` desynchronizes a terminal's +/// command tracking. +#[test] +fn osc_command_markers_stay_paired_when_preexec_exits() -> anyhow::Result<()> { + const COMMAND_STARTED: &str = "\x1b]633;C"; + const COMMAND_FINISHED: &str = "\x1b]633;D"; + + let mut session = start_shell_session_with(|cmd| { + cmd.args([ + "--norc", + "--enable-zsh-hooks", + "--enable-terminal-integration", + ]); + // OSC 633 is emitted only for terminals known to understand it. + cmd.env("TERM_PROGRAM", "vscode"); + })?; + session.expect_prompt()?; + + // A command that does run gets the full pair. (`preexec` isn't defined yet when this line + // is dispatched, so the hook doesn't fire for it.) + session.send_line("preexec() { case $1 in stop) exit 5;; esac; }")?; + session + .expect(COMMAND_STARTED) + .context("no command-started marker for a command that ran")?; + session + .expect(COMMAND_FINISHED) + .context("no command-finished marker for a command that ran")?; + session.expect_prompt()?; + + // The hook exits before this line runs, so it gets neither marker. + session.send_line("stop")?; + // `Eof` matches the whole remaining buffer, so the bytes seen since the last prompt are + // the match itself; `before()` would be empty. + let captures = session + .expect(expectrl::Eof) + .context("shell did not exit")?; + let tail = String::from_utf8_lossy(captures.as_bytes()).into_owned(); + + assert!( + !tail.contains(COMMAND_STARTED) && !tail.contains(COMMAND_FINISHED), + "a command that never ran emitted markers: {tail:?}" + ); + + Ok(()) +} + +// +// Helpers +// + +#[test_with::executable(sh, sleep)] +#[test] +fn suspend_program_while_background_jobs_wait() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + // Background jobs wait for programs of their own while the foreground program runs; the + // foreground program's stop is for its own wait alone. (Which wait hears of it first + // varies, so more background jobs make for a likelier failure without the fix.) + session.send_line("for i in {1..16}; do { sleep 5; } & done")?; + session.expect_prompt()?; + session.send_line("sh -c 'echo ready-$((1 + 1)); exec sleep 1061'")?; + session.expect("ready-2")?; + session.suspend()?; + session + .expect_prompt() + .context("Prompt after suspending the program")?; + + let jobs_output = session.exec_output("jobs")?; + assert!( + jobs_output + .lines() + .any(|line| line.contains("Stopped") && line.contains("1061")), + "unexpected jobs: {jobs_output}" + ); + + session.send_line("kill -9 %+")?; + session.expect_prompt()?; + + session.exit()?; + + Ok(()) +} + +#[test_with::executable(sleep)] +#[test] +fn stop_signal_to_shell_leaves_background_jobs_alone() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + session.expect_prompt()?; + + // A stop signal that reaches the shell stops none of the programs that background jobs are + // waiting for, so none of them is taken to be stopped. + session.send_line("{ sleep 3; echo bg-$((1 + 1)); } &")?; + session.expect_prompt()?; + // (The background job's program has started by the time the signal is sent.) + let output = + session.exec_output("sleep 0.5; kill -TSTP $$; sleep 1; echo marker-$((1 + 2))")?; + assert!(output.contains("marker-3"), "unexpected output: {output}"); + assert!( + !output.contains("Stopped") && !output.contains("bg-2"), + "unexpected output: {output}" + ); + + session.exit()?; + + Ok(()) +} + +type ShellSession = ReplSession>>; +// N.B. Comment out the above line and uncomment out the following line to disable logging of the +// session. type ShellSession = ReplSession>; + +trait SessionExt { + fn suspend(&mut self) -> anyhow::Result<()>; + fn interrupt(&mut self) -> anyhow::Result<()>; + fn exec_output>(&mut self, cmd: S) -> anyhow::Result; +} + +impl SessionExt for ShellSession { + fn suspend(&mut self) -> anyhow::Result<()> { + // Send Ctrl+Z to suspend. + self.send(expectrl::ControlCode::Substitute)?; + Ok(()) + } + + fn interrupt(&mut self) -> anyhow::Result<()> { + // Send Ctrl+C to interrupt. + self.send(expectrl::ControlCode::EndOfText)?; + Ok(()) + } + + fn exec_output>(&mut self, cmd: S) -> anyhow::Result { + let output = self.execute(cmd)?; + let output_str = String::from_utf8(output)?; + Ok(output_str) + } +} + +/// Asserts that no process at the session's terminal has a command line matching `pattern`. +fn assert_no_process_left(session: &mut ShellSession, pattern: &str) -> anyhow::Result<()> { + // (macOS's `pgrep` has no `-s` to search the session, but has `-t`, which also finds + // processes that aren't the shell's children. It exits with 1 when it finds nothing, and + // with 2 or more when it fails.) + let output = session.exec_output(format!( + "t=$(tty); pgrep -f -t \"${{t#/dev/}}\" '{pattern}'; echo \"pgrep: $?\"" + ))?; + assert_eq!(output.trim(), "pgrep: 1", "every process should be gone"); + Ok(()) +} + +/// A process's state, as `ps` reports it. +#[derive(Clone, Copy, Debug)] +enum ProcessState { + /// Waiting, as for input (`S`). + Sleeping, + /// Stopped by a signal (`T`). + Stopped, +} + +/// Waits for the shell's descendants named `name` to be in the given state. +fn wait_for_state(shell_pid: u32, name: &str, state: ProcessState) -> anyhow::Result<()> { + let state_code = match state { + ProcessState::Sleeping => 'S', + ProcessState::Stopped => 'T', + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let ps = std::process::Command::new("ps") + .args(["-A", "-o", "pid=,ppid=,stat=,comm="]) + .output()?; + let ps = String::from_utf8(ps.stdout)?; + let processes: Vec<(u32, u32, &str, &str)> = ps + .lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + Some(( + fields.next()?.parse().ok()?, + fields.next()?.parse().ok()?, + fields.next()?, + fields.next()?, + )) + }) + .collect(); + + let mut descendants = vec![shell_pid]; + let mut i = 0; + while let Some(&parent) = descendants.get(i) { + descendants.extend(processes.iter().filter(|p| p.1 == parent).map(|p| p.0)); + i += 1; + } + + let named: Vec<_> = processes + .iter() + .filter(|p| descendants.contains(&p.0) && p.3.rsplit('/').next() == Some(name)) + .collect(); + if !named.is_empty() && named.iter().all(|p| p.2.starts_with(state_code)) { + return Ok(()); + } + + anyhow::ensure!( + std::time::Instant::now() < deadline, + "{name} isn't {state:?}: {named:?}" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Returns whether the session's terminal reads lines, echoes input, and generates signals. +fn terminal_modes(session: &mut ShellSession) -> anyhow::Result> { + let output = session.exec_output("stty -a")?; + Ok(output + .split(|c: char| c.is_whitespace() || c == ';') + .filter(|mode| matches!(mode.trim_start_matches('-'), "icanon" | "echo" | "isig")) + .map(str::to_owned) + .collect()) +} + +/// Writes `contents` to a temporary file that is deleted on drop. +fn temp_script(contents: &str) -> anyhow::Result { + use std::io::Write as _; + + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(contents.as_bytes())?; + Ok(file) +} + +fn start_shell_session() -> anyhow::Result { + start_shell_session_with(|cmd| _ = cmd.arg("--norc")) +} + +/// Starts a shell session at a pty. `configure` is the only place arguments and environment +/// come from beyond the fixed set below, so a session says for itself what it wants -- notably +/// `--norc`, or the `--rcfile` that a session wanting an rc file passes instead. +fn start_shell_session_with( + configure: impl FnOnce(&mut std::process::Command), +) -> anyhow::Result { + const DEFAULT_PROMPT: &str = "brush> "; + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + + let mut cmd = std::process::Command::new(shell_path); + cmd.args([ + "--noprofile", + "--no-config", + "--disable-bracketed-paste", + "--disable-color", + "--input-backend=basic", + ]); + cmd.env("PS1", DEFAULT_PROMPT); + cmd.env("TERM", "linux"); + configure(&mut cmd); + + let session = expectrl::session::Session::spawn(cmd)?; + + // N.B. Comment out this line to disable logging of the session (along with a similar line + // above). + let session = expectrl::session::log(session, std::io::stdout())?; + + let mut session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + session.set_echo(true); + + Ok(session) +} diff --git a/userland/brush/source/brush-shell/tests/pty_help_tests.rs b/userland/brush/source/brush-shell/tests/pty_help_tests.rs new file mode 100644 index 0000000..306730a --- /dev/null +++ b/userland/brush/source/brush-shell/tests/pty_help_tests.rs @@ -0,0 +1,63 @@ +//! Help printed to a pseudo-terminal (pty) is wrapped to the terminal's width. + +// Only compile this for platforms supported by expectrl's pty backend. +#![cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd" +))] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::time::Duration; + +use expectrl::{Expect, Session}; + +/// Runs `script` with `brush -c` in a terminal `cols` wide and returns its +/// output. `$BRUSH` names the shell. +fn run_in_terminal(cols: u16, script: &str) -> anyhow::Result { + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin!("brush")); + cmd.args(["--norc", "--noprofile", "--no-config", "-c", script]); + // No COLUMNS, NO_COLOR or COLORTERM from the caller: only the terminal decides. + cmd.env_clear().env("TERM", "xterm"); + cmd.env("BRUSH", assert_cmd::cargo::cargo_bin!("brush")); + + let mut session = Session::spawn(cmd)?; + session.set_expect_timeout(Some(Duration::from_secs(8))); + // The script reads a line first, so it runs only once the size is set. + session.get_process_mut().set_window_size(cols, 24)?; + session.send_line("")?; + let output = session.expect(expectrl::Eof)?; + Ok(String::from_utf8_lossy(output.as_bytes()).into_owned()) +} + +/// The width of the widest line, without escape sequences. +fn widest_line(output: &str) -> usize { + strip_ansi_escapes::strip_str(output) + .lines() + .map(|line| line.trim_end().chars().count()) + .max() + .unwrap_or(0) +} + +#[test] +fn builtin_help_wraps_to_the_terminal() -> anyhow::Result<()> { + let output = run_in_terminal(40, "read; brushctl --help")?; + assert!( + strip_ansi_escapes::strip_str(&output).contains("Usage: brushctl"), + "{output}" + ); + assert!(widest_line(&output) <= 40, "{output}"); + Ok(()) +} + +#[test] +fn shell_help_has_brush_colors_and_wraps_to_the_terminal() -> anyhow::Result<()> { + // Wide enough for the usage lines, which are never wrapped. + let output = run_in_terminal(60, r#"read; "$BRUSH" --help"#)?; + assert!(output.contains("\x1b[1;4;33mOptions:\x1b[0m"), "{output:?}"); + assert!(output.contains("\x1b[1;35m--version\x1b[0m"), "{output:?}"); + assert!(widest_line(&output) <= 60, "{output}"); + Ok(()) +} diff --git a/userland/brush/source/brush-shell/tests/pty_startup_tests.rs b/userland/brush/source/brush-shell/tests/pty_startup_tests.rs new file mode 100644 index 0000000..7c9e385 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/pty_startup_tests.rs @@ -0,0 +1,192 @@ +//! Shell startup tests run over a real pseudo-terminal (pty). +//! +//! These tests verify that the shell becomes responsive — prints a prompt and executes +//! a first command — within a bounded amount of time when attached to a controlling +//! terminal, across the invocation modes real terminal environments use: plain +//! invocation, explicit `-i`, explicit `-l`, presence/absence of rc and profile files, +//! and different process topologies (the shell as the direct pty-attached child vs. a +//! descendant of another pty-attached process). +//! +//! They complement `interactive_tests.rs`, which exercises interactive *behavior* (job +//! control, suspension, pipelines) using the simplified `basic` input backend and +//! argv0-based login detection. This file instead focuses on *startup*, uses the +//! default input backend (the one real terminal sessions get), and exercises the +//! explicit `--login` flag. Startup is where terminal-ownership setup happens +//! (process-group creation, `tcsetpgrp`, `SIGTTIN`/`SIGTTOU` handling), and bugs there +//! tend to appear only with a real controlling terminal — piped stdin bypasses the +//! whole path. (See issue #1318 for an example of such a regression.) + +// Only compile this for platforms supported by expectrl's pty backend. +#![cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd" +))] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Context; +use std::time::Duration; + +use expectrl::{ + Expect, Session, + process::unix::{PtyStream, UnixProcess}, + stream::log::LogStream, +}; + +/// Bound on how long we wait for the shell to become responsive. A healthy shell prompts +/// in well under a second; the generous margin only accommodates slow CI machines. Kept +/// well under any outer test-harness timeout so a startup hang fails here, with a +/// specific message, rather than stalling the harness. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(8); + +const DEFAULT_PROMPT: &str = "brush> "; + +/// The shell invoked with no flags, as by `exec brush` or a terminal emulator configured +/// with a bare command line; interactivity is inferred from stdin being a terminal. +#[test] +fn default_backend_plain_starts_promptly() -> anyhow::Result<()> { + let session = spawn_shell(brush_command(&[]))?; + expect_responsive_shell(session, /* expect_prompt */ true) +} + +/// The shell invoked with an explicit `-i`, as scripts and embedders commonly do to force +/// interactive mode. +#[test] +fn default_backend_interactive_flag_starts_promptly() -> anyhow::Result<()> { + let session = spawn_shell(brush_command(&["-i"]))?; + expect_responsive_shell(session, /* expect_prompt */ true) +} + +/// The shell invoked with an explicit `-l`, as terminal emulators configured to start +/// login shells do. This is a distinct code path from argv0-based login detection, which +/// `login_shell_via_argv0_shows_prompt` in `interactive_tests.rs` covers. +#[test] +fn default_backend_login_flag_starts_promptly() -> anyhow::Result<()> { + let session = spawn_shell(brush_command(&["-l"]))?; + expect_responsive_shell(session, /* expect_prompt */ true) +} + +/// A login shell for a user with no rc/profile files at all (fresh account, minimal +/// container image): startup must not depend on any dotfile existing. Since system-wide +/// profile files may still run and set their own PS1, this asserts on command output +/// rather than a specific prompt string. +#[test] +fn login_flag_with_empty_home_starts_promptly() -> anyhow::Result<()> { + let empty_home = tempfile::tempdir()?; + + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + let mut cmd = std::process::Command::new(shell_path); + cmd.args(["-l", "--disable-bracketed-paste", "--disable-color"]); + cmd.env("HOME", empty_home.path()); + cmd.env("TERM", "linux"); + + let session = spawn_shell(cmd)?; + expect_responsive_shell(session, /* expect_prompt */ false) +} + +/// The shell started by a wrapper process rather than directly by whatever owns the pty — +/// as happens under `script(1)`, `su`, terminal multiplexers, and nested-shell setups. The +/// shell is then not the pty's session leader and must take terminal ownership from +/// another process group; spawning it directly (as the other tests here and all of +/// `interactive_tests.rs` do) never exercises that transition, since the direct child *is* +/// the session leader and already owns the terminal. +#[test] +fn login_shell_as_grandchild_starts_promptly() -> anyhow::Result<()> { + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + + let mut cmd = std::process::Command::new("sh"); + // The trailing `exit $?` keeps `sh` from optimizing the command into an exec, so brush + // really runs as a grandchild of the pty session leader. PS1 is set inline since some + // `sh` implementations drop it from the environment of non-interactive shells. + cmd.args([ + "-c", + "PS1='brush> ' \"$BRUSH_BIN\" -l --norc --noprofile --no-config \ + --disable-bracketed-paste --disable-color; exit $?", + ]); + cmd.env("BRUSH_BIN", shell_path); + cmd.env("TERM", "linux"); + + let session = spawn_shell(cmd)?; + expect_responsive_shell(session, /* expect_prompt */ true) +} + +// +// Helpers +// + +type PtySession = Session>; + +/// Builds a hermetic brush invocation that, unlike the sessions in `interactive_tests.rs`, +/// leaves the input backend at its default (reedline/crossterm) — the backend real +/// terminal sessions use. +fn brush_command(extra_args: &[&str]) -> std::process::Command { + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + + let mut cmd = std::process::Command::new(shell_path); + cmd.args([ + "--norc", + "--noprofile", + "--no-config", + "--disable-bracketed-paste", + "--disable-color", + ]); + cmd.args(extra_args); + cmd.env("PS1", DEFAULT_PROMPT); + cmd.env("TERM", "linux"); + + cmd +} + +fn spawn_shell(cmd: std::process::Command) -> anyhow::Result { + let session = Session::spawn(cmd)?; + let mut session = expectrl::session::log(session, std::io::stdout())?; + + // Enforce a bounded expect timeout so a startup hang fails fast with a clear error + // instead of relying on the outer test-harness timeout. + session.set_expect_timeout(Some(STARTUP_TIMEOUT)); + + Ok(session) +} + +/// Asserts the shell comes up and responds to input within `STARTUP_TIMEOUT`. +fn expect_responsive_shell(mut session: PtySession, expect_prompt: bool) -> anyhow::Result<()> { + if expect_prompt { + expect_answering_queries(&mut session, DEFAULT_PROMPT) + .context("No prompt appeared within the startup timeout")?; + } + + session.send_line("echo marker $((6*7))")?; + expect_answering_queries(&mut session, "marker 42") + .context("Shell did not respond to input within the startup timeout")?; + + session.send_line("exit")?; + session + .expect(expectrl::Eof) + .context("Shell did not exit cleanly")?; + + Ok(()) +} + +/// Waits for `needle`, answering any cursor-position (DSR) queries the shell's terminal +/// backend emits along the way, as a real terminal emulator would. Without a response the +/// default input backend fails on its own query timeout, which would mask whatever this +/// test is actually trying to observe. +fn expect_answering_queries(session: &mut PtySession, needle: &str) -> anyhow::Result<()> { + const CURSOR_POSITION_QUERY: &str = "\x1b[6n"; + + loop { + let captures = session.expect(expectrl::Any::boxed(vec![ + Box::new(needle.to_owned()), + Box::new(CURSOR_POSITION_QUERY.to_owned()), + ]))?; + + if captures.get(0) == Some(CURSOR_POSITION_QUERY.as_bytes()) { + // Report the cursor as being at row 1, column 1. + session.send("\x1b[1;1R")?; + } else { + return Ok(()); + } + } +} diff --git a/userland/brush/source/brush-shell/tests/reedline_interactive_tests.rs b/userland/brush/source/brush-shell/tests/reedline_interactive_tests.rs new file mode 100644 index 0000000..064316c --- /dev/null +++ b/userland/brush/source/brush-shell/tests/reedline_interactive_tests.rs @@ -0,0 +1,270 @@ +//! Interactive behavior tests for the reedline input backend, run over a +//! real pseudo-terminal. +//! +//! `pty_startup_tests.rs` covers *startup* on the default backend and +//! `interactive_tests.rs` covers job control on the `basic` backend; this file +//! is for behavior specific to reedline's line editing (key bindings, its +//! interaction with the terminal). +//! +//! Unlike the basic backend, reedline queries the terminal for the cursor +//! position (DSR, `ESC [ 6 n`) before it paints a prompt. A pty with nothing +//! on the other end never answers, so these tests play the role of the +//! terminal emulator and answer each query themselves (or, for the timeout +//! test, deliberately withhold an answer). + +// Only compile this for platforms supported by expectrl's pty backend. +#![cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use std::time::Duration; + +use anyhow::Context; +use expectrl::{ + Expect, Session, + process::unix::{PtyStream, UnixProcess}, + stream::log::LogStream, +}; + +const PROMPT: &str = "brush> "; +const DSR_QUERY: &str = "\x1b[6n"; +const DSR_REPLY: &str = "\x1b[1;1R"; + +/// OSC 633 markers bracketing a command: emitted before it runs, and after. +const COMMAND_STARTED: &str = "\x1b]633;C"; +const COMMAND_FINISHED: &str = "\x1b]633;D"; + +/// A key bound with `bind -x` must run its command and leave the shell +/// alive. Regression test for reedline >= 0.48 delivering such keys as +/// `Signal::HostCommand`, which previously fell through to a fatal +/// "unexpected error occurred reading input". +#[test] +fn bound_key_runs_command_and_shell_survives() -> anyhow::Result<()> { + let mut session = start_reedline_session()?; + expect_next_prompt(&mut session, 0)?; + + // The bound command's output is split so the echoed keystrokes of the + // `bind` line itself can't satisfy the expectation below. + session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; + expect_next_prompt(&mut session, 0)?; + + // Ctrl+T. + session.send("\x14")?; + session + .expect("BOUND_FIRED") + .context("bound command did not run")?; + expect_next_prompt(&mut session, 0).context("no prompt after bound command")?; + + // The shell must still be interactive afterwards. + session.send_line("echo STILL_$((40+2))")?; + session + .expect("STILL_42") + .context("shell did not survive the bound command")?; + + Ok(()) +} + +/// Like readline, completion sees the whole line, not just the text before the cursor: +/// e.g. `COMP_WORDS` includes words after it. +#[test] +fn completion_sees_text_after_cursor() -> anyhow::Result<()> { + let mut session = start_reedline_session()?; + expect_next_prompt(&mut session, 0)?; + + session.send_line( + r#"_f() { echo "WORDS""=<${COMP_WORDS[*]}> CWORD=$COMP_CWORD" >&2; }; complete -F _f mycmd"#, + )?; + expect_next_prompt(&mut session, 0)?; + + // Type the line, move the cursor back before `b`, and press Tab. + session.send("mycmd a b")?; + session.send("\x1b[D\t")?; + session + .expect("WORDS= CWORD=2") + .context("completion did not see the whole line")?; + + Ok(()) +} + +/// `preexec` fires for lines the user typed, not for a command a key binding ran. The +/// hooks exist to observe what the user is about to run; bash-preexec has the same split, +/// because a `bind -x` command runs from readline rather than from the command line. +#[test] +fn bound_key_command_does_not_fire_preexec() -> anyhow::Result<()> { + let mut session = start_reedline_session_with(|cmd| { + cmd.arg("--enable-zsh-hooks"); + })?; + expect_next_prompt(&mut session, 0)?; + + // The hook counts its dispatches as well as reporting its argument, so a stray dispatch + // for the bound command can't hide between the markers below. Markers are split so the + // echoed keystrokes of the setup lines can't satisfy the expectations. + session.send_line(r#"preexec() { n=$((n+1)); echo "PRE_""EXEC[$1]"; }"#)?; + expect_next_prompt(&mut session, 0)?; + + // Dispatch 1, for this line. + session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; + expect_next_prompt(&mut session, 0)?; + + // Ctrl+T. The bound command runs, but nothing was typed, so no dispatch. + session.send("\x14")?; + session + .expect("BOUND_FIRED") + .context("bound command did not run")?; + expect_next_prompt(&mut session, 0).context("no prompt after bound command")?; + + // Dispatch 2, for this line -- `$n` expands after the hook has already run, so a third + // dispatch anywhere would show up here. + session.send_line("echo COUNT_$n")?; + session + .expect("PRE_EXEC[echo COUNT_$n]") + .context("preexec did not fire for a typed line")?; + session + .expect("COUNT_2") + .context("preexec fired for something other than the two typed lines")?; + + Ok(()) +} + +/// A command a key binding runs is bracketed by the same OSC 633 marker pair as a typed one. +/// Terminals track commands by that pair, so emitting only the closing marker -- as this path +/// used to -- leaves one counting a command that never started. +#[test] +fn osc_command_markers_stay_paired_for_a_bound_command() -> anyhow::Result<()> { + let mut session = start_reedline_session_with(|cmd| { + cmd.arg("--enable-terminal-integration"); + // OSC 633 is emitted only for terminals known to understand it. + cmd.env("TERM_PROGRAM", "vscode"); + })?; + expect_next_prompt(&mut session, 0)?; + + session.send_line(r#"bind -x '"\C-t": echo BOUND_""FIRED'"#)?; + expect_next_prompt(&mut session, 0)?; + + // Ctrl+T. The bound command runs without the user typing a line, but it is still a + // command, so it gets both markers around it. + session.send("\x14")?; + session + .expect(COMMAND_STARTED) + .context("no command-started marker for the bound command")?; + session + .expect("BOUND_FIRED") + .context("bound command did not run")?; + session + .expect(COMMAND_FINISHED) + .context("no command-finished marker for the bound command")?; + + Ok(()) +} + +/// A single unanswered cursor-position query (a transient terminal hiccup, +/// e.g. right after a full-screen program hands the terminal back) must not +/// terminate the shell; reedline asks again and uses the next answer. +#[test] +fn transient_cursor_query_timeout_is_tolerated() -> anyhow::Result<()> { + let mut session = start_reedline_session()?; + expect_next_prompt(&mut session, 0)?; + + // Withhold the answer to the query preceding the next prompt; crossterm + // gives up on it after ~2s and the shell must carry on rather than exit. + session.send_line("echo BEFORE_$((7*6))")?; + session.expect("BEFORE_42")?; + expect_next_prompt(&mut session, 1) + .context("shell did not survive one unanswered cursor query")?; + + session.send_line("echo AFTER_$((6*7))")?; + session + .expect("AFTER_42") + .context("shell not interactive after the unanswered query")?; + + Ok(()) +} + +/// A terminal that never answers cursor-position queries still gets a +/// prompt and a working shell; reedline (>= 0.52) falls back to anchoring +/// the prompt at the bottom of the screen instead of failing the read. +#[test] +fn unanswered_cursor_queries_still_yield_a_prompt() -> anyhow::Result<()> { + let mut session = start_reedline_session()?; + expect_next_prompt(&mut session, 0)?; + + session.send_line("echo BEFORE_$((7*6))")?; + session.expect("BEFORE_42")?; + + // Never answer from here on. + session + .expect(PROMPT) + .context("no prompt without cursor-query answers")?; + session.send_line("echo AFTER_$((6*7))")?; + session + .expect("AFTER_42") + .context("shell not interactive without cursor-query answers")?; + + Ok(()) +} + +// +// Helpers +// + +type ShellSession = Session>; + +/// Waits for the cursor-position query that precedes a prompt paint, answers +/// it, and then waits for the prompt. The first `withhold` queries are left +/// unanswered instead, so the shell has to re-issue them. +fn expect_next_prompt(session: &mut ShellSession, mut withhold: usize) -> anyhow::Result<()> { + loop { + session + .expect(DSR_QUERY) + .context("no cursor-position query before prompt")?; + if withhold > 0 { + withhold -= 1; + continue; + } + session.send(DSR_REPLY)?; + session + .expect(PROMPT) + .context("no prompt after answered query")?; + return Ok(()); + } +} + +fn start_reedline_session() -> anyhow::Result { + start_reedline_session_with(|_| {}) +} + +fn start_reedline_session_with( + configure: impl FnOnce(&mut std::process::Command), +) -> anyhow::Result { + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + + let mut cmd = std::process::Command::new(shell_path); + cmd.args([ + "--norc", + "--noprofile", + "--no-config", + "--disable-bracketed-paste", + "--disable-color", + "--input-backend=reedline", + ]); + cmd.env("PS1", PROMPT); + cmd.env("TERM", "xterm-256color"); + configure(&mut cmd); + + let session = expectrl::session::Session::spawn(cmd)?; + + // N.B. Replace with `session` directly to disable logging of the session. + let mut session = expectrl::session::log(session, std::io::stdout())?; + + // The timeout tests deliberately let ~2s crossterm timeouts elapse, + // several in a row when the terminal never answers. + session.set_expect_timeout(Some(Duration::from_secs(15))); + + Ok(session) +} diff --git a/userland/brush/source/brush-shell/tests/utils/process-helpers.sh b/userland/brush/source/brush-shell/tests/utils/process-helpers.sh new file mode 100644 index 0000000..c174fbf --- /dev/null +++ b/userland/brush/source/brush-shell/tests/utils/process-helpers.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +function get-proc-stat-value() { + cat /proc/self/stat | cut -d ' ' --output-delimiter=, -f"$1" +} + +function get-pid() { + get-proc-stat-value 1 +} + +function get-ppid() { + get-proc-stat-value 4 +} + +function get-pgrp() { + get-proc-stat-value 5 +} + +function get-session-id() { + get-proc-stat-value 6 +} + +function get-tty-nr() { + get-proc-stat-value 7 +} + +function get-term-pgid() { + get-proc-stat-value 8 +} diff --git a/userland/brush/source/brush-shell/tests/version_tests.rs b/userland/brush/source/brush-shell/tests/version_tests.rs new file mode 100644 index 0000000..d1ea123 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/version_tests.rs @@ -0,0 +1,48 @@ +//! Integration tests for brush shell +//! +//! Most CLI tests have been moved to YAML format in tests/cases/brush/cli.yaml. +//! This file contains tests that require dynamic value comparison. + +// For now, only compile this for Unix-like platforms (Linux, macOS). +#![cfg(unix)] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Context; + +#[test] +fn get_version_variables() -> anyhow::Result<()> { + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + let brush_ver_str = get_variable(shell_path, /* shell_is_brush */ true, "BRUSH_VERSION")?; + let bash_ver_str = get_variable(shell_path, /* shell_is_brush */ false, "BASH_VERSION")?; + + assert_eq!(brush_ver_str, env!("CARGO_PKG_VERSION")); + assert_ne!( + brush_ver_str, bash_ver_str, + "Should differ for scripting use-case" + ); + + Ok(()) +} + +fn get_variable( + shell_path: &std::path::Path, + shell_is_brush: bool, + var: &str, +) -> anyhow::Result { + let mut cmd = std::process::Command::new(shell_path); + + if shell_is_brush { + cmd.arg("--no-config"); + } + + let output = cmd + .arg("--norc") + .arg("--noprofile") + .arg("-c") + .arg(format!("echo -n ${{{var}}}")) + .output() + .with_context(|| format!("failed to retrieve {var}"))? + .stdout; + Ok(String::from_utf8(output)?) +} diff --git a/userland/brush/source/brush-shell/tests/vt_interactive_tests.rs b/userland/brush/source/brush-shell/tests/vt_interactive_tests.rs new file mode 100644 index 0000000..4a8b9e7 --- /dev/null +++ b/userland/brush/source/brush-shell/tests/vt_interactive_tests.rs @@ -0,0 +1,63 @@ +//! Syntax highlighting options on the VT backend, over a real pseudo-terminal. +//! +//! Like the other pty tests, these run from a cargo target directory rather than +//! as prebuilt compatibility-test artifacts. + +#![cfg(test)] +#![cfg(vt_backend)] +#![cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] +#![allow(clippy::panic_in_result_fn)] + +use std::time::Duration; + +use anyhow::Context; +use expectrl::Expect; + +#[test] +fn vt_highlighting_respects_cli_and_config() -> anyhow::Result<()> { + for (config_enabled, flags, expected) in [ + (Some(false), vec![], false), + (None, vec!["--enable-highlighting"], true), + (Some(true), vec![], true), + (Some(true), vec!["--disable-color"], false), + ( + None, + vec!["--enable-highlighting", "--disable-color"], + false, + ), + ] { + let config_dir = tempfile::tempdir()?; + let config_path = config_dir.path().join("config.toml"); + std::fs::write( + &config_path, + config_enabled.map_or_else( + || "[ui]\n".to_owned(), + |enabled| format!("[ui]\nsyntax-highlighting = {enabled}\n"), + ), + )?; + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin!("brush")); + cmd.args(["--norc", "--noprofile", "--input-backend=vt", "--config"]) + .arg(&config_path) + .args(&flags) + .env("PS1", "brush> ") + .env("TERM", "xterm-256color"); + let mut session = expectrl::Session::spawn(cmd)?; + session.set_expect_timeout(Some(Duration::from_secs(8))); + session.expect("brush> ").context("VT prompt missing")?; + // Interrupt after typing so the captured bytes contain the redraws but + // no command output that could supply its own color sequences. + session.send("echo 'color' $HOME")?; + session.send("\x03")?; + let captured = session.expect("^C")?; + let output = String::from_utf8_lossy(captured.before()); + assert_eq!( + output.contains("\x1b[33m"), + expected, + "config={config_enabled:?}, flags={flags:?}: {output:?}" + ); + session.expect("brush> ").context("VT prompt missing")?; + session.send("\x04")?; + session.expect(expectrl::Eof)?; + } + Ok(()) +} diff --git a/userland/brush/source/brush-test-harness/Cargo.toml b/userland/brush/source/brush-test-harness/Cargo.toml new file mode 100644 index 0000000..fbab7c8 --- /dev/null +++ b/userland/brush/source/brush-test-harness/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "brush-test-harness" +description = "Test harness library for brush shell integration tests" +version = "0.1.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lib] +bench = false + +[lints] +workspace = true + +[features] +default = [] +# Enable insta snapshot support +insta = ["dep:insta"] + +[dependencies] +anyhow = "1.0.102" +assert_cmd = "2.2.0" +assert_fs = "1.1.3" +clap = { version = "4.6.0", features = ["derive", "env"] } +colored = "3.1.1" +descape = "3.0.0" +diff = "0.1.13" +glob = "0.3.3" +indent = "0.1.1" +insta = { version = "1.47.1", features = ["yaml"], optional = true } +junit-report = "0.9.0" +os-release = "0.1.0" +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +yaml_serde = "0.10.7" +strip-ansi-escapes = "0.2.1" +tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "sync"] } +version-compare = "0.2.1" +walkdir = "2.5.0" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = ["process"] } + +[build-dependencies] +# Defines the `pty` cfg (see build.rs) used to gate pty-based test code. +cfg_aliases = "0.2.1" + +# expectrl's pty support (via ptyprocess) is limited to these platforms; +# pty-based tests are reported as unsupported elsewhere. Keep this list in sync +# with the `pty` cfg in build.rs (which gates the code using this dependency). +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))'.dependencies] +expectrl = "0.9.0" diff --git a/userland/brush/source/brush-test-harness/LICENSE b/userland/brush/source/brush-test-harness/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-test-harness/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-test-harness/build.rs b/userland/brush/source/brush-test-harness/build.rs new file mode 100644 index 0000000..97a3c1b --- /dev/null +++ b/userland/brush/source/brush-test-harness/build.rs @@ -0,0 +1,11 @@ +//! Defines a `pty` cfg for platforms that have a working PTY backend (i.e. +//! where the `expectrl` library we use for pty-based tests is supported). +//! +//! Keep this predicate in sync with the `[target.'cfg(...)'.dependencies]` +//! section in `Cargo.toml` (Cargo manifests can't reference custom cfg names). + +fn main() { + cfg_aliases::cfg_aliases! { + pty: { any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd") }, + } +} diff --git a/userland/brush/source/brush-test-harness/src/comparison.rs b/userland/brush/source/brush-test-harness/src/comparison.rs new file mode 100644 index 0000000..b3154cc --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/comparison.rs @@ -0,0 +1,374 @@ +//! Comparison types for test results. + +use std::{io::Read, path::PathBuf, process::ExitStatus}; + +/// Comparison of durations between oracle and test runs. +#[derive(Default)] +pub struct DurationComparison { + /// Duration of the oracle run. + pub oracle: std::time::Duration, + /// Duration of the test run. + pub test: std::time::Duration, +} + +/// Comparison of exit statuses. +pub enum ExitStatusComparison { + /// Exit status was ignored. + Ignored, + /// Exit statuses match. + Same(ExitStatus), + /// Exit statuses differ. + TestDiffers { + /// Exit status from the test shell. + test_exit_status: ExitStatus, + /// Exit status from the oracle shell. + oracle_exit_status: ExitStatus, + }, +} + +impl ExitStatusComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!( + self, + Self::TestDiffers { + test_exit_status: _, + oracle_exit_status: _ + } + ) + } +} + +/// Comparison of string outputs (stdout/stderr). +pub enum StringComparison { + /// Output was ignored. + Ignored { + /// Output from the test shell. + test_string: String, + /// Output from the oracle shell. + oracle_string: String, + }, + /// Outputs match. + Same(String), + /// Outputs differ. + TestDiffers { + /// Output from the test shell. + test_string: String, + /// Output from the oracle shell. + oracle_string: String, + }, +} + +impl StringComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!( + self, + Self::TestDiffers { + test_string: _, + oracle_string: _ + } + ) + } +} + +/// A single entry in a directory comparison. +pub enum DirComparisonEntry { + /// File exists only in the left (oracle) directory. + LeftOnly(PathBuf), + /// File exists only in the right (test) directory. + RightOnly(PathBuf), + /// Files differ between directories. + Different(PathBuf, String, PathBuf, String), +} + +/// Comparison of directory contents. +pub enum DirComparison { + /// Directory comparison was ignored. + Ignored, + /// Directory contents match. + Same, + /// Directory contents differ. + TestDiffers(Vec), +} + +impl DirComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::TestDiffers(_)) + } +} + +/// Full comparison between oracle and test shell runs. +pub struct OracleComparison { + /// Comparison of exit statuses. + pub exit_status: ExitStatusComparison, + /// Comparison of stdout. + pub stdout: StringComparison, + /// Comparison of stderr. + pub stderr: StringComparison, + /// Comparison of temporary directory contents. + pub temp_dir: DirComparison, + /// Comparison of durations. + pub duration: DurationComparison, +} + +impl OracleComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + self.exit_status.is_failure() + || self.stdout.is_failure() + || self.stderr.is_failure() + || self.temp_dir.is_failure() + } + + /// Creates an ignored comparison (all fields ignored). + pub fn ignored() -> Self { + Self { + exit_status: ExitStatusComparison::Ignored, + stdout: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + stderr: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + temp_dir: DirComparison::Ignored, + duration: DurationComparison::default(), + } + } +} + +/// Comparison of a single expectation. +#[derive(Debug)] +pub enum SingleExpectationComparison { + /// Expectation was not specified (ignored). + NotSpecified, + /// Actual matches expected. + Matches, + /// Actual differs from expected. + Differs { + /// The expected value. + expected: String, + /// The actual value. + actual: String, + }, +} + +impl SingleExpectationComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Differs { .. }) + } +} + +/// Comparison against inline expectations. +#[derive(Debug)] +pub struct ExpectationComparison { + /// Comparison of exit code. + pub exit_code: SingleExpectationComparison, + /// Comparison of stdout. + pub stdout: SingleExpectationComparison, + /// Comparison of stderr. + pub stderr: SingleExpectationComparison, + /// Whether snapshot comparison was used. + pub snapshot_used: bool, + /// Snapshot comparison result (if used). + pub snapshot_result: Option, +} + +impl ExpectationComparison { + /// Creates an empty expectation comparison (all not specified). + pub const fn not_specified() -> Self { + Self { + exit_code: SingleExpectationComparison::NotSpecified, + stdout: SingleExpectationComparison::NotSpecified, + stderr: SingleExpectationComparison::NotSpecified, + snapshot_used: false, + snapshot_result: None, + } + } + + /// Returns whether this comparison indicates a failure. + pub fn is_failure(&self) -> bool { + self.exit_code.is_failure() + || self.stdout.is_failure() + || self.stderr.is_failure() + || self + .snapshot_result + .as_ref() + .is_some_and(|r| r.is_failure()) + } + + /// Returns whether any expectations were checked. + pub const fn has_any_checks(&self) -> bool { + !matches!(self.exit_code, SingleExpectationComparison::NotSpecified) + || !matches!(self.stdout, SingleExpectationComparison::NotSpecified) + || !matches!(self.stderr, SingleExpectationComparison::NotSpecified) + || self.snapshot_used + } +} + +/// Result of a snapshot comparison. +#[derive(Debug)] +pub enum SnapshotResult { + /// Snapshot matches. + Matches, + /// Snapshot differs (new snapshot created or update needed). + Differs { + /// Description of the difference. + message: String, + }, +} + +impl SnapshotResult { + /// Returns whether this result indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Differs { .. }) + } +} + +/// Combined test comparison result. +pub struct TestComparison { + /// Oracle comparison (if oracle mode was used). + pub oracle: Option, + /// Expectation comparison (if expectations were defined). + pub expectation: ExpectationComparison, + /// Duration of the test run. + pub duration: std::time::Duration, +} + +impl TestComparison { + /// Returns whether this comparison indicates a failure. + pub fn is_failure(&self) -> bool { + self.oracle.as_ref().is_some_and(|o| o.is_failure()) || self.expectation.is_failure() + } + + /// Creates a skipped comparison. + pub fn skipped() -> Self { + Self { + oracle: None, + expectation: ExpectationComparison::not_specified(), + duration: std::time::Duration::default(), + } + } +} + +/// Compares two strings, optionally ignoring whitespace. +pub fn output_matches(oracle: &str, test: &str, ignore_whitespace: bool) -> bool { + if ignore_whitespace { + let whitespace_re = regex::Regex::new(r"\s+").unwrap(); + + let cleaned_oracle = whitespace_re.replace_all(oracle, " ").to_string(); + let cleaned_test = whitespace_re.replace_all(test, " ").to_string(); + + cleaned_oracle == cleaned_test + } else { + oracle == test + } +} + +/// Compares directory contents between oracle and test. +pub fn diff_dirs( + oracle_path: &std::path::Path, + test_path: &std::path::Path, +) -> anyhow::Result { + use std::collections::HashMap; + use std::fs; + + fn get_dir_entries( + dir_path: &std::path::Path, + ) -> anyhow::Result> { + let mut entries = HashMap::new(); + for entry in fs::read_dir(dir_path)? { + let entry = entry?; + let file_type = entry.file_type()?; + let filename = entry.file_name().to_string_lossy().to_string(); + + // Ignore raw coverage profile data files. + if filename.ends_with(".profraw") { + continue; + } + + entries.insert(filename, file_type); + } + + Ok(entries) + } + + let mut entries = vec![]; + + let oracle_entries = get_dir_entries(oracle_path)?; + let test_entries = get_dir_entries(test_path)?; + + // Look through all the files in the oracle directory + for (filename, file_type) in &oracle_entries { + if !test_entries.contains_key(filename) { + for left_only_file in walkdir::WalkDir::new(oracle_path.join(filename)) { + let entry = left_only_file?; + let left_only_path = entry.path(); + entries.push(DirComparisonEntry::LeftOnly(left_only_path.to_owned())); + } + + continue; + } + + let oracle_file_path = oracle_path.join(filename); + let test_file_path = test_path.join(filename); + + if file_type.is_file() { + let mut oracle_file = std::fs::OpenOptions::new() + .read(true) + .open(&oracle_file_path)?; + let mut oracle_bytes = vec![]; + oracle_file.read_to_end(&mut oracle_bytes)?; + + let mut test_file = std::fs::OpenOptions::new() + .read(true) + .open(&test_file_path)?; + let mut test_bytes = vec![]; + test_file.read_to_end(&mut test_bytes)?; + + if oracle_bytes != test_bytes { + let oracle_display_text = String::from_utf8_lossy(&oracle_bytes); + let test_display_text = String::from_utf8_lossy(&test_bytes); + + entries.push(DirComparisonEntry::Different( + oracle_file_path, + oracle_display_text.to_string(), + test_file_path, + test_display_text.to_string(), + )); + } + } else if file_type.is_dir() { + let subdir_comparison = + diff_dirs(oracle_file_path.as_path(), test_file_path.as_path())?; + if let DirComparison::TestDiffers(subdir_entries) = subdir_comparison { + entries.extend(subdir_entries); + } + } + } + + for (filename, file_type) in &test_entries { + if oracle_entries.contains_key(filename) { + continue; + } + + if file_type.is_dir() { + for right_only_file in walkdir::WalkDir::new(test_path.join(filename)) { + let entry = right_only_file?; + let right_only_path = entry.path(); + entries.push(DirComparisonEntry::RightOnly(right_only_path.to_owned())); + } + } else { + entries.push(DirComparisonEntry::RightOnly(test_path.join(filename))); + } + } + + if entries.is_empty() { + Ok(DirComparison::Same) + } else { + Ok(DirComparison::TestDiffers(entries)) + } +} diff --git a/userland/brush/source/brush-test-harness/src/config.rs b/userland/brush/source/brush-test-harness/src/config.rs new file mode 100644 index 0000000..97a7d6e --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/config.rs @@ -0,0 +1,391 @@ +//! Configuration types for the test harness. + +use clap::Parser; +use std::{collections::HashSet, ffi::OsString, path::PathBuf}; + +/// Which shell to use for a test. +#[derive(Clone, Debug)] +pub enum WhichShell { + /// The shell under test (brush). + ShellUnderTest(PathBuf), + /// A named shell (e.g., bash, sh). + NamedShell(PathBuf), +} + +/// Configuration for a shell. +#[derive(Clone, Debug)] +pub struct ShellConfig { + /// Which shell this is. + pub which: WhichShell, + /// Default arguments to pass to this shell. + pub default_args: Vec, + /// Default PATH variable for this shell. + pub default_path_var: Option, + /// Optional launcher command to prepend (e.g., `["wasmtime", "run", "--"]` for wasm + /// targets). The first element is the program to execute; the rest are leading arguments + /// inserted before the shell binary path. + pub launcher: Option>, +} + +impl ShellConfig { + /// Computes the PATH variable to use for tests. + pub fn compute_test_path_var(&self) -> OsString { + let mut dirs = vec![]; + + // Start with any default we were provided. + if let Some(default_path_var) = &self.default_path_var { + dirs.extend(std::env::split_paths(default_path_var)); + } + + // Add hard-coded paths that will work on *most* Unix-like systems. + dirs.extend([ + "/usr/local/sbin".into(), + "/usr/local/bin".into(), + "/usr/sbin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/bin".into(), + ]); + + // Handle systems that store their standard POSIX binaries elsewhere. + // For example, NixOS has an interesting set of paths that must be consulted. + if let Some(host_path) = std::env::var_os("PATH") { + for path in std::env::split_paths(&host_path) { + if !dirs.contains(&path) && path.join("sh").is_file() { + dirs.push(path); + } + } + } + + std::env::join_paths(dirs).unwrap_or_else(|_| PathBuf::from("").into()) + } +} + +/// Configuration for the oracle shell (e.g., bash). +#[derive(Clone, Debug)] +pub struct OracleConfig { + /// Name of this oracle configuration (e.g., "bash", "sh"). + pub name: String, + /// Shell configuration for the oracle. + pub shell: ShellConfig, + /// Version string of the oracle. + pub version_str: Option, +} + +/// The mode in which to run tests. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TestMode { + /// Compare test shell output against an oracle shell. + #[default] + Oracle, + /// Validate against inline expectations or snapshots only. + Expectation, + /// Both oracle comparison and expectation validation. + Hybrid, +} + +/// Configuration for the test runner. +#[derive(Clone, Debug)] +pub struct RunnerConfig { + /// The test mode to use. + pub mode: TestMode, + /// Configuration for the oracle shell (if using oracle mode). + pub oracle: Option, + /// Configuration for the test shell (brush). + pub test_shell: ShellConfig, + /// Directory containing test case YAML files. + pub test_cases_dir: PathBuf, + /// Directory for storing snapshots (relative to test case YAML files). + pub snapshot_dir_name: String, + /// Host OS ID (for filtering incompatible tests). + pub host_os_id: Option, + /// Active runtime platform tags (e.g., "wasi", "wasm", or for a native + /// build, the host's OS: "linux", "macos", "windows"). Tests that declare + /// any of these in `incompatible_platforms` will be skipped. + pub platform_tags: HashSet, +} + +impl RunnerConfig { + /// Creates a new runner config with minimal safe defaults. + /// + /// N.B. Callers typically override `test_shell` via + /// `TestOptions::create_test_shell_config()`, which adds + /// platform-appropriate flags like `--input-backend=basic`. + pub fn new(test_shell_path: PathBuf, test_cases_dir: PathBuf) -> Self { + Self { + mode: TestMode::Expectation, + oracle: None, + test_shell: ShellConfig { + which: WhichShell::ShellUnderTest(test_shell_path), + default_args: vec![ + "--norc".into(), + "--noprofile".into(), + "--no-config".into(), + "--disable-bracketed-paste".into(), + "--disable-color".into(), + ], + default_path_var: None, + launcher: None, + }, + test_cases_dir, + snapshot_dir_name: String::from("snaps"), + host_os_id: crate::util::get_host_os_id(), + platform_tags: HashSet::new(), + } + } + + /// Sets the active runtime platform tags. + #[must_use] + pub fn with_platform_tags(mut self, tags: HashSet) -> Self { + self.platform_tags = tags; + self + } + + /// Sets the oracle configuration, enabling oracle comparison mode. + #[must_use] + pub fn with_oracle(mut self, oracle: OracleConfig) -> Self { + self.oracle = Some(oracle); + self.mode = TestMode::Oracle; + self + } + + /// Sets the test mode. + #[must_use] + pub const fn with_mode(mut self, mode: TestMode) -> Self { + self.mode = mode; + self + } + + /// Sets the snapshot directory name. + #[must_use] + pub fn with_snapshot_dir_name(mut self, name: impl Into) -> Self { + self.snapshot_dir_name = name.into(); + self + } + + /// Sets the default PATH variable for the test shell. + #[must_use] + pub fn with_test_path_var(mut self, path_var: Option) -> Self { + self.test_shell.default_path_var = path_var; + self + } +} + +/// Output format for test results. +#[derive(Clone, Copy, Default, clap::ValueEnum, Debug)] +pub enum OutputFormat { + /// Human-readable colored output. + #[default] + Pretty, + /// `JUnit` XML format. + Junit, + /// Minimal output. + Terse, +} + +/// Command-line options for the test harness. +#[derive(Clone, Parser, Debug)] +#[clap(version, about, disable_help_flag = true, disable_version_flag = true)] +pub struct TestOptions { + /// Display usage information. + #[clap(long = "help", action = clap::ArgAction::HelpLong)] + pub help: Option, + + /// Output format for test results. + #[clap(long = "format", default_value = "pretty")] + pub format: OutputFormat, + + /// Display full details on known failures. + #[clap(long = "known-failure-details")] + pub display_known_failure_details: bool, + + /// Display details regarding successful test cases. + #[clap(short = 'v', long = "verbose", env = "BRUSH_VERBOSE")] + pub verbose: bool, + + /// Enable a specific configuration. + #[clap(long = "enable-config")] + pub enabled_configs: Vec, + + /// List available tests without running them. + #[clap(long = "list")] + pub list_tests_only: bool, + + /// Exactly match filters (not just substring match). + #[clap(long = "exact")] + pub exact_match: bool, + + /// Optionally specify a non-default path for bash. + #[clap(long = "bash-path", default_value = "bash", env = "BASH_PATH")] + pub bash_path: PathBuf, + + /// Optionally specify a non-default path for brush. + #[clap(long = "brush-path", default_value = "", env = "BRUSH_PATH")] + pub brush_path: String, + + /// Optionally specify additional arguments for brush. + #[clap(long = "brush-args", default_value = "", env = "BRUSH_ARGS")] + pub brush_args: String, + + /// Optionally specify a launcher command to prepend when invoking brush + /// (e.g., "wasmtime run --" to execute a wasm build under wasmtime). + /// The string is split on whitespace; the first token becomes the program + /// to execute and the remainder are passed as leading arguments before + /// the brush binary path. + #[clap(long = "brush-launcher", default_value = "", env = "BRUSH_LAUNCHER")] + pub brush_launcher: String, + + /// Runtime platform tags (e.g., "wasi", "wasm") describing the + /// environment in which brush is being executed. Test cases that + /// declare any of these tags in `incompatible_platforms` will be + /// skipped. May be specified multiple times on the CLI or as a + /// space-separated value in the environment variable. When none are + /// given, brush runs natively, and its platform is the host's OS. + #[clap( + long = "brush-platform-tags", + value_delimiter = ' ', + env = "BRUSH_PLATFORM_TAGS" + )] + pub brush_platform_tags: Vec, + + /// Optionally specify path to test cases. + #[clap(long = "test-cases-path", env = "BRUSH_TEST_CASES")] + pub test_cases_path: Option, + + /// Optionally specify PATH variable to use in shells. + #[clap(long = "test-path-var", env = "BRUSH_TEST_PATH_VAR")] + pub test_path_var: Option, + + /// Show output from test cases (for compatibility only, has no effect). + #[clap(long = "show-output")] + pub show_output: bool, + + /// Capture output? (for compatibility only, has no effect). + #[clap(long = "nocapture")] + pub no_capture: bool, + + /// Colorize output? (for compatibility only, has no effect). + #[clap(long = "color", default_value_t = clap::ColorChoice::Auto)] + pub color: clap::ColorChoice, + + /// Run skipped tests only. + #[clap(long = "ignored")] + pub skipped_tests_only: bool, + + /// Unstable flags (for compatibility only, has no effect). + #[clap(short = 'Z')] + pub unstable_flag: Vec, + + /// Patterns for tests to be excluded. + #[clap(long = "skip")] + pub exclude_filters: Vec, + + /// Patterns for tests to be included. + pub include_filters: Vec, +} + +impl TestOptions { + /// Returns the configured platform tags as a set. With none configured, + /// brush runs natively, so its platform is the host's OS (e.g., "linux", + /// "macos", "windows"). + pub fn platform_tags(&self) -> HashSet { + if self.brush_platform_tags.is_empty() { + HashSet::from([std::env::consts::OS.to_owned()]) + } else { + self.brush_platform_tags.iter().cloned().collect() + } + } + + /// Builds the default `ShellConfig` for the shell under test based on + /// the common options (path, launcher, platform tags, extra args). + /// + /// Resolves the launcher binary to an absolute path (if one is + /// configured) because the test harness clears env vars — including + /// `PATH` — before spawning child processes. + pub fn create_test_shell_config(&self) -> anyhow::Result { + let mut default_args: Vec = vec![ + "--norc".into(), + "--noprofile".into(), + "--no-config".into(), + "--disable-bracketed-paste".into(), + "--disable-color".into(), + ]; + + // Use the basic input backend where brush offers it: natively, on Unix. + // WASI builds (run here under a runtime) and Windows builds don't, so + // asking for it would cause a startup error. Omitting it lets brush pick + // its own default (Minimal, when input isn't a terminal). + let tags = self.platform_tags(); + if !tags.contains("wasi") && !tags.contains("windows") { + default_args.push("--input-backend=basic".into()); + } + + // Append any additional brush args specified by the caller. + self.brush_args.split_whitespace().for_each(|arg| { + default_args.push(arg.into()); + }); + + let launcher = if self.brush_launcher.is_empty() { + None + } else { + let mut tokens: Vec = self + .brush_launcher + .split_whitespace() + .map(Into::into) + .collect(); + crate::util::resolve_launcher_path(&mut tokens)?; + Some(tokens) + }; + + Ok(ShellConfig { + which: WhichShell::ShellUnderTest(PathBuf::from(&self.brush_path)), + default_args, + default_path_var: self.test_path_var.clone(), + launcher, + }) + } + + /// Returns whether the given config name should be enabled. + pub fn should_enable_config(&self, config: &str, default_configs: &[&str]) -> bool { + let enabled_configs = if self.enabled_configs.is_empty() { + default_configs.iter().map(|s| String::from(*s)).collect() + } else { + self.enabled_configs.clone() + }; + + enabled_configs.contains(&config.to_string()) + } + + /// Returns whether a test should run based on include/exclude filters. + pub fn should_run_test(&self, qualified_name: &str) -> bool { + if self.include_filters.is_empty() && self.exclude_filters.is_empty() { + return true; + } + + // If any include filters were given, then we are in opt-in mode. + if !self.include_filters.is_empty() + && !self.test_matches_filters(qualified_name, &self.include_filters) + { + return false; + } + + // In all cases, exclude filters may be used to exclude tests. + if !self.exclude_filters.is_empty() + && self.test_matches_filters(qualified_name, &self.exclude_filters) + { + return false; + } + + true + } + + fn test_matches_filters(&self, qualified_test_name: &str, filters: &[String]) -> bool { + if self.exact_match { + filters.iter().any(|f| f == qualified_test_name) + } else { + filters + .iter() + .any(|filter| qualified_test_name.contains(filter)) + } + } +} diff --git a/userland/brush/source/brush-test-harness/src/execution.rs b/userland/brush/source/brush-test-harness/src/execution.rs new file mode 100644 index 0000000..f633e2b --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/execution.rs @@ -0,0 +1,348 @@ +//! Execution logic for running shell commands. + +use crate::config::{ShellConfig, WhichShell}; +use crate::testcase::{ShellInvocation, TestCase, TestCaseSet, TestFile}; +use anyhow::{Context, Result}; +use assert_fs::fixture::{FileWriteStr, PathChild}; +#[cfg(pty)] +use std::os::unix::process::ExitStatusExt; +#[cfg(unix)] +use std::os::unix::{fs::PermissionsExt, process::CommandExt}; +use std::{path::PathBuf, process::ExitStatus}; + +/// Default timeout for test commands in seconds. +pub const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + +/// Result of running a shell command. +#[derive(Debug)] +pub struct RunResult { + /// Exit status of the command. + pub exit_status: ExitStatus, + /// Standard output. + pub stdout: String, + /// Standard error. + pub stderr: String, + /// Duration of the command. + pub duration: std::time::Duration, +} + +impl TestCase { + /// Runs this test case with the given shell configuration. + pub fn run_shell( + &self, + shell_config: &ShellConfig, + working_dir: &assert_fs::TempDir, + ) -> Result { + let test_cmd = self.create_command_for_shell(shell_config, working_dir); + + let result = if self.pty { + self.run_command_with_pty(test_cmd)? + } else { + self.run_command_with_stdin(test_cmd)? + }; + + Ok(result) + } + + /// Creates the test files in the given temporary directory. + pub fn create_test_files_in( + &self, + temp_dir: &assert_fs::TempDir, + test_case_set: &TestCaseSet, + ) -> Result<()> { + for test_file in test_case_set + .common_test_files + .iter() + .chain(self.test_files.iter()) + { + Self::create_test_file(temp_dir, test_file, &test_case_set.source_dir)?; + } + + Ok(()) + } + + fn create_test_file( + temp_dir: &assert_fs::TempDir, + test_file: &TestFile, + source_dir: &std::path::Path, + ) -> Result<()> { + let test_file_path = temp_dir.child(test_file.path.as_path()); + + if let Some(source_path) = &test_file.source_path { + if !test_file.contents.is_empty() { + return Err(anyhow::anyhow!( + "test file {} has both contents and source_path", + test_file_path.to_string_lossy() + )); + } + + if source_path.is_absolute() { + return Err(anyhow::anyhow!( + "source_path {} is not a relative path", + source_path.to_string_lossy() + )); + } + + let abs_source_path = source_dir.join(source_path); + + let source_contents = std::fs::read_to_string(&abs_source_path) + .with_context(|| format!("reading {}", abs_source_path.to_string_lossy()))?; + + test_file_path.write_str(source_contents.as_str())?; + } else { + test_file_path.write_str(test_file.contents.as_str())?; + } + + #[cfg(unix)] + if test_file.executable { + // chmod u+x + let mut perms = test_file_path.metadata()?.permissions(); + perms.set_mode(perms.mode() | 0o100); + std::fs::set_permissions(test_file_path, perms)?; + } + + Ok(()) + } + + /// Constructs a `Command` to invoke the given shell binary, optionally + /// prepending a launcher (e.g., `["wasmtime", "run", "--"]`). When a + /// launcher is provided, the first element becomes the program to execute + /// and the rest are passed as leading arguments before the shell binary path. + fn new_shell_command( + shell_path: &std::path::Path, + launcher: Option<&[String]>, + ) -> std::process::Command { + if let Some([program, leading_args @ ..]) = launcher { + let mut cmd = std::process::Command::new(program); + cmd.args(leading_args); + cmd.arg(shell_path); + cmd + } else { + std::process::Command::new(shell_path) + } + } + + fn create_command_for_shell( + &self, + shell_config: &ShellConfig, + working_dir: &assert_fs::TempDir, + ) -> std::process::Command { + let (mut test_cmd, coverage_target_dir) = match self.invocation { + ShellInvocation::ExecShellBinary => match &shell_config.which { + WhichShell::ShellUnderTest(name) => { + let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let default_target_dir = || cli_dir.parent().unwrap().join("target"); + let target_dir = std::env::var("CARGO_TARGET_DIR") + .ok() + .map_or_else(default_target_dir, PathBuf::from); + ( + Self::new_shell_command(name, shell_config.launcher.as_deref()), + Some(target_dir), + ) + } + // Launcher only applies to the shell under test; the oracle is invoked directly. + WhichShell::NamedShell(name) => (Self::new_shell_command(name, None), None), + }, + ShellInvocation::ExecScript(_) => unimplemented!("exec script test"), + }; + + if matches!(shell_config.which, WhichShell::ShellUnderTest(_)) { + for arg in &self.additional_test_args { + test_cmd.arg(arg); + } + } + + for arg in &shell_config.default_args { + if !self.removed_default_args.contains(arg) { + test_cmd.arg(arg); + } + } + + // Clear all environment vars for consistency. + test_cmd.args(&self.args).env_clear(); + + // Set locale to C for consistent behavior across systems. + test_cmd.env("LC_ALL", "C"); + // Hard-code a well known prompt for PS1. + test_cmd.env("PS1", "test$ "); + // Try to get decent backtraces when problems get hit. + test_cmd.env("RUST_BACKTRACE", "1"); + // Compute a PATH that contains what we need. + test_cmd.env("PATH", shell_config.compute_test_path_var()); + + // Set up any env vars needed for collecting coverage data. + if let Some(coverage_target_dir) = &coverage_target_dir { + test_cmd.env("CARGO_LLVM_COV_TARGET_DIR", coverage_target_dir); + test_cmd.env( + "LLVM_PROFILE_FILE", + coverage_target_dir.join("brush-%p-%40m.profraw"), + ); + } + + for (k, v) in &self.env { + test_cmd.env(k, v); + } + + if let Some(home_dir) = &self.home_dir { + let abs_home_dir = if home_dir.is_relative() { + working_dir.join(home_dir) + } else { + home_dir.to_owned() + }; + + test_cmd.env("HOME", abs_home_dir.to_string_lossy().to_string()); + } + + test_cmd.current_dir(working_dir.to_string_lossy().to_string()); + + test_cmd + } + + // Must keep the same signature as the `cfg(pty)` arm below, which does use `self`. + #[cfg(not(pty))] + #[expect(clippy::unused_self, reason = "signature must match the cfg(pty) arm")] + fn run_command_with_pty(&self, _cmd: std::process::Command) -> Result { + Err(anyhow::anyhow!("pty test not supported on this platform")) + } + + #[cfg(pty)] + fn run_command_with_pty(&self, cmd: std::process::Command) -> Result { + use crate::util::{make_expectrl_output_readable, read_expectrl_log}; + use expectrl::{Expect, process::Termios as _}; + + let mut log = Vec::new(); + let writer = std::io::Cursor::new(&mut log); + + let start_time = std::time::Instant::now(); + let mut p = expectrl::session::log(expectrl::Session::spawn(cmd)?, writer)?; + p.set_echo(true)?; + if let Some(timeout) = self.timeout_in_seconds { + p.set_expect_timeout(Some(std::time::Duration::from_secs(timeout))); + } + + if let Some(stdin) = &self.stdin { + for line in stdin.lines() { + if let Some(expectation) = line.strip_prefix("#expect:") { + if let Err(inner) = p.expect(expectation) { + return Ok(RunResult { + exit_status: exited_with(1), + stdout: read_expectrl_log(log) + .map(make_expectrl_output_readable) + .unwrap_or_default(), + stderr: std::format!("failed to expect '{expectation}': {inner}"), + duration: start_time.elapsed(), + }); + } + } else if let Some(control_code) = line.strip_prefix("#send:") { + match control_code.to_lowercase().as_str() { + "ctrl+c" => p.send(expectrl::ControlCode::EndOfText)?, + "ctrl+d" => p.send(expectrl::ControlCode::EndOfTransmission)?, + "tab" => p.send(expectrl::ControlCode::HorizontalTabulation)?, + "enter" => p.send(expectrl::ControlCode::LineFeed)?, + _ => (), + } + } else if line.trim() == "#expect-prompt" { + if let Err(inner) = p.expect("test$ ") { + return Ok(RunResult { + exit_status: exited_with(1), + stdout: read_expectrl_log(log) + .map(make_expectrl_output_readable) + .unwrap_or_default(), + stderr: std::format!("failed to expect prompt: {inner}"), + duration: start_time.elapsed(), + }); + } + } else { + p.send(line)?; + } + } + } + + if let Err(inner) = p.expect(expectrl::Eof) { + return Ok(RunResult { + exit_status: exited_with(1), + stdout: read_expectrl_log(log) + .map(make_expectrl_output_readable) + .unwrap_or_default(), + stderr: std::format!("failed to expect EOF: {inner}"), + duration: start_time.elapsed(), + }); + } + + let mut wait_status = p.get_process().status()?; + + if matches!(wait_status, expectrl::process::unix::WaitStatus::StillAlive) { + // Try to terminate it safely. + p.get_process_mut() + .kill(expectrl::process::unix::Signal::SIGTERM)?; + wait_status = p.get_process().wait()?; + } + + let duration = start_time.elapsed(); + let output = read_expectrl_log(log)?; + let cleaned = make_expectrl_output_readable(output); + + match wait_status { + expectrl::process::unix::WaitStatus::Exited(_, code) => Ok(RunResult { + exit_status: exited_with(code), + stdout: cleaned, + stderr: String::new(), + duration, + }), + expectrl::process::unix::WaitStatus::Signaled(_, _, _) => { + Err(anyhow::anyhow!("process was signaled")) + } + _ => Err(anyhow::anyhow!( + "unexpected status for process: {wait_status:?}" + )), + } + } + + #[allow(unused_mut, reason = "only mutated on some platforms")] + fn run_command_with_stdin(&self, mut cmd: std::process::Command) -> Result { + // SAFETY: + // To avoid bash trying to directly access /dev/tty and generate tty-related signals, + // we create a new session for the child process. The standard library has a setsid() + // API but it's unstable, so we use nix here. Calling pre_exec can be unsafe as + // it runs in the child process after fork() but before exec(), and there are constraints + // around what can be safely done in that context. However, calling setsid() is generally + // considered safe as it doesn't allocate memory or perform complex operations to forked + // state. + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + let _ = nix::unistd::setsid(); + Ok(()) + }) + }; + + let mut test_cmd = assert_cmd::Command::from_std(cmd); + + test_cmd.timeout(std::time::Duration::from_secs( + self.timeout_in_seconds + .unwrap_or(DEFAULT_TIMEOUT_IN_SECONDS), + )); + + if let Some(stdin) = &self.stdin { + test_cmd.write_stdin(stdin.as_bytes()); + } + + let start_time = std::time::Instant::now(); + let cmd_result = test_cmd.output()?; + let duration = start_time.elapsed(); + + Ok(RunResult { + exit_status: cmd_result.status, + stdout: String::from_utf8_lossy(cmd_result.stdout.as_slice()).to_string(), + stderr: String::from_utf8_lossy(cmd_result.stderr.as_slice()).to_string(), + duration, + }) + } +} + +/// Returns the status of a process that exited with the given code. +#[cfg(pty)] +fn exited_with(code: i32) -> ExitStatus { + // (A raw wait status holds a process's exit code above its low byte.) + ExitStatus::from_raw((code & 0xff) << 8) +} diff --git a/userland/brush/source/brush-test-harness/src/lib.rs b/userland/brush/source/brush-test-harness/src/lib.rs new file mode 100644 index 0000000..de875c8 --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/lib.rs @@ -0,0 +1,40 @@ +//! Test harness library for brush shell integration tests. +//! +//! This crate provides a unified framework for running YAML-based integration tests +//! that support both oracle-based comparison (comparing brush output against bash) +//! and expectation-based testing (inline expectations or insta snapshots). +//! +//! # Modes of Operation +//! +//! 1. **Oracle comparison**: Runs both an oracle shell (e.g., bash) and the test shell (brush), +//! comparing their outputs. This is the traditional compatibility testing mode. +//! +//! 2. **Expectation-based**: Runs only the test shell and compares against inline expectations +//! specified in the YAML or against insta snapshots. +//! +//! 3. **Hybrid**: Combines both modes - runs oracle comparison AND validates against expectations. +//! Both must pass for the test to succeed. + +#![cfg(any(unix, windows))] +#![allow(clippy::missing_panics_doc)] +#![allow(clippy::unwrap_used)] + +mod comparison; +mod config; +mod execution; +mod reporting; +mod runner; +mod testcase; +pub mod util; + +pub use comparison::{ + DirComparison, DirComparisonEntry, DurationComparison, ExitStatusComparison, + ExpectationComparison, OracleComparison, SingleExpectationComparison, SnapshotResult, + StringComparison, TestComparison, +}; +pub use config::{ + OracleConfig, OutputFormat, RunnerConfig, ShellConfig, TestMode, TestOptions, WhichShell, +}; +pub use execution::RunResult; +pub use runner::TestRunner; +pub use testcase::{ShellInvocation, TestCase, TestCaseSet, TestFile}; diff --git a/userland/brush/source/brush-test-harness/src/reporting.rs b/userland/brush/source/brush-test-harness/src/reporting.rs new file mode 100644 index 0000000..ba2608f --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/reporting.rs @@ -0,0 +1,524 @@ +//! Reporting utilities for test results. + +use crate::comparison::{ + DirComparison, DirComparisonEntry, ExitStatusComparison, ExpectationComparison, + OracleComparison, SingleExpectationComparison, StringComparison, TestComparison, +}; +use crate::config::{OutputFormat, TestOptions}; +use crate::util::{make_expectrl_output_readable, write_diff}; +use anyhow::Result; +use colored::Colorize; +use std::io::Write; + +/// Result of running a single test case. +pub struct TestCaseResult { + /// Name of the test case. + pub name: Option, + /// Whether the test succeeded. + pub success: bool, + /// Whether the test was skipped. + pub skip: bool, + /// Whether this is a known failure. + pub known_failure: bool, + /// The comparison result. + pub comparison: TestComparison, +} + +impl TestCaseResult { + /// Reports this result in pretty format. + pub fn report_pretty(&self, options: &TestOptions) -> Result<()> { + self.write_details(std::io::stderr(), options) + } + + /// Writes the details of this result to a writer. + pub fn write_details(&self, mut writer: W, options: &TestOptions) -> Result<()> { + if self.skip { + return Ok(()); + } + + if !options.verbose { + if (!self.comparison.is_failure() && !self.known_failure) + || (self.comparison.is_failure() && self.known_failure) + { + return Ok(()); + } + } + + write!( + writer, + "* {}: [{}]... ", + "Test case".bright_yellow(), + self.name + .as_ref() + .map_or_else(|| "(unnamed)", |n| n.as_str()) + .italic() + )?; + + if !self.comparison.is_failure() { + if self.known_failure { + writeln!(writer, "{}", "unexpected success.".bright_red())?; + } else { + writeln!(writer, "{}", "ok.".bright_green())?; + return Ok(()); + } + } else if self.known_failure { + writeln!(writer, "{}", "known failure.".bright_magenta())?; + if !options.display_known_failure_details { + return Ok(()); + } + } + + writeln!(writer)?; + + // Report oracle comparison if present + if let Some(oracle) = &self.comparison.oracle { + self.write_oracle_details(&mut writer, oracle, options)?; + } + + // Report expectation comparison + if self.comparison.expectation.has_any_checks() { + self.write_expectation_details(&mut writer, &self.comparison.expectation)?; + } + + if !self.success { + writeln!(writer, " {}", "FAILED.".bright_red())?; + } + + Ok(()) + } + + #[expect(clippy::too_many_lines)] + #[expect(clippy::unused_self)] + fn write_oracle_details( + &self, + writer: &mut W, + oracle: &OracleComparison, + options: &TestOptions, + ) -> Result<()> { + writeln!(writer, " {} comparison:", "Oracle".cyan())?; + + match oracle.exit_status { + ExitStatusComparison::Ignored => writeln!(writer, " status {}", "ignored".cyan())?, + ExitStatusComparison::Same(status) => { + writeln!( + writer, + " status matches ({}) {}", + format!("{status}").green(), + "✔️".green() + )?; + } + ExitStatusComparison::TestDiffers { + test_exit_status, + oracle_exit_status, + } => { + writeln!( + writer, + " status mismatch: {} from oracle vs. {} from test", + format!("{oracle_exit_status}").cyan(), + format!("{test_exit_status}").bright_red() + )?; + } + } + + match &oracle.stdout { + StringComparison::Ignored { + test_string, + oracle_string, + } => { + writeln!(writer, " stdout {}", "ignored".cyan())?; + + writeln!( + writer, + " {}", + "------ Oracle: stdout ---------------------------------".cyan() + )?; + writeln!(writer, "{}", indent::indent_all_by(10, oracle_string))?; + + writeln!( + writer, + " {}", + "------ Oracle: stdout [cleaned]------------------------".cyan() + )?; + writeln!( + writer, + "{}", + indent::indent_all_by(10, make_expectrl_output_readable(oracle_string)) + )?; + + writeln!( + writer, + " {}", + "------ Test: stdout ---------------------------------".cyan() + )?; + writeln!(writer, "{}", indent::indent_all_by(10, test_string))?; + + writeln!( + writer, + " {}", + "------ Test: stdout [cleaned]------------------------".cyan() + )?; + + writeln!( + writer, + "{}", + indent::indent_all_by(10, make_expectrl_output_readable(test_string)) + )?; + } + StringComparison::Same(s) => { + writeln!(writer, " stdout matches {}", "✔️".green())?; + + if options.verbose { + writeln!( + writer, + " {}", + "------ Oracle <> Test: stdout ---------------------------------".cyan() + )?; + + writeln!(writer, "{}", indent::indent_all_by(10, s))?; + } + } + StringComparison::TestDiffers { + test_string: t, + oracle_string: o, + } => { + writeln!(writer, " stdout {}", "DIFFERS:".bright_red())?; + + writeln!( + writer, + " {}", + "------ Oracle <> Test: stdout ---------------------------------".cyan() + )?; + + write_diff(writer, 10, o.as_str(), t.as_str())?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------".cyan() + )?; + } + } + + match &oracle.stderr { + StringComparison::Ignored { .. } => { + writeln!(writer, " stderr {}", "ignored".cyan())?; + } + StringComparison::Same(s) => { + writeln!(writer, " stderr matches {}", "✔️".green())?; + + if options.verbose { + writeln!( + writer, + " {}", + "------ Oracle <> Test: stderr ---------------------------------".cyan() + )?; + + writeln!(writer, "{}", indent::indent_all_by(10, s))?; + } + } + StringComparison::TestDiffers { + test_string: t, + oracle_string: o, + } => { + writeln!(writer, " stderr {}", "DIFFERS:".bright_red())?; + + writeln!( + writer, + " {}", + "------ Oracle <> Test: stderr ---------------------------------".cyan() + )?; + + write_diff(writer, 10, o.as_str(), t.as_str())?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------".cyan() + )?; + } + } + + match &oracle.temp_dir { + DirComparison::Ignored => writeln!(writer, " temp dir {}", "ignored".cyan())?, + DirComparison::Same => writeln!(writer, " temp dir matches {}", "✔️".green())?, + DirComparison::TestDiffers(entries) => { + writeln!(writer, " temp dir {}", "DIFFERS".bright_red())?; + + for entry in entries { + const INDENT: &str = " "; + match entry { + DirComparisonEntry::Different( + left_path, + left_contents, + right_path, + right_contents, + ) => { + writeln!( + writer, + "{INDENT}oracle file {} differs from test file {}", + left_path.to_string_lossy(), + right_path.to_string_lossy() + )?; + + writeln!( + writer, + "{INDENT}{}", + "------ Oracle <> Test: file ---------------------------------" + .cyan() + )?; + + write_diff( + writer, + 10, + left_contents.as_str(), + right_contents.as_str(), + )?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------" + .cyan() + )?; + } + DirComparisonEntry::LeftOnly(p) => { + writeln!( + writer, + "{INDENT}file missing from test dir: {}", + p.to_string_lossy() + )?; + } + DirComparisonEntry::RightOnly(p) => { + writeln!( + writer, + "{INDENT}unexpected file in test dir: {}", + p.to_string_lossy() + )?; + } + } + } + } + } + + Ok(()) + } + + #[expect(clippy::unused_self)] + fn write_expectation_details( + &self, + writer: &mut W, + expectation: &ExpectationComparison, + ) -> Result<()> { + writeln!(writer, " {} check:", "Expectation".cyan())?; + + // Exit code + match &expectation.exit_code { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " exit code matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!( + writer, + " exit code {}: expected {}, got {}", + "DIFFERS".bright_red(), + expected.cyan(), + actual.bright_red() + )?; + } + } + + // Stdout + match &expectation.stdout { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " stdout matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!(writer, " stdout {}", "DIFFERS:".bright_red())?; + write_diff(writer, 8, expected.as_str(), actual.as_str())?; + } + } + + // Stderr + match &expectation.stderr { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " stderr matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!(writer, " stderr {}", "DIFFERS:".bright_red())?; + write_diff(writer, 8, expected.as_str(), actual.as_str())?; + } + } + + // Snapshot + if expectation.snapshot_used { + if let Some(result) = &expectation.snapshot_result { + match result { + crate::comparison::SnapshotResult::Matches => { + writeln!(writer, " snapshot matches {}", "✔️".green())?; + } + crate::comparison::SnapshotResult::Differs { message } => { + writeln!( + writer, + " snapshot {}: {}", + "DIFFERS".bright_red(), + message + )?; + } + } + } + } + + Ok(()) + } +} + +/// Results from running a set of test cases. +pub struct TestCaseSetResults { + /// Name of the test case set. + pub name: Option, + /// Name of the configuration used. + pub config_name: String, + /// Number of successful tests. + pub success_count: u32, + /// Number of skipped tests. + pub skip_count: u32, + /// Number of known failures. + pub known_failure_count: u32, + /// Number of failed tests. + pub fail_count: u32, + /// Individual test case results. + pub test_case_results: Vec, + /// Total duration comparison for successful tests. + pub success_duration: std::time::Duration, +} + +impl TestCaseSetResults { + /// Reports these results in pretty format. + pub fn report_pretty(&self, options: &TestOptions) -> Result<()> { + self.write_details(std::io::stderr(), options) + } + + fn write_details(&self, mut writer: W, options: &TestOptions) -> Result<()> { + if options.verbose { + writeln!( + writer, + "=================== {}: [{}/{}] ===================", + "Running test case set".blue(), + self.name + .as_ref() + .map_or_else(|| "(unnamed)", |n| n.as_str()) + .italic(), + self.config_name.magenta(), + )?; + } + + for test_case_result in &self.test_case_results { + test_case_result.report_pretty(options)?; + } + + if options.verbose { + writeln!( + writer, + " successful cases ran in {:?}", + self.success_duration + )?; + } + + Ok(()) + } +} + +/// Reports test results based on the configured output format. +pub fn report_results(results: Vec, options: &TestOptions) -> Result<()> { + match options.format { + OutputFormat::Pretty => report_results_pretty(results, options), + OutputFormat::Junit => report_results_junit(results, options), + OutputFormat::Terse => Ok(()), + } +} + +fn report_results_pretty(results: Vec, options: &TestOptions) -> Result<()> { + for result in results { + result.report_pretty(options)?; + } + Ok(()) +} + +fn report_results_junit(results: Vec, options: &TestOptions) -> Result<()> { + let mut report = junit_report::Report::new(); + + for result in results { + let mut suite = junit_report::TestSuite::new(result.name.unwrap_or(String::new()).as_str()); + for r in result.test_case_results { + let test_case_name = r.name.as_deref().unwrap_or(""); + let mut test_case: junit_report::TestCase = if r.success { + junit_report::TestCase::success(test_case_name, r.comparison.duration.try_into()?) + } else if r.known_failure { + junit_report::TestCase::skipped(test_case_name) + } else { + junit_report::TestCase::failure( + test_case_name, + r.comparison.duration.try_into()?, + "test failure", + "failed", + ) + }; + + let mut output_buf: Vec = vec![]; + r.write_details(&mut output_buf, options)?; + + let output_as_string = String::from_utf8(output_buf)?; + test_case.set_system_out(strip_ansi_escapes::strip_str(output_as_string).as_str()); + + suite.add_testcase(test_case); + } + + report.add_testsuite(suite); + } + + report.write_xml(std::io::stdout())?; + writeln!(std::io::stdout())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::comparison::{ExpectationComparison, TestComparison}; + use clap::Parser as _; + + #[test] + fn reports_pty_transcripts_holding_backslashes() { + // A failing case's pty transcripts, which are reported even when ignored, may hold + // backslashes that don't start escapes. + let transcript = "test$ echo 'a\\q\\$b'\n"; + let mut oracle = OracleComparison::ignored(); + oracle.stdout = StringComparison::Ignored { + test_string: transcript.into(), + oracle_string: transcript.into(), + }; + oracle.temp_dir = DirComparison::TestDiffers(vec![]); + let result = TestCaseResult { + name: Some("case".into()), + success: false, + skip: false, + known_failure: false, + comparison: TestComparison { + oracle: Some(oracle), + expectation: ExpectationComparison::not_specified(), + duration: std::time::Duration::default(), + }, + }; + + let mut report = Vec::new(); + result + .write_details(&mut report, &TestOptions::parse_from(["test"])) + .unwrap(); + let report = String::from_utf8(report).unwrap(); + assert!(report.contains("a\\q\\$b"), "unexpected report: {report}"); + } +} diff --git a/userland/brush/source/brush-test-harness/src/runner.rs b/userland/brush/source/brush-test-harness/src/runner.rs new file mode 100644 index 0000000..019e7ad --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/runner.rs @@ -0,0 +1,712 @@ +//! Test runner implementation. + +use crate::comparison::{ + DirComparison, DurationComparison, ExitStatusComparison, ExpectationComparison, + OracleComparison, SingleExpectationComparison, SnapshotResult, StringComparison, + TestComparison, diff_dirs, output_matches, +}; +use crate::config::{RunnerConfig, TestMode, TestOptions}; +use crate::reporting::{TestCaseResult, TestCaseSetResults}; +use crate::testcase::{TestCase, TestCaseSet}; +use anyhow::{Context, Result}; +use colored::Colorize; + +/// The main test runner. +pub struct TestRunner { + config: RunnerConfig, + options: TestOptions, +} + +impl TestRunner { + /// Creates a new test runner with the given configuration and options. + pub const fn new(config: RunnerConfig, options: TestOptions) -> Self { + Self { config, options } + } + + /// Runs all tests and returns success/failure. + pub async fn run(&self) -> Result { + let mut success_count = 0; + let mut skip_count = 0; + let mut known_failure_count = 0; + let mut fail_count = 0; + let mut join_handles = vec![]; + let mut success_duration = std::time::Duration::default(); + + // Generate a glob pattern to find all the YAML test case files. + let glob_pattern = self + .config + .test_cases_dir + .join("**/*.yaml") + .to_string_lossy() + .to_string(); + + if self.options.verbose { + eprintln!("Running test cases: {glob_pattern}"); + } + + // Spawn each test case set separately. + for entry in glob::glob(glob_pattern.as_ref()).unwrap() { + let entry = entry.unwrap(); + + let yaml_file = std::fs::File::open(entry.as_path())?; + let mut test_case_set: TestCaseSet = yaml_serde::from_reader(yaml_file) + .context(format!("parsing {}", entry.to_string_lossy()))?; + + test_case_set.source_dir = entry.parent().unwrap().to_path_buf(); + test_case_set.source_file.clone_from(&entry); + + if self.options.list_tests_only { + for test_case in &test_case_set.cases { + let case_is_skipped = self.should_skip_test(&test_case_set, test_case)?; + if case_is_skipped == self.options.skipped_tests_only { + println!( + "{}::{}: test", + test_case_set.name.as_deref().unwrap_or("unnamed"), + test_case.name.as_deref().unwrap_or("unnamed"), + ); + } + } + } else { + let config = self.config.clone(); + let options = self.options.clone(); + + join_handles.push(tokio::spawn(async move { + run_test_case_set(&test_case_set, config, &options) + })); + } + } + + if self.options.list_tests_only { + return Ok(true); + } + + // Await all results. + let mut all_results = vec![]; + for join_handle in join_handles { + let results = join_handle.await??; + + success_count += results.success_count; + skip_count += results.skip_count; + known_failure_count += results.known_failure_count; + fail_count += results.fail_count; + success_duration += results.success_duration; + + all_results.push(results); + } + + crate::reporting::report_results(all_results, &self.options)?; + + if matches!(self.options.format, crate::config::OutputFormat::Pretty) { + let formatted_fail_count = if fail_count > 0 { + fail_count.to_string().red() + } else { + fail_count.to_string().green() + }; + + let formatted_known_failure_count = if known_failure_count > 0 { + known_failure_count.to_string().magenta() + } else { + known_failure_count.to_string().green() + }; + + let formatted_skip_count = if skip_count > 0 { + skip_count.to_string().cyan() + } else { + skip_count.to_string().green() + }; + + eprintln!( + "================================================================================" + ); + eprintln!( + "{} test case(s) ran: {} succeeded, {} failed, {} known to fail, {} skipped.", + success_count + fail_count + known_failure_count, + success_count.to_string().green(), + formatted_fail_count, + formatted_known_failure_count, + formatted_skip_count, + ); + eprintln!("duration of successful tests: {success_duration:?}"); + eprintln!( + "================================================================================" + ); + } + + Ok(fail_count == 0) + } + + fn should_skip_test(&self, test_case_set: &TestCaseSet, test_case: &TestCase) -> Result { + // Check incompatible configs (set-level and per-case). + if let Some(oracle) = &self.config.oracle { + if test_case_set.incompatible_configs.contains(&oracle.name) + || test_case.incompatible_configs.contains(&oracle.name) + { + return Ok(true); + } + } + + // Check incompatible OS + if let Some(host_os_id) = &self.config.host_os_id { + if test_case.incompatible_os.contains(host_os_id) { + return Ok(true); + } + } + + // Check incompatible runtime platforms (set-level and per-case). + if test_case_set + .incompatible_platforms + .iter() + .chain(test_case.incompatible_platforms.iter()) + .any(|p| self.config.platform_tags.contains(p)) + { + return Ok(true); + } + + // Check oracle version constraints + if let Some(oracle) = &self.config.oracle { + if test_case.min_oracle_version.is_some() || test_case.max_oracle_version.is_some() { + if let Some(actual_oracle_version_str) = &oracle.version_str { + let actual_oracle_version = + version_compare::Version::from(actual_oracle_version_str.as_str()) + .ok_or_else(|| anyhow::anyhow!("failed to parse oracle version"))?; + + if let Some(min_oracle_version_str) = &test_case.min_oracle_version { + let min_oracle_version = version_compare::Version::from( + min_oracle_version_str, + ) + .ok_or_else(|| anyhow::anyhow!("failed to parse min oracle version"))?; + + if matches!( + actual_oracle_version.compare(min_oracle_version), + version_compare::Cmp::Lt + ) { + return Ok(true); + } + } + + if let Some(max_oracle_version_str) = &test_case.max_oracle_version { + let max_oracle_version = version_compare::Version::from( + max_oracle_version_str, + ) + .ok_or_else(|| anyhow::anyhow!("failed to parse max oracle version"))?; + + if matches!( + actual_oracle_version.compare(max_oracle_version), + version_compare::Cmp::Gt + ) { + return Ok(true); + } + } + } + } + } + + // Check filters + let test_case_set_name = test_case_set.name.as_deref().unwrap_or(""); + let test_case_name = test_case.name.as_deref().unwrap_or(""); + + if test_case_set_name.is_empty() || test_case_name.is_empty() { + return Ok(false); + } + + let qualified_name = format!("{test_case_set_name}::{test_case_name}"); + if !self.options.should_run_test(&qualified_name) { + return Ok(true); + } + + Ok(test_case.skip) + } +} + +fn run_test_case_set( + test_case_set: &TestCaseSet, + config: RunnerConfig, + options: &TestOptions, +) -> Result { + let mut success_count = 0; + let mut skip_count = 0; + let mut known_failure_count = 0; + let mut fail_count = 0; + let mut success_duration = std::time::Duration::default(); + let mut test_case_results = vec![]; + + for test_case in &test_case_set.cases { + let runner = TestRunner::new(config.clone(), options.clone()); + let case_is_skipped = runner.should_skip_test(test_case_set, test_case)?; + let known_failure = test_case.is_known_failure(&config.platform_tags); + + let test_case_result = if case_is_skipped == options.skipped_tests_only { + run_single_test(test_case_set, test_case, &config)? + } else { + TestCaseResult { + success: true, + comparison: TestComparison::skipped(), + name: test_case.name.clone(), + skip: true, + known_failure, + } + }; + + if test_case_result.skip { + skip_count += 1; + } else if test_case_result.success { + if known_failure { + fail_count += 1; + } else { + success_count += 1; + success_duration += test_case_result.comparison.duration; + } + } else if known_failure { + known_failure_count += 1; + } else { + fail_count += 1; + } + + test_case_results.push(test_case_result); + } + + let config_name = config + .oracle + .map_or_else(|| String::from("brush"), |o| o.name); + + Ok(TestCaseSetResults { + name: test_case_set.name.clone(), + config_name, + test_case_results, + success_count, + skip_count, + known_failure_count, + fail_count, + success_duration, + }) +} + +fn run_single_test( + test_case_set: &TestCaseSet, + test_case: &TestCase, + config: &RunnerConfig, +) -> Result { + let start_time = std::time::Instant::now(); + + // Determine what comparisons to perform + let should_run_oracle = config.oracle.is_some() + && !test_case.skip_oracle + && matches!(config.mode, TestMode::Oracle | TestMode::Hybrid); + + let should_check_expectations = test_case.has_expectations() + || matches!(config.mode, TestMode::Expectation | TestMode::Hybrid); + + // Run oracle comparison if needed + let (oracle_comparison, test_result, test_temp_dir) = if should_run_oracle { + let oracle_config = config.oracle.as_ref().unwrap(); + let (oracle_comp, test_res) = + run_oracle_comparison(test_case_set, test_case, config, oracle_config)?; + (Some(oracle_comp), Some(test_res), None) + } else { + // Run test shell only + let test_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&test_temp_dir, test_case_set)?; + let test_res = test_case.run_shell(&config.test_shell, &test_temp_dir)?; + (None, Some(test_res), Some(test_temp_dir)) + }; + + // Check expectations + let expectation_comparison = if should_check_expectations { + if let Some(test_res) = &test_result { + check_expectations( + test_case_set, + test_case, + test_res, + test_temp_dir.as_ref(), + config, + ) + } else { + ExpectationComparison::not_specified() + } + } else { + ExpectationComparison::not_specified() + }; + + let duration = start_time.elapsed(); + + let comparison = TestComparison { + oracle: oracle_comparison, + expectation: expectation_comparison, + duration, + }; + + let success = !comparison.is_failure(); + + Ok(TestCaseResult { + success, + comparison, + name: test_case.name.clone(), + skip: false, + known_failure: test_case.is_known_failure(&config.platform_tags), + }) +} + +fn run_oracle_comparison( + test_case_set: &TestCaseSet, + test_case: &TestCase, + config: &RunnerConfig, + oracle_config: &crate::config::OracleConfig, +) -> Result<(OracleComparison, crate::execution::RunResult)> { + // Run oracle + let oracle_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&oracle_temp_dir, test_case_set)?; + let oracle_result = test_case.run_shell(&oracle_config.shell, &oracle_temp_dir)?; + + // Run test shell + let test_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&test_temp_dir, test_case_set)?; + let test_result = test_case.run_shell(&config.test_shell, &test_temp_dir)?; + + // Build comparison + let mut comparison = OracleComparison { + exit_status: ExitStatusComparison::Ignored, + stdout: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + stderr: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + temp_dir: DirComparison::Ignored, + duration: DurationComparison { + oracle: oracle_result.duration, + test: test_result.duration, + }, + }; + + // Compare exit status + if test_case.ignore_exit_status { + comparison.exit_status = ExitStatusComparison::Ignored; + } else if oracle_result.exit_status == test_result.exit_status { + comparison.exit_status = ExitStatusComparison::Same(oracle_result.exit_status); + } else { + comparison.exit_status = ExitStatusComparison::TestDiffers { + test_exit_status: test_result.exit_status, + oracle_exit_status: oracle_result.exit_status, + }; + } + + // Compare stdout + if test_case.ignore_stdout { + comparison.stdout = StringComparison::Ignored { + test_string: test_result.stdout.clone(), + oracle_string: oracle_result.stdout, + }; + } else if output_matches( + &oracle_result.stdout, + &test_result.stdout, + test_case.ignore_whitespace, + ) { + comparison.stdout = StringComparison::Same(oracle_result.stdout); + } else { + comparison.stdout = StringComparison::TestDiffers { + test_string: test_result.stdout.clone(), + oracle_string: oracle_result.stdout, + }; + } + + // Compare stderr + if test_case.ignore_stderr { + comparison.stderr = StringComparison::Ignored { + test_string: test_result.stderr.clone(), + oracle_string: oracle_result.stderr, + }; + } else if output_matches( + &oracle_result.stderr, + &test_result.stderr, + test_case.ignore_whitespace, + ) { + comparison.stderr = StringComparison::Same(oracle_result.stderr); + } else { + comparison.stderr = StringComparison::TestDiffers { + test_string: test_result.stderr.clone(), + oracle_string: oracle_result.stderr, + }; + } + + // Compare temporary directory contents + comparison.temp_dir = diff_dirs(oracle_temp_dir.path(), test_temp_dir.path())?; + + Ok((comparison, test_result)) +} + +fn check_expectations( + test_case_set: &TestCaseSet, + test_case: &TestCase, + test_result: &crate::execution::RunResult, + test_temp_dir: Option<&assert_fs::TempDir>, + config: &RunnerConfig, +) -> ExpectationComparison { + let mut comparison = ExpectationComparison::not_specified(); + + // Check inline expectations + if let Some(expected_exit_code) = test_case.expected_exit_code { + let actual_code = test_result.exit_status.code().unwrap_or(-1); + if actual_code == expected_exit_code { + comparison.exit_code = SingleExpectationComparison::Matches; + } else { + comparison.exit_code = SingleExpectationComparison::Differs { + expected: expected_exit_code.to_string(), + actual: actual_code.to_string(), + }; + } + } + + if let Some(expected_stdout) = &test_case.expected_stdout { + if output_matches( + expected_stdout, + &test_result.stdout, + test_case.ignore_whitespace, + ) { + comparison.stdout = SingleExpectationComparison::Matches; + } else { + comparison.stdout = SingleExpectationComparison::Differs { + expected: expected_stdout.clone(), + actual: test_result.stdout.clone(), + }; + } + } + + if let Some(expected_stderr) = &test_case.expected_stderr { + if output_matches( + expected_stderr, + &test_result.stderr, + test_case.ignore_whitespace, + ) { + comparison.stderr = SingleExpectationComparison::Matches; + } else { + comparison.stderr = SingleExpectationComparison::Differs { + expected: expected_stderr.clone(), + actual: test_result.stderr.clone(), + }; + } + } + + // Check snapshot if enabled + if test_case.snapshot { + comparison.snapshot_used = true; + comparison.snapshot_result = Some(check_snapshot( + test_case_set, + test_case, + test_result, + test_temp_dir, + config, + )); + } + + comparison +} + +#[cfg(feature = "insta")] +fn check_snapshot( + test_case_set: &TestCaseSet, + test_case: &TestCase, + test_result: &crate::execution::RunResult, + test_temp_dir: Option<&assert_fs::TempDir>, + config: &RunnerConfig, +) -> SnapshotResult { + // Collect files from the temp directory + let files = collect_temp_dir_files(test_temp_dir, test_case); + + // Build snapshot content manually with literal block style for better readability + let snapshot_content = format_snapshot_yaml( + test_result.exit_status.code().unwrap_or(-1), + &test_result.stdout, + &test_result.stderr, + &files, + ); + + // Compute snapshot path + let snapshot_dir = test_case_set.source_dir.join(&config.snapshot_dir_name); + let yaml_stem = test_case_set + .source_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let test_name = test_case.name.as_deref().unwrap_or("unnamed"); + + // Convert spaces to underscores in snapshot name for filesystem compatibility + let snapshot_name = format!("{}_{}", yaml_stem, test_name.replace(' ', "_")); + + // Use insta's settings to configure snapshot location + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path(&snapshot_dir); + settings.set_prepend_module_to_snapshot(false); + + settings.bind(|| { + // Use assert_snapshot with raw string for block-style YAML + let result = std::panic::catch_unwind(|| { + insta::assert_snapshot!(snapshot_name.clone(), snapshot_content); + }); + + match result { + Ok(()) => SnapshotResult::Matches, + Err(_) => SnapshotResult::Differs { + message: format!( + "Snapshot '{snapshot_name}' differs or is new. Run `cargo insta review` to update." + ), + }, + } + }) +} + +/// Format snapshot data as YAML with literal block style for multiline strings. +#[cfg(feature = "insta")] +fn format_snapshot_yaml( + exit_code: i32, + stdout: &str, + stderr: &str, + files: &std::collections::BTreeMap, +) -> String { + use std::fmt::Write; + + let mut output = String::new(); + + writeln!(output, "exit_code: {exit_code}").ok(); + + // Format stdout + write!(output, "stdout: ").ok(); + format_yaml_string(&mut output, stdout, 0); + + // Format stderr + write!(output, "stderr: ").ok(); + format_yaml_string(&mut output, stderr, 0); + + // Format files if any + if !files.is_empty() { + writeln!(output, "files:").ok(); + for (filename, contents) in files { + write!(output, " {filename}: ").ok(); + format_yaml_string(&mut output, contents, 2); + } + } + + output +} + +/// Format a string as YAML, using literal block style for multiline content. +#[cfg(feature = "insta")] +fn format_yaml_string(output: &mut String, s: &str, indent: usize) { + use std::fmt::Write; + + if s.is_empty() { + writeln!(output, "\"\"").ok(); + } else if s.contains('\n') { + // Use literal block style for multiline strings + if s.ends_with('\n') { + writeln!(output, "|").ok(); + } else { + writeln!(output, "|-").ok(); + } + let indent_str = " ".repeat(indent + 2); + for line in s.lines() { + writeln!(output, "{indent_str}{line}").ok(); + } + // If string ends with newline but lines() doesn't capture trailing empty line + if s.ends_with('\n') && !s.ends_with("\n\n") { + // Already handled by literal block indicator '|' + } else if s.ends_with("\n\n") { + // Multiple trailing newlines need explicit empty lines + let trailing_newlines = s.len() - s.trim_end_matches('\n').len(); + for _ in 1..trailing_newlines { + writeln!(output, "{indent_str}").ok(); + } + } + } else { + // Single line - use quoted style if contains special chars, otherwise plain + if s.contains(':') + || s.contains('#') + || s.contains('\'') + || s.contains('"') + || s.starts_with(' ') + || s.ends_with(' ') + || s == "true" + || s == "false" + || s == "null" + || s.parse::().is_ok() + { + // Use double-quoted style with escapes + let escaped = s + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\t', "\\t"); + writeln!(output, "\"{escaped}\"").ok(); + } else { + writeln!(output, "{s}").ok(); + } + } +} + +/// Collect files from the temp directory, excluding test input files. +/// Returns a map of filename -> contents (as string for text files). +#[cfg(feature = "insta")] +fn collect_temp_dir_files( + test_temp_dir: Option<&assert_fs::TempDir>, + test_case: &TestCase, +) -> std::collections::BTreeMap { + use std::collections::BTreeMap; + + let mut files = BTreeMap::new(); + + let Some(temp_dir) = test_temp_dir else { + return files; + }; + + // Get the set of input test file names to exclude + let input_files: std::collections::HashSet<_> = test_case + .test_files + .iter() + .map(|f| f.path.to_string_lossy().to_string()) + .collect(); + + // Walk the temp directory and collect files + let Ok(entries) = std::fs::read_dir(temp_dir.path()) else { + return files; + }; + + for entry in entries.flatten() { + let filename = entry.file_name().to_string_lossy().to_string(); + + // Skip input test files + if input_files.contains(&filename) { + continue; + } + + // Skip coverage profile data + if filename.ends_with(".profraw") { + continue; + } + + let path = entry.path(); + if !path.is_file() { + continue; + } + + // Read file contents - use lossy conversion for binary files + if let Ok(contents) = std::fs::read(&path) { + let text = String::from_utf8_lossy(&contents).to_string(); + files.insert(filename, text); + } + } + + files +} + +#[cfg(not(feature = "insta"))] +fn check_snapshot( + _test_case_set: &TestCaseSet, + _test_case: &TestCase, + _test_result: &crate::execution::RunResult, + _test_temp_dir: Option<&assert_fs::TempDir>, + _config: &RunnerConfig, +) -> SnapshotResult { + SnapshotResult::Differs { + message: String::from("Snapshot testing requires the 'insta' feature to be enabled"), + } +} diff --git a/userland/brush/source/brush-test-harness/src/testcase.rs b/userland/brush/source/brush-test-harness/src/testcase.rs new file mode 100644 index 0000000..ed6c648 --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/testcase.rs @@ -0,0 +1,216 @@ +//! Test case definitions and YAML schema. + +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +/// How to invoke the shell for a test case. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub enum ShellInvocation { + /// Execute the shell binary directly. + #[default] + ExecShellBinary, + /// Execute a script file. + ExecScript(String), +} + +/// A file to create in the test's temporary directory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestFile { + /// Relative path to test file within the temp directory. + pub path: PathBuf, + /// Contents to seed the file with. + #[serde(default)] + pub contents: String, + /// Optionally provides relative path to the source file + /// that should be used to populate this file. + pub source_path: Option, + /// Whether the file should be executable. + #[serde(default)] + pub executable: bool, +} + +/// A single test case. +#[allow( + clippy::unsafe_derive_deserialize, + reason = "the unsafe call is unrelated to deserialization" +)] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestCase { + /// Name of the test case. + pub name: Option, + + /// How to invoke the shell. + #[serde(default)] + pub invocation: ShellInvocation, + + /// Command-line arguments to the shell. + #[serde(default)] + pub args: Vec, + + /// Command-line arguments to append for the shell-under-test only. + #[serde(default)] + pub additional_test_args: Vec, + + /// Default command-line shell arguments that should be *removed*. + #[serde(default)] + pub removed_default_args: HashSet, + + /// Environment variables for the shell. + #[serde(default)] + pub env: HashMap, + + /// Home directory to set for the test. + #[serde(default)] + pub home_dir: Option, + + /// Whether to skip this test. + #[serde(default)] + pub skip: bool, + + /// Whether this test requires a PTY. + #[serde(default)] + pub pty: bool, + + /// Input to provide via stdin. + #[serde(default)] + pub stdin: Option, + + /// Whether to ignore exit status differences. + #[serde(default)] + pub ignore_exit_status: bool, + + /// Whether to ignore stderr differences. + #[serde(default)] + pub ignore_stderr: bool, + + /// Whether to ignore stdout differences. + #[serde(default)] + pub ignore_stdout: bool, + + /// Whether to normalize whitespace when comparing output. + #[serde(default)] + pub ignore_whitespace: bool, + + /// Files to create in the test's temporary directory. + #[serde(default)] + pub test_files: Vec, + + /// Whether this test is a known failure. + #[serde(default)] + pub known_failure: bool, + + /// Runtime platform tags (e.g., "wasi", "windows") on which this test is + /// a known failure. Elsewhere it's expected to pass. + #[serde(default)] + pub known_failure_platforms: HashSet, + + /// Configurations that are incompatible with this test. + #[serde(default)] + pub incompatible_configs: HashSet, + + /// Operating systems that are incompatible with this test. + #[serde(default)] + pub incompatible_os: HashSet, + + /// Runtime platform tags (e.g., "wasi", "wasm") that are incompatible + /// with this test. The test is skipped when any of these tags is present + /// in the runner's active platform tag set. + #[serde(default)] + pub incompatible_platforms: HashSet, + + /// Minimum oracle version required for this test. + #[serde(default)] + pub min_oracle_version: Option, + + /// Maximum oracle version allowed for this test. + #[serde(default)] + pub max_oracle_version: Option, + + /// Timeout for this test in seconds; for a PTY test, for each expectation in it. + #[serde(default)] + pub timeout_in_seconds: Option, + + // ==================== Expectation fields ==================== + /// Expected stdout content (for expectation-based testing). + #[serde(default)] + pub expected_stdout: Option, + + /// Expected stderr content (for expectation-based testing). + #[serde(default)] + pub expected_stderr: Option, + + /// Expected exit code (for expectation-based testing). + #[serde(default)] + pub expected_exit_code: Option, + + /// Whether to use insta snapshot for this test's expectations. + #[serde(default)] + pub snapshot: bool, + + /// Whether to skip oracle comparison even when an oracle is configured. + #[serde(default)] + pub skip_oracle: bool, +} + +impl TestCase { + /// Returns whether this test case is expected to fail, given the runner's + /// active platform tags. + pub fn is_known_failure(&self, platform_tags: &HashSet) -> bool { + self.known_failure + || self + .known_failure_platforms + .iter() + .any(|p| platform_tags.contains(p)) + } + + /// Returns whether this test case has any inline expectations defined. + pub const fn has_inline_expectations(&self) -> bool { + self.expected_stdout.is_some() + || self.expected_stderr.is_some() + || self.expected_exit_code.is_some() + } + + /// Returns whether this test case uses snapshots for expectations. + pub const fn uses_snapshot(&self) -> bool { + self.snapshot + } + + /// Returns whether this test case has any expectations (inline or snapshot). + pub const fn has_expectations(&self) -> bool { + self.has_inline_expectations() || self.uses_snapshot() + } +} + +/// A set of test cases loaded from a single YAML file. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestCaseSet { + /// Name of the test case set. + pub name: Option, + + /// The test cases in this set. + pub cases: Vec, + + /// Common test files applicable to all children test cases. + #[serde(default)] + pub common_test_files: Vec, + + /// Configurations that are incompatible with this entire test set. + #[serde(default)] + pub incompatible_configs: HashSet, + + /// Runtime platform tags (e.g., "wasi", "wasm") that are incompatible + /// with this entire test set. + #[serde(default)] + pub incompatible_platforms: HashSet, + + /// Directory containing the YAML file (computed at runtime). + #[serde(skip)] + pub source_dir: PathBuf, + + /// Path to the YAML file (computed at runtime). + #[serde(skip)] + pub source_file: PathBuf, +} diff --git a/userland/brush/source/brush-test-harness/src/util.rs b/userland/brush/source/brush-test-harness/src/util.rs new file mode 100644 index 0000000..262479f --- /dev/null +++ b/userland/brush/source/brush-test-harness/src/util.rs @@ -0,0 +1,155 @@ +//! Utility functions for the test harness. + +use anyhow::Result; + +/// Get the OS ID from /etc/os-release file. +/// Returns the value of the ID field, which is the canonical OS identifier. +/// For example: "ubuntu", "opensuse-tumbleweed", "fedora", etc. +pub fn get_host_os_id() -> Option { + os_release::OsRelease::new().ok().and_then(|info| { + if info.id.is_empty() { + None + } else { + Some(info.id) + } + }) +} + +/// Reads what a pty session read from the shell, from expectrl's log of the session. +#[cfg(pty)] +pub fn read_expectrl_log(log: Vec) -> Result { + use descape::UnescapeExt; + + // expectrl logs each chunk it reads as a string (escaped, as Rust's `Debug` does), if it's + // UTF-8, or else as a list of bytes (as when a character is split between chunks); so the + // chunks' bytes are put back together before they're decoded. + let mut output = Vec::new(); + for line in String::from_utf8(log)?.lines() { + if let Some(chunk) = line + .strip_prefix("read: \"") + .and_then(|line| line.strip_suffix('"')) + { + let chunk = chunk + .to_unescaped() + .map_err(|err| anyhow::anyhow!("invalid escape in pty log: {err:?}"))?; + output.extend_from_slice(chunk.as_bytes()); + } else if let Some(bytes) = line + .strip_prefix("read:(bytes): [") + .and_then(|line| line.strip_suffix(']')) + { + for byte in bytes.split(", ") { + output.push(byte.parse()?); + } + } + } + + Ok(String::from_utf8_lossy(&output).into_owned()) +} + +/// Makes what a pty session read from the shell human-readable, by removing VT escape sequences. +pub fn make_expectrl_output_readable>(output: S) -> String { + strip_ansi_escapes::strip_str(output) +} + +/// Writes a diff between two strings to a writer. +pub fn write_diff( + writer: &mut impl std::io::Write, + indent: usize, + left: &str, + right: &str, +) -> Result<()> { + use colored::Colorize; + + let indent_str = " ".repeat(indent); + + let diff = diff::lines(left, right); + for d in diff { + let formatted = match d { + diff::Result::Left(l) => std::format!("{indent_str}- {l}").red(), + diff::Result::Both(l, _) => std::format!("{indent_str} {l}").bright_black(), + diff::Result::Right(r) => std::format!("{indent_str}+ {r}").green(), + }; + + writeln!(writer, "{formatted}")?; + } + + Ok(()) +} + +/// Resolves the first element of the given launcher token list to an absolute path. +/// +/// This is needed because the test harness clears env vars (including `PATH`) +/// before spawning child processes, so a launcher binary referenced by name +/// (e.g., `wasmtime`) would fail to resolve at exec time. +pub fn resolve_launcher_path(tokens: &mut [String]) -> Result<()> { + let Some(first) = tokens.first() else { + return Ok(()); + }; + if std::path::Path::new(first.as_str()).is_absolute() { + return Ok(()); + } + let resolved = std::env::var_os("PATH") + .into_iter() + .flat_map(|p| std::env::split_paths(&p).collect::>()) + .map(|d| d.join(first.as_str())) + .find(|p| p.is_file()) + .ok_or_else(|| anyhow::anyhow!("could not resolve launcher binary '{first}' in PATH"))?; + tokens[0] = resolved.to_string_lossy().into_owned(); + Ok(()) +} + +/// Gets the bash version string from the given bash path. +pub fn get_bash_version_str(bash_path: &std::path::Path) -> Result { + use anyhow::Context; + + let output = std::process::Command::new(bash_path) + .arg("--norc") + .arg("--noprofile") + .arg("-c") + .arg("echo -n ${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}.${BASH_VERSINFO[2]}") + .output() + .context("failed to retrieve bash version")? + .stdout; + + let ver_str = String::from_utf8(output)?; + + Ok(ver_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn readable_output_keeps_backslashes() { + // A transcript is decoded already, so a backslash in it is just text. + let output = "PS1='test\\$ '\n"; + assert_eq!(make_expectrl_output_readable(output), output); + } + + #[test] + fn readable_output_drops_vt_escape_sequences() { + assert_eq!( + make_expectrl_output_readable("\u{1b}[31mred\u{1b}[0m"), + "red" + ); + } + + #[cfg(pty)] + #[test] + fn log_is_decoded_once() { + // As expectrl logs what it reads: each chunk as a string, if it's UTF-8, or else as + // bytes (as when a character is split between chunks). + let log = format!( + "write: {:?}\nread: {:?}\nread:(bytes): {:?}\nread:(bytes): {:?}\n", + "echo", + "a \\$ b\r\n", + &[0xc3_u8][..], + &[0xa9_u8, b'!'][..], + ); + assert_eq!( + read_expectrl_log(log.into_bytes()).unwrap(), + "a \\$ b\r\né!" + ); + } +} diff --git a/userland/brush/source/brush-winnow-args-adapter/Cargo.toml b/userland/brush/source/brush-winnow-args-adapter/Cargo.toml new file mode 100644 index 0000000..6c35054 --- /dev/null +++ b/userland/brush/source/brush-winnow-args-adapter/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "brush-winnow-args-adapter" +description = "winnow-args adapter for brush builtins" +version = "0.1.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +winnow-args = { version = "0.6.0", default-features = false, features = ["derive", "help-text", "terminal-size"] } diff --git a/userland/brush/source/brush-winnow-args-adapter/LICENSE b/userland/brush/source/brush-winnow-args-adapter/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush-winnow-args-adapter/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush-winnow-args-adapter/src/adapter.rs b/userland/brush/source/brush-winnow-args-adapter/src/adapter.rs new file mode 100644 index 0000000..a3c1691 --- /dev/null +++ b/userland/brush/source/brush-winnow-args-adapter/src/adapter.rs @@ -0,0 +1,441 @@ +//! Parses builtin arguments with winnow-args and renders their help. + +use std::fmt::Write as _; + +use brush_core::CommandArg; +use brush_core::builtins::{ContentOptions, ContentType, Invocation, UsageError}; +use winnow_args::BStr; +use winnow_args::color::{Ansi, Depth, Paint, Palette}; +use winnow_args::{Args, Error, ErrorKind, help}; + +/// The colors of brush's help: yellow headings, magenta flags and cyan value +/// names, with clap's colors for errors. +pub const HELP_PALETTE: Palette = Palette { + header: Paint::ansi(Ansi::Yellow).bold().underline(), + flag: Paint::ansi(Ansi::Magenta).bold(), + command: Paint::ansi(Ansi::Magenta).bold(), + placeholder: Paint::ansi(Ansi::Cyan), + ..Palette::CLAP +}; + +/// Parses a builtin's arguments, each flattened to a word. +/// +/// # Errors +/// +/// Returns a [`UsageError`] for a parse failure, or with the help text when +/// the arguments ask for help or version information. +pub fn parse(invocation: Invocation<'_>) -> Result { + let name = invocation.name(); + parse_words(name, &into_words(invocation.into_args())) +} + +/// Like [`parse`], but leaves the first `--` and everything after it unparsed. +/// +/// Returns them for the builtin to read (`echo`, `test`, `set`, `getopts`). +/// +/// # Errors +/// +/// Returns the same errors as [`parse`], for the words before the separator. +pub fn parse_with_double_dash( + invocation: Invocation<'_>, +) -> Result<(T, Vec), UsageError> { + let name = invocation.name(); + let mut words = into_words(invocation.into_args()); + let Some(separator) = words.iter().position(|w| w == "--") else { + return Ok((parse_words(name, &words)?, Vec::new())); + }; + let trailing = words.split_off(separator); + Ok((parse_words(name, &words)?, trailing)) +} + +/// Parses a declaration utility's arguments: its options, and the rest as +/// declarations, which it returns as they arrived. +/// +/// The options are the words that start with `-` (or `+`, for a builtin +/// that takes `+` options), and aren't only that, up to the first word that +/// doesn't or to `--`, which is dropped. To any other builtin (`export`) a +/// `+x` word is an operand, which it rejects as an invalid name, as a shell +/// does. No declaration utility's option takes its value as a separate word. +/// +/// # Errors +/// +/// Returns the same errors as [`parse`], for the options. +pub fn parse_declarations( + invocation: Invocation<'_>, +) -> Result<(T, Vec), UsageError> { + let name = invocation.name(); + let mut args = invocation.into_args(); + let takes_plus = T::HELP.items.iter().any(|item| item.plus.is_some()); + let end = args + .iter() + .position(|arg| match arg { + CommandArg::String(s) => { + s == "--" + || !(s.len() > 1 && (s.starts_with('-') || (takes_plus && s.starts_with('+')))) + } + CommandArg::Assignment(_) => true, + }) + .unwrap_or(args.len()); + + let mut declarations = args.split_off(end); + if matches!(declarations.first(), Some(CommandArg::String(s)) if s == "--") { + declarations.remove(0); + } + + Ok((parse_words(name, &into_words(args))?, declarations)) +} + +/// Flattens arguments into words. Assignments render as `name=value`. +fn into_words(args: Vec) -> Vec { + args.into_iter().map(String::from).collect() +} + +/// Builtin invocations this short borrow their words from a stack buffer +/// rather than an allocated list. +const INLINE_WORDS: usize = 16; + +fn parse_words(name: &str, words: &[String]) -> Result { + let parsed = if words.len() <= INLINE_WORDS { + let mut buf = [BStr::new(b""); INLINE_WORDS]; + for (slot, word) in buf.iter_mut().zip(words) { + *slot = BStr::new(word.as_bytes()); + } + T::parse_words(&buf[..words.len()]) + } else { + T::parse_words(&winnow_args::words(words)) + }; + parsed.map_err(|e| to_usage_error::(name, &e)) +} + +/// A parse failure as the builtin reports it. +/// +/// That is `name: error: …` in winnow-args' words, then the usage line. A +/// help request is [`UsageError::help`], with the help of the subcommand it +/// was made in: the short page for `-h` and the long one for `--help`, as +/// clap's. The shell prints it on standard output. A bare invocation of an +/// `arg_required_else_help` command is instead a plain usage error with that +/// help. A version request is the top-level help. +/// +/// # Arguments +/// +/// * `name` - The name the builtin was invoked under. +/// * `error` - The failure. +pub fn to_usage_error(name: &str, error: &Error) -> UsageError { + match error.kind() { + ErrorKind::HelpRequested | ErrorKind::VersionRequested => { + let text = error + .render_help_styled(name, help::Style::PLAIN) + .unwrap_or_else(|| { + // Only a version request has no help page of its own. + help::render_styled(T::HELP, &[name], true, help::width(), help::Style::PLAIN) + }); + if error.is_bare_help() { + UsageError::new(text.trim_end()) + } else { + UsageError::help(text.trim_end()) + } + } + _ => UsageError::new(format!( + "{name}: error: {}\nusage: {}", + error.message(help::Style::PLAIN), + synopsis::(name) + )), + } +} + +/// Returns the builtin's help content of the given type. +/// +/// # Errors +/// +/// Returns an error for [`ContentType::ManPage`], which isn't implemented. +pub fn content( + name: &str, + content_type: ContentType, + options: &ContentOptions, +) -> Result { + Ok(match content_type { + ContentType::DetailedHelp => { + let style = if options.colorized { + help::Style::at(HELP_PALETTE, Depth::Ansi16) + } else { + help::Style::PLAIN + }; + help::render_styled(T::HELP, &[name], true, help::width(), style) + } + ContentType::ShortUsage => format!("{name}: {}\n", synopsis::(name)), + ContentType::ShortDescription => format!("{name} - {}\n", T::HELP.about), + ContentType::ManPage => { + brush_core::error::unimp("man page rendering is not yet implemented")? + } + }) +} + +/// A one-line synopsis: `name [-ab] [-c value] [arg ...]`, from the help data +/// the derive emits: short and `+` options; long options and hidden flags +/// are left out. +fn synopsis(name: &str) -> String { + let command: &help::Command = T::HELP; + let mut out = String::from(name); + let visible = || command.items.iter().filter(|i| !i.hide); + let switch = |i: &&help::Item| !i.positional && i.value_name.is_none() && !i.required; + let switches: String = visible() + .filter(switch) + .flat_map(|i| i.short.into_iter().chain(i.more_shorts.iter().copied())) + .collect(); + if !switches.is_empty() { + let _ = write!(out, " [-{switches}]"); + } + let plus_switches: String = visible().filter(switch).filter_map(|i| i.plus).collect(); + if !plus_switches.is_empty() { + let _ = write!(out, " [+{plus_switches}]"); + } + for item in visible().filter(|i| !i.positional && i.value_name.is_some()) { + let value = item.value_name.unwrap_or("VALUE").to_lowercase(); + let value = if item.optional_value { + format!("[{value}]") + } else { + value + }; + // `-o value` and `+o value` are told apart; a long-only flag is left out. + let spellings = item + .short + .map(|c| format!("-{c}")) + .into_iter() + .chain(item.plus.map(|c| format!("+{c}"))); + for flag in spellings { + if item.required { + let _ = write!(out, " {flag} {value}"); + } else { + let _ = write!(out, " [{flag} {value}]"); + } + } + } + for item in visible().filter(|i| i.positional) { + let value = item.value_name.unwrap_or("arg").to_lowercase(); + let dots = if item.multiple { " ..." } else { "" }; + if item.required { + let _ = write!(out, " {value}{dots}"); + } else { + let _ = write!(out, " [{value}{dots}]"); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + use brush_core::builtins::{DeclarationRole, ParseArgs}; + + /// Echo-shaped: unknown flags are operands, and `--` stays in the tail. + #[derive(Debug, winnow_args::Args)] + #[arg(unknown_flags = "value", disable_help_flag, disable_version_flag)] + struct EchoLike { + /// Suppress the trailing newline. + #[arg(short = 'n')] + no_newline: bool, + + /// Words to echo. + #[arg(positional)] + args: Vec, + } + + crate::impl_parse_args!(EchoLike, keep_double_dash = args); + + /// Export-shaped: options stop at the first operand. + #[derive(Debug, winnow_args::Args)] + #[arg(disable_help_flag, disable_version_flag)] + struct ExportLike { + /// Un-export the names. + #[arg(short = 'n')] + unexport: bool, + + /// Operands, filled by the adapter. + #[arg(skip)] + declarations: Vec, + } + + crate::impl_parse_args!(ExportLike, declarations = declarations); + + /// Set-shaped: `+x` disables what `-x` enables, including in a cluster. + #[derive(Debug, winnow_args::Args)] + #[arg(plus_options, disable_help_flag, disable_version_flag)] + struct PlusLike { + #[arg(short = 'x', plus = 'x')] + x: Option, + #[arg(short = 'y', plus = 'y')] + y: Option, + /// Options end at the first operand. + #[arg(positional, stop_flags)] + words: Vec, + } + + crate::impl_parse_args!(PlusLike); + + /// Help-shaped: `--help` gives the help text. + #[derive(Debug, winnow_args::Args)] + #[arg(disable_version_flag)] + struct HelpLike { + /// Use the physical directory. + #[arg(short = 'P')] + physical: bool, + + /// Directory to enter. + #[arg(positional)] + target: Option, + } + + crate::impl_parse_args!(HelpLike); + + /// Subcommand-shaped: each subcommand has a help page of its own. + #[derive(Debug, winnow_args::Args)] + #[arg(arg_required_else_help)] + struct CtlLike { + #[arg(subcommand)] + command: CtlCommand, + } + + #[derive(Debug, winnow_args::Subcommand)] + enum CtlCommand { + /// Show the status. + Status, + /// Enable a thing. + Enable { + /// The thing to enable. + #[arg(positional)] + thing: String, + }, + } + + crate::impl_parse_args!(CtlLike); + + fn words(parsed: Vec) -> Vec { + into_words(parsed) + } + + #[test] + fn a_double_dash_and_what_follows_are_kept() { + let parsed = + ::parse(Invocation::new("echo", ["-n", "--", "-e"])).unwrap(); + assert!(parsed.no_newline); + assert_eq!(parsed.args, ["--", "-e"]); + } + + #[test] + fn an_unknown_flag_becomes_an_operand() { + let parsed = ::parse(Invocation::new("echo", ["-z", "hi"])).unwrap(); + assert!(!parsed.no_newline); + assert_eq!(parsed.args, ["-z", "hi"]); + } + + #[test] + fn declarations_start_at_the_first_operand() { + let parsed = + ::parse(Invocation::new("export", ["-n", "FOO", "-p"])) + .unwrap(); + assert!(parsed.unexport); + assert_eq!(ExportLike::DECLARATION_ROLE, DeclarationRole::Utility); + assert_eq!(words(parsed.declarations), ["FOO", "-p"]); + } + + #[test] + fn a_plus_word_is_an_operand_to_a_builtin_without_plus_options() { + let parsed = + ::parse(Invocation::new("export", ["+n", "FOO"])).unwrap(); + assert!(!parsed.unexport); + assert_eq!(words(parsed.declarations), ["+n", "FOO"]); + } + + #[test] + fn declarations_start_after_a_double_dash_which_is_dropped() { + let parsed = + ::parse(Invocation::new("export", ["-n", "--", "-n"])) + .unwrap(); + assert!(parsed.unexport); + assert_eq!(words(parsed.declarations), ["-n"]); + } + + #[test] + fn a_lone_dash_or_plus_is_a_declaration() { + let parsed = + ::parse(Invocation::new("export", ["-", "+"])).unwrap(); + assert!(!parsed.unexport); + assert_eq!(words(parsed.declarations), ["-", "+"]); + } + + #[test] + fn a_plus_cluster_toggles_each_flag_and_options_stop_at_operands() { + let disabled = ::parse(Invocation::new("set", ["+xy"])).unwrap(); + assert_eq!((disabled.x, disabled.y), (Some(false), Some(false))); + + let mixed = + ::parse(Invocation::new("set", ["-x", "foo", "+bar"])).unwrap(); + assert_eq!(mixed.x, Some(true)); + assert_eq!(mixed.words, ["foo", "+bar"]); + } + + #[test] + fn a_help_request_gives_the_help_text() { + let err = ::parse(Invocation::new("cd", ["--help"])).unwrap_err(); + assert!(err.is_help(), "{err}"); + assert!(err.to_string().contains("Directory to enter."), "{err}"); + let err = ::parse(Invocation::new("cd", ["-z"])).unwrap_err(); + assert!(!err.is_help(), "{err}"); + } + + #[test] + fn a_subcommand_and_its_arguments_parse() { + let parsed = + ::parse(Invocation::new("ctl", ["enable", "x"])).unwrap(); + assert!(matches!(parsed.command, CtlCommand::Enable { thing } if thing == "x")); + } + + #[test] + fn a_help_request_in_a_subcommand_gives_its_help() { + let err = ::parse(Invocation::new("ctl", ["enable", "--help"])) + .unwrap_err(); + assert!(err.is_help(), "{err}"); + assert!(err.to_string().contains("Usage: ctl enable "), "{err}"); + assert!(err.to_string().contains("The thing to enable."), "{err}"); + assert!(!err.to_string().contains("Show the status."), "{err}"); + } + + #[test] + fn a_bare_invocation_that_needs_more_is_an_error_with_the_help() { + let err = + ::parse(Invocation::new("ctl", [] as [&str; 0])).unwrap_err(); + assert!(!err.is_help(), "{err}"); + assert!(err.to_string().contains("Show the status."), "{err}"); + } + + #[test] + fn an_unknown_option_is_reported_with_the_usage_line() { + let err = ::parse(Invocation::new("cd", ["-x"])).unwrap_err(); + assert_eq!( + err.to_string(), + "cd: error: unknown flag `-x`\nusage: cd [-P] [target]" + ); + } + + #[test] + fn short_usage_and_description_come_from_the_help_data() { + let options = ContentOptions::default(); + let usage = HelpLike::content("cd", ContentType::ShortUsage, &options).unwrap(); + assert_eq!(usage, "cd: cd [-P] [target]\n"); + let description = HelpLike::content("cd", ContentType::ShortDescription, &options).unwrap(); + assert_eq!( + description, + "cd - Help-shaped: `--help` gives the help text.\n" + ); + } + + #[test] + fn colorized_help_uses_brush_colors() { + let options = ContentOptions { colorized: true }; + let help = HelpLike::content("cd", ContentType::DetailedHelp, &options).unwrap(); + assert!(help.contains("\x1b[1;4;33mUsage:\x1b[0m"), "{help:?}"); + assert!(help.contains("\x1b[1;35m-P\x1b[0m"), "{help:?}"); + assert!(help.contains("\x1b[36mTARGET\x1b[0m"), "{help:?}"); + } +} diff --git a/userland/brush/source/brush-winnow-args-adapter/src/lib.rs b/userland/brush/source/brush-winnow-args-adapter/src/lib.rs new file mode 100644 index 0000000..c2a8cc0 --- /dev/null +++ b/userland/brush/source/brush-winnow-args-adapter/src/lib.rs @@ -0,0 +1,116 @@ +//! Binds builtins that use [winnow-args](https://github.com/lu-zero/winnow-args) +//! to the engine-neutral contract in [`brush_core::builtins`]. +//! +//! A builtin opts in by deriving [`winnow_args::Args`] and invoking +//! [`impl_parse_args`]; the macro implements +//! [`brush_core::builtins::ParseArgs`], its parsing and its help. That macro, +//! and [`to_usage_error`] for a builtin that parses by hand, are this crate's +//! API. Its forms +//! are a subset of `brush_clap_adapter::impl_parse_args!`'s: `+x` options are +//! winnow-args' own (`#[arg(plus_options)]`), so they need no form of their +//! own. +//! +//! The words are parsed where they lie, borrowed rather than copied. Errors +//! are winnow-args' own (`name: error: …`), followed by the usage line. + +mod adapter; + +pub use adapter::{HELP_PALETTE, to_usage_error}; + +/// What the exported macros expand to. Not part of the API, so it can change +/// in any release. +#[doc(hidden)] +pub mod __private { + // Lets the macros name brush-core items from any calling crate. + pub use brush_core; + + pub use crate::adapter::{content, parse, parse_declarations, parse_with_double_dash}; +} + +/// Declares that a builtin uses winnow-args: implements +/// [`ParseArgs`](brush_core::builtins::ParseArgs), its parsing and its help, for +/// a type that derives [`winnow_args::Args`]. +/// +/// # Forms +/// +/// | Form | Parsing | +/// |---|---| +/// | `impl_parse_args!(Type)` | Every argument is flattened to a word and parsed. | +/// | `impl_parse_args!(Type, keep_double_dash = field)` | The first `--` and every word after it are appended to `field` (a `Vec`) verbatim. | +/// | `impl_parse_args!(Type, declarations = field)` | A declaration utility: the options up to the first operand or `--` are parsed, and the operands are kept in `field` (a `Vec`, marked `#[arg(skip)]`) as they arrived. | +/// | `impl_parse_args!(Type, parse = parser)` | `parser` (a function or closure from an [`Invocation`](brush_core::builtins::Invocation) to `Result`) parses the arguments by hand; only the help comes from winnow-args. | +/// +/// # Examples +/// +/// ``` +/// /// Greet someone. +/// #[derive(winnow_args::Args)] +/// #[arg(disable_help_flag, disable_version_flag)] +/// struct GreetCommand { +/// /// Names to greet. +/// #[arg(positional)] +/// names: Vec, +/// } +/// +/// brush_winnow_args_adapter::impl_parse_args!(GreetCommand); +/// ``` +#[macro_export] +macro_rules! impl_parse_args { + ($t:ty $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + $crate::__private::parse::(invocation) + }); + }; + + ($t:ty, keep_double_dash = $field:ident $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + let (mut this, trailing) = $crate::__private::parse_with_double_dash::(invocation)?; + this.$field.extend(trailing); + ::std::result::Result::Ok(this) + }); + }; + + ($t:ty, declarations = $field:ident $(,)?) => { + $crate::__impl_parse_args!($t, role = Utility, invocation => { + let (mut this, declarations) = $crate::__private::parse_declarations::(invocation)?; + this.$field = declarations; + ::std::result::Result::Ok(this) + }); + }; + + ($t:ty, parse = $parse:expr $(,)?) => { + $crate::__impl_parse_args!($t, role = None, invocation => { + ($parse)(invocation) + }); + }; +} + +/// Implements [`ParseArgs`](brush_core::builtins::ParseArgs) for a +/// winnow-args type, with the given body of `parse`. Internal to +/// [`impl_parse_args!`]; not part of the API. +#[doc(hidden)] +#[macro_export] +macro_rules! __impl_parse_args { + ($t:ty, role = $role:ident, $invocation:ident => $parse:block) => { + impl $crate::__private::brush_core::builtins::ParseArgs for $t { + const DECLARATION_ROLE: $crate::__private::brush_core::builtins::DeclarationRole = + $crate::__private::brush_core::builtins::DeclarationRole::$role; + + fn content( + name: &str, + content_type: $crate::__private::brush_core::builtins::ContentType, + options: &$crate::__private::brush_core::builtins::ContentOptions, + ) -> ::std::result::Result<::std::string::String, $crate::__private::brush_core::Error> + { + $crate::__private::content::(name, content_type, options) + } + + fn parse( + $invocation: $crate::__private::brush_core::builtins::Invocation<'_>, + ) -> ::std::result::Result + { + $parse + } + } + }; +} diff --git a/userland/brush/source/brush/Cargo.toml b/userland/brush/source/brush/Cargo.toml new file mode 100644 index 0000000..e9149bf --- /dev/null +++ b/userland/brush/source/brush/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "brush" +description = "Rust-implemented shell focused on POSIX and bash compatibility" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +# Application tier; see docs/reference/msrv-policy.md. +rust-version = "1.95.0" + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/brush-shell-v{ version }/brush-{ target }{ archive-suffix }" + +[[bin]] +name = "brush" +path = "src/main.rs" +bench = false + +[features] +default = ["brush-shell/default"] +basic = ["brush-shell/basic"] +minimal = ["brush-shell/minimal"] +reedline = ["brush-shell/reedline"] +experimental = ["brush-shell/experimental"] + +[lints] +workspace = true + +[dependencies] +brush-shell = { version = "^0.4.0", path = "../brush-shell", default-features = false } diff --git a/userland/brush/source/brush/LICENSE b/userland/brush/source/brush/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/userland/brush/source/brush/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/userland/brush/source/brush/src/main.rs b/userland/brush/source/brush/src/main.rs new file mode 100644 index 0000000..5d25a93 --- /dev/null +++ b/userland/brush/source/brush/src/main.rs @@ -0,0 +1,5 @@ +//! Wrapper binary for brush-shell + +fn main() { + brush_shell::entry::run(); +} diff --git a/userland/brush/source/cliff.toml b/userland/brush/source/cliff.toml new file mode 100644 index 0000000..3415dad --- /dev/null +++ b/userland/brush/source/cliff.toml @@ -0,0 +1,91 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# changelog header +header = """ +# Changelog\n +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "🚀 Features" }, + { message = "^fix", group = "🐛 Bug Fixes" }, + { message = "^doc", group = "📚 Documentation" }, + { message = "^perf", group = "⚡ Performance" }, + { message = "^refactor", group = "🚜 Refactor" }, + { message = "^style", group = "🎨 Styling" }, + { message = "^test", group = "🧪 Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, + { body = ".*security", group = "🛡️ Security" }, + { message = "^revert", group = "◀️ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/userland/brush/source/clippy.toml b/userland/brush/source/clippy.toml new file mode 100644 index 0000000..154626e --- /dev/null +++ b/userland/brush/source/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/userland/brush/source/deny.toml b/userland/brush/source/deny.toml new file mode 100644 index 0000000..ee4eadd --- /dev/null +++ b/userland/brush/source/deny.toml @@ -0,0 +1,92 @@ +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [] +#exclude = [] +all-features = false +no-default-features = false +#features = [] + +[output] +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +version = 2 +yanked = "deny" +#db-path = "$CARGO_HOME/advisory-dbs" +#db-urls = ["https://github.com/rustsec/advisory-db"] +ignore = [ + { id = "RUSTSEC-2026-0441", reason = "pin-utils is unmaintained, not vulnerable; test-only, via expectrl -> nix 0.26 (default `aio` feature); drop once expectrl moves to nix >= 0.27" }, +] +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +version = 2 +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "WTFPL", + "Zlib", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [] + +[licenses.private] +ignore = false +registries = [] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +multiple-versions = "allow" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [] +# List of crates to deny +deny = [] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[sources.allow-org] +# 1 or more github.com organizations to allow git sources for +github = [] diff --git a/userland/brush/source/docs/README.md b/userland/brush/source/docs/README.md new file mode 100644 index 0000000..243505f --- /dev/null +++ b/userland/brush/source/docs/README.md @@ -0,0 +1,15 @@ +# brush documentation + +User-facing documentation lives at [brush.sh](https://brush.sh): [installation](https://brush.sh/getting-started/install/), the [compatibility](https://brush.sh/reference/compatibility/), [configuration](https://brush.sh/reference/config-files/), and [command-line](https://brush.sh/reference/usage/) references, and [release notes](https://brush.sh/releases/). The reference pages are generated from this directory at each release. + +The docs here are for people working on brush itself: + +* [How-to guides](how-to/README.md) for building, testing, benchmarking, and releasing +* [Reference material](reference/README.md) for compatibility, configuration, experimental features, and project policies +* [Explanation](explanation/README.md) of how brush is built, starting with its [architecture](explanation/architecture.md) + +If you're just getting started with the codebase, start with the [How to build](how-to/build.md) guide and [CONTRIBUTING.md](../CONTRIBUTING.md). + +--- + +> _Note: The structure of our docs is inspired by the [Diátaxis](https://diataxis.fr/) approach; we've found over time that best helps readers find the material most relevant to them, as well as provides a rough shape for where to place the right docs._ diff --git a/userland/brush/source/docs/demos/demo.tape b/userland/brush/source/docs/demos/demo.tape new file mode 100644 index 0000000..efed234 --- /dev/null +++ b/userland/brush/source/docs/demos/demo.tape @@ -0,0 +1,146 @@ +# Works with https://github.com/charmbracelet/vhs + +Output sample.gif + +Set FontFamily "CaskaydiaMono Nerd Font Mono" +Set FontSize 20 +Set Theme "Monokai Pro" +Set Width 1600 +Set Height 600 +Set CursorBlink false + +# Setup environment to launch brush in +Env HISTFILE "" +Env PS1 '$0$ ' + +# Launch brush and set up bash-completion +Hide +Type `brush --enable-highlighting --norc --noprofile --no-config` +Enter +Type `source /usr/share/bash-completion/bash_completion && clear` +Enter +Show + +# Enable starship +Type `# Let's start with a better prompt. starship to the rescue!` +Sleep 0.8s +Enter +Type `eval "$(starship init bash)"` +Sleep 0.8s +Enter +Sleep 1s + +# git describe +Type `git d` +Tab +Sleep 1.3s +Enter +Sleep 0.4s +Type `--l` +Sleep 0.4s +Tab +Sleep 0.8s +Type `brush-she` +Sleep 0.5s +Tab +Sleep 0.2s +Right +Sleep 0.2s +Right +Sleep 0.8s +Enter +Sleep 0.8s +Enter +Sleep 1s + +# vim +Type `vim Ca` +Sleep 0.5s +Tab +Sleep 0.8s +Right +Sleep 0.5s +Enter +Sleep 1s +Enter + +Type `1G` +Type `i` +Enter +Up +Type `# Let's try suspending vim...` +Enter +Escape +Sleep 1s +Ctrl+Z +Sleep 0.7s + +Type `# Yep, it's suspended.` +Sleep 0.4s +Type ` Let's bring it back.` +Enter +Sleep 0.8s + +Type `fg` +Sleep 0.4s +Enter +Sleep 0.6s + +Type `:q!` +Sleep 0.3s +Enter +Sleep 0.5s +Ctrl+L +Sleep 0.2s + +Type `# Let's properly greet the world.` +Enter +Sleep 0.4s + +# Figure out version +Type `verline=$(help | head -n1)` +Sleep 0.2s +Enter +Sleep 0.3s +Type `[[ "${verline}" =~ ^.*version\ ([[:digit:]\.]+).*$ ]] && ver=${BASH_REMATCH[1]}` +Sleep 0.4s +Enter +Sleep 0.2s +Type `declare -p ver` +Enter +Sleep 0.4s + +# Declare function +Type `function greet() {` +Enter +Type ` echo "Hello from brush ${ver}!"` +Enter +Type `}` +Sleep 1s +Enter +Type `type greet` +Sleep 0.4s +Enter +Sleep 1s + +# Use function +Ctrl+L +Type `for ((i = 0; i < 5; i++)); do greet; done` +Sleep 1s +Enter +Sleep 0.8s + +Type `# Surely we can make that more colorful.` +Enter +Sleep 1s + +# Now with lolcat +Type `for ` +Sleep 1s +Right +Sleep 0.8s +Type ` | lolcat -F 0.3 -S 12` +Sleep 0.6s +Enter + +Sleep 3s diff --git a/userland/brush/source/docs/demos/fzf.tape b/userland/brush/source/docs/demos/fzf.tape new file mode 100644 index 0000000..788786f --- /dev/null +++ b/userland/brush/source/docs/demos/fzf.tape @@ -0,0 +1,59 @@ +# Works with https://github.com/charmbracelet/vhs +# This tape assumes that fzf and brush are already pre-installed and ready to go. + +Output sample.gif + +# Set FontFamily "CaskaydiaMono Nerd Font Mono" +Set FontSize 20 +Set Theme "Monokai Pro" +Set Width 1600 +Set Height 600 +Set CursorBlink false + +# Setup environment to launch brush in +Env HISTFILE "" +Env PS1 '$0$ ' + +# Launch brush and set up bash-completion +Hide +Type `brush --enable-highlighting --norc --noprofile --no-config` +Enter +Show + +# Enable fzf +Type `# Let's enable fzf with standard bash mode` +Sleep 0.8s +Enter +Type `eval "$(fzf --bash)"` +Sleep 0.8s +Enter +Sleep 1s + +Enter +Enter + +Type `# ^^^ we see an error above. Some key combos are missing but more are functional.` +Enter + +Enter +Enter + +# Type some things. +Type `echo 'We will type something'` +Enter +Type `echo '...just to populate history'` +Enter +Type `echo 'And now, hit Ctrl+R.'` +Enter +Sleep 1s + +# Ctrl+R +Ctrl+R +Sleep 1.5s +Up +Sleep 0.8s +Enter + +Sleep 2s +Enter +Sleep 2s diff --git a/userland/brush/source/docs/explanation/README.md b/userland/brush/source/docs/explanation/README.md new file mode 100644 index 0000000..5a3cd8b --- /dev/null +++ b/userland/brush/source/docs/explanation/README.md @@ -0,0 +1,6 @@ +# Explanation + +These documents explain how and why `brush` is built the way it is. + +* [Architecture](architecture.md): the crates, how they depend on each other, and the + principles that decide where code belongs diff --git a/userland/brush/source/docs/explanation/architecture.md b/userland/brush/source/docs/explanation/architecture.md new file mode 100644 index 0000000..26540d1 --- /dev/null +++ b/userland/brush/source/docs/explanation/architecture.md @@ -0,0 +1,89 @@ +# Architecture + +This describes how brush is split into crates, and the principles that decide which crate +a piece of code belongs in. When a change raises the question "where should this go?", +the answer should follow from here. If it doesn't, this document needs updating. + +## Crates + +| Crate | Published | Role | +|---|---|---| +| `brush-parser` | yes | Tokenizes and parses shell syntax. Knows nothing about a running shell. | +| `brush-core` | yes | The shell itself: the `Shell` type, expansion, execution, jobs, variables, traps, and programmable completion. Has no line editor or UI of its own. | +| `brush-clap-adapter` | yes | Implements `brush-core`'s engine-neutral builtin contract for builtins whose arguments are parsed with clap. | +| `brush-winnow-args-adapter` | yes | The same, for builtins whose arguments are parsed with [winnow-args](https://github.com/lu-zero/winnow-args). | +| `brush-builtins` | yes | The standard builtins, registered on a `Shell` through `brush-core`'s public API. | +| `brush-experimental-builtins` | yes | Builtins not ready to be standard. | +| `brush-coreutils-builtins` | yes | Optional builtins wrapping [uutils/coreutils](https://github.com/uutils/coreutils); doesn't depend on the other crates. | +| `brush-interactive` | yes | The interactive front end: reads input, edits the line (with reedline or a basic line reader), and presents prompts, completions, suggestions, and highlighting. | +| `brush-shell` | yes | The `brush` binary: its command line, and the wiring of the crates above into a shell. | +| `brush` | yes | A thin wrapper binary around `brush-shell`. | +| `brush-test-harness` | no | Runs the YAML test cases that compare brush with bash. | +| `brush-bundled-tests` | no | Tests the bundled-command shim end to end, against a brush with fake bundled commands. | +| `xtask`, `fuzz` | no | Development tooling. | + +Their dependencies run one way, from front end to parser. Each crate's direct dependencies +on the others (not counting dev-dependencies) are: + +```text +brush → brush-shell +brush-shell → brush-interactive, brush-builtins, brush-core, + brush-winnow-args-adapter, + brush-experimental-builtins (optional), + brush-coreutils-builtins (optional) +brush-interactive → brush-core, brush-parser +brush-builtins → brush-core, brush-parser, brush-winnow-args-adapter +brush-experimental-builtins → brush-core, brush-winnow-args-adapter +brush-clap-adapter → brush-core +brush-winnow-args-adapter → brush-core +brush-core → brush-parser +brush-parser (none) +brush-coreutils-builtins (none) +``` + +Anything a published crate exports is public API, including to people who embed brush +without its front end. Keep that in mind when deciding what to export. + +## Structure within `brush-core` + +- Shells are built with `Shell::builder()`, a type-safe builder. +- `Shell` is generic over `ShellExtensions`, which lets embedders swap in parts of its + behavior (today, how errors are formatted, how processes are launched, and how the + tasks the shell runs concurrently with itself are started) at compile time. +- Platform-specific code lives under the `sys` module, so the rest of the crate stays + platform-neutral. +- Builtins meet the shell at one contract, `brush_core::builtins`, which names no + argument-parsing engine. A builtin is a type that is its own parsed arguments: it + parses an invocation's words (`ParseArgs`), supplies its help, and runs + (`Builtin`), and is registered by type (`Registration::of`). Builtins that parse + with clap implement `ParseArgs` through `brush-clap-adapter`, and those that parse + with winnow-args through `brush-winnow-args-adapter`. +- A shell keeps its builtins' registrations, by name, in a `builtins::Registry` + (`Shell::builtins`, `Shell::builtins_mut`), which is the one way to look them up, + register them, or enable and disable them. How it stores them is private, so + that can change (to share them between a shell and its subshells, say) without + changing the API. +- State a builtin keeps between invocations (as `getopts`'s place in its arguments) + lives in the shell, kept by its type (`ExecutionContext::state_mut`), and is cloned + with the shell, so it follows subshell rules as a variable does. A program embedding + the shell can set it beforehand (`Shell::set_builtin_state`). + +## Layering principles + +These decide where a behavior belongs when more than one crate could plausibly own it. + +1. **Meaning vs. presentation.** If getting it wrong changes what the shell does -- what + command a line runs, or what a script sees -- it generally belongs in `brush-core`. + If it only changes what the user sees, or how many keystrokes something takes, it + belongs to the front end. For example, core composes a prompt from `PS1`, and the + front end draws it. A front end presents what core produces and chooses among it, + but doesn't recreate shell semantics on its own. + + Shell semantics that a front end needs are exposed as core APIs rather than + reimplemented. Helpers that only make sense inside one of core's own operations stay + private. + +2. **Layers own their state.** A feature layered above the interpreter should reach + `Shell` only through public APIs, and keep its own state. Until `Shell` can hold state + owned by other crates, such features live in `brush-core`; don't add new coupling + between them and the rest of core. diff --git a/userland/brush/source/docs/extras/brand/brush-mark-b.svg b/userland/brush/source/docs/extras/brand/brush-mark-b.svg new file mode 100644 index 0000000..f76c0ed --- /dev/null +++ b/userland/brush/source/docs/extras/brand/brush-mark-b.svg @@ -0,0 +1,29 @@ + + + + + diff --git a/userland/brush/source/docs/extras/brand/brush-wordmark-black.svg b/userland/brush/source/docs/extras/brand/brush-wordmark-black.svg new file mode 100644 index 0000000..bae30be --- /dev/null +++ b/userland/brush/source/docs/extras/brand/brush-wordmark-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/userland/brush/source/docs/extras/brand/brush-wordmark-white.svg b/userland/brush/source/docs/extras/brand/brush-wordmark-white.svg new file mode 100644 index 0000000..c56feb5 --- /dev/null +++ b/userland/brush/source/docs/extras/brand/brush-wordmark-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/userland/brush/source/docs/extras/brand/brush-wordmark.svg b/userland/brush/source/docs/extras/brand/brush-wordmark.svg new file mode 100644 index 0000000..1e00b38 --- /dev/null +++ b/userland/brush/source/docs/extras/brand/brush-wordmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/userland/brush/source/docs/extras/brand/mascot/turtle-dark.png b/userland/brush/source/docs/extras/brand/mascot/turtle-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..aa44fe6e28df36f4ae9b04128b222b30f8b086e2 GIT binary patch literal 331876 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%0A2i9JnbGLX~-rQZs<8B>e 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 Directory to install into; defaults to $XDG_BIN_HOME, or ~/.local/bin. + --require-attestation Fail if the build attestation can't be verified. + -h, --help Show this help. +EOF +} + +say() { + echo "brush-install: $*" >&2 +} + +die() { + say "error: $*" + exit 1 +} + +# curl with the settings every request here wants: https only (even across +# redirects), TLS 1.2 or newer, fail on HTTP errors, and retry transient failures. +fetch() { + curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 3 "$@" +} + +download() { + url="$1" + destination="$2" + fetch -o "${destination}" "${url}" || die "failed to download ${url}" +} + +# macOS ships shasum rather than sha256sum. +if ! command -v sha256sum >/dev/null; then + sha256sum() { + shasum -a 256 "$@" + } +fi + +# Removes whatever this script has left on disk. Runs when the script exits for +# any reason, including errors and Ctrl-C. +cleanup() { + rm -rf "${tmp_dir}" + if [ -n "${staged_binary}" ]; then + rm -f "${staged_binary}" + fi +} + +parse_args() { + version="" + canary="" + commit="" + install_dir="" + require_attestation="" + + while [ $# -gt 0 ]; do + case "$1" in + --version) + [ -n "${2:-}" ] || die "--version requires a value" + version="$2" + shift + ;; + --canary) + canary=1 + ;; + --commit) + [ -n "${2:-}" ] || die "--commit requires a value" + commit="$2" + canary=1 + shift + ;; + --dir) + [ -n "${2:-}" ] || die "--dir requires a value" + install_dir="$2" + shift + ;; + --require-attestation) + require_attestation=1 + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1 (see --help)" + ;; + esac + shift + done + + if [ -n "${canary}" ] && [ -n "${version}" ]; then + die "--version can't be combined with --canary or --commit" + fi + # The hash becomes part of a URL, so accept nothing but a full, lowercase commit hash. + if [ -n "${commit}" ]; then + case "${commit}" in + *[!0-9a-f]*) commit="" ;; # fails the length check below + esac + [ "${#commit}" -eq 40 ] || die "--commit needs a full 40-character commit hash (see \`git rev-parse\`)" + fi + + # ~/.local/bin is where the XDG base directory spec puts user executables. The spec + # has no variable for it, but XDG_BIN_HOME is a common extension (uv, among others). + if [ -z "${install_dir}" ] && [ -n "${XDG_BIN_HOME:-}" ]; then + install_dir="${XDG_BIN_HOME}" + elif [ -z "${install_dir}" ]; then + [ -n "${HOME:-}" ] || die "HOME is not set; use --dir " + install_dir="${HOME}/.local/bin" + fi +} + +# Sets `target` to the name of the release build for this machine. +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + + case "${arch}" in + x86_64 | amd64) + arch="x86_64" + ;; + aarch64 | arm64) + arch="aarch64" + ;; + *) + die "unsupported architecture: ${arch}" + ;; + esac + + case "${os}" in + Darwin) + target="${arch}-apple-darwin" + ;; + Linux) + # Prefer the glibc build, which needs glibc 2.34 or newer. Whenever that + # can't be confirmed (no getconf, a non-glibc libc, a sort without -V), + # fall back to the static musl build, which runs on any Linux. + glibc_version="$(getconf GNU_LIBC_VERSION 2>/dev/null | cut -d' ' -f2)" + # `sort -C` succeeds if its input is already in order; with -V that's a + # version comparison, so this asks whether 2.34 <= glibc_version. + if printf '2.34\n%s\n' "${glibc_version}" | sort -CV 2>/dev/null; then + target="${arch}-unknown-linux-gnu" + else + target="${arch}-unknown-linux-musl" + fi + ;; + *) + die "unsupported OS: ${os}" + ;; + esac +} + +# Sets `tag` to the git tag of the release to install, and `source_ref` to its full ref. +resolve_release_tag() { + if [ -n "${version}" ]; then + tag="brush-shell-v${version#v}" + source_ref="refs/tags/${tag}" + return + fi + + # Follow the "latest" redirect once, up front, so that every download and + # check below refers to the same release. + latest_url="$(fetch -I -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest")" || + die "could not determine latest release" + tag="${latest_url##*/}" + case "${tag}" in + brush-shell-v*) ;; + *) die "could not determine latest release" ;; + esac + source_ref="refs/tags/${tag}" +} + +# Downloads the release archive into `tmp_dir` and checks it against its +# published SHA-256 checksum. +download_archive() { + archive="brush-${target}.tar.gz" + release_url="https://github.com/${REPO}/releases/download/${tag}" + + say "downloading ${release_url}/${archive}" + download "${release_url}/${archive}" "${tmp_dir}/${archive}" + download "${release_url}/brush-${target}.sha256" "${tmp_dir}/${archive}.sha256" + + (cd "${tmp_dir}" && sha256sum -c "${archive}.sha256" >/dev/null) || die "checksum mismatch for ${archive}" + say "verified SHA-256 checksum" +} + +# Prints the value of the annotation named $1 in the (whitespace-free) `manifest`, if it +# matches the sed pattern $2. Values go straight to the terminal, so the patterns should +# admit nothing but the expected characters. +manifest_annotation() { + printf '%s' "${manifest}" | sed -n "s|.*\"$1\":\"\($2\)\".*|\1|p" +} + +# Downloads the newest canary build (or the one for `commit`) into `tmp_dir`, and checks it +# against its digest in the registry, which is the SHA-256 of its content. +download_canary_archive() { + archive="brush-${target}.tar.gz" + image_tag="${target}${commit:+-${commit}}" + registry="https://ghcr.io/v2/${CANARY_PACKAGE}" + source_ref="refs/heads/main" + + # Even public packages need a token to download, but an anonymous one does. ghcr.io refuses + # one for a package that doesn't exist or isn't public. + token="$(fetch "https://ghcr.io/token?scope=repository:${CANARY_PACKAGE}:pull" | + sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" + [ -n "${token}" ] || die "could not access canary builds at ghcr.io/${CANARY_PACKAGE}" + + manifest="$(fetch -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "${registry}/manifests/${image_tag}")" || + die "no canary build of brush found for ${target}${commit:+ at commit ${commit}}" + manifest="$(printf '%s' "${manifest}" | tr -d ' \t\r\n')" + + # The archive is the manifest's only layer. + digest="$(printf '%s' "${manifest}" | + sed -n 's/.*"layers":\[{[^}]*"digest":"sha256:\([0-9a-f]\{64\}\)".*/\1/p')" + [ -n "${digest}" ] || die "unexpected manifest for ghcr.io/${CANARY_PACKAGE}:${image_tag}" + + # Where the build came from, as the release workflow recorded it. Just for the user's + # information: only the archive itself is covered by the checks below. + built_commit="$(manifest_annotation org.opencontainers.image.revision '[0-9a-f]\{40\}')" + built_at="$(manifest_annotation org.opencontainers.image.created '[0-9T:Z-]*')" + + say "downloading ghcr.io/${CANARY_PACKAGE}:${image_tag} (${archive})" + say "canary build of commit ${built_commit:-(unknown)}, published ${built_at:-(unknown)}" + fetch -H "Authorization: Bearer ${token}" -o "${tmp_dir}/${archive}" \ + "${registry}/blobs/sha256:${digest}" || die "failed to download ${archive}" + + echo "${digest} ${archive}" >"${tmp_dir}/${archive}.sha256" + (cd "${tmp_dir}" && sha256sum -c "${archive}.sha256" >/dev/null) || die "checksum mismatch for ${archive}" + say "verified SHA-256 checksum" +} + +# Verifies the archive's build provenance attestation, when that's possible here. +# The attestation must come from the official release workflow, running on +# GitHub-hosted runners, for this release's tag (or, for canary builds, main, +# and with --commit, that exact commit). +verify_attestation() { + skip_reason="" + + if ! command -v gh >/dev/null; then + skip_reason="GitHub CLI (gh) is not installed" + elif ! gh attestation verify --help 2>/dev/null | grep -q -- --source-ref; then + skip_reason="GitHub CLI (gh) is too old (2.68 or newer is needed)" + else + gh_status=0 + # Left unquoted so it vanishes without --commit; parse_args made sure it's plain hex. + gh attestation verify "${tmp_dir}/${archive}" \ + --repo "${REPO}" \ + --signer-workflow "${RELEASE_WORKFLOW}" \ + --source-ref "${source_ref}" \ + ${commit:+--source-digest ${commit}} \ + --deny-self-hosted-runners \ + >/dev/null 2>"${tmp_dir}/gh.err" || gh_status=$? + + if [ "${gh_status}" -eq 4 ]; then + # gh exits with 4 when it has no credentials; anything else is a real failure. + skip_reason="GitHub CLI (gh) is not authenticated" + elif [ "${gh_status}" -ne 0 ]; then + cat "${tmp_dir}/gh.err" >&2 + die "attestation verification failed for ${archive}" + fi + fi + + if [ -z "${skip_reason}" ]; then + say "verified GitHub build attestation" + elif [ -n "${require_attestation}" ]; then + die "cannot verify build attestation: ${skip_reason}" + else + say "note: ${skip_reason}, so the build attestation wasn't checked; an authenticated gh (2.68+) would also verify this build came from the official release workflow" + fi +} + +# Extracts the binary and moves it into place. +install_binary() { + tar -xzf "${tmp_dir}/${archive}" -C "${tmp_dir}" brush || die "failed to extract ${archive}" + + # Stage the binary next to its destination so that the final step is a rename. + # A rename is atomic, so an interrupted install never leaves a partial binary + # behind, and it works even while an older brush at that path is running + # (overwriting a running executable in place fails on Linux). Checking that the + # staged binary runs first means a build that can't run here never replaces a + # working one. + mkdir -p "${install_dir}" || die "cannot write to ${install_dir}" + staged_binary="$(mktemp "${install_dir}/.brush.tmp.XXXXXX")" || die "cannot write to ${install_dir}" + cp "${tmp_dir}/brush" "${staged_binary}" || die "cannot write to ${install_dir}" + chmod 755 "${staged_binary}" + + installed_version="$("${staged_binary}" --version)" || + die "the ${target} build failed to run on this system; see https://brush.sh/getting-started/install/ for other ways to install" + + mv -f "${staged_binary}" "${install_dir}/brush" || die "cannot write to ${install_dir}" + say "installed ${installed_version} to ${install_dir}/brush" +} + +# Lets the user know if running `brush` won't run what was just installed. +check_path() { + # Nothing above has run `brush` by name, so this is a fresh PATH search. + found="$(command -v brush)" || true + + # shellcheck disable=SC3013 # -ef isn't POSIX, but every sh we support has it. + if [ -z "${found}" ]; then + say "note: ${install_dir} is not in your PATH" + elif [ ! "${found}" -ef "${install_dir}/brush" ]; then + say "note: running 'brush' will run ${found}, not ${install_dir}/brush" + fi +} + +main() { + parse_args "$@" + detect_target + if [ -z "${canary}" ]; then + resolve_release_tag + fi + + staged_binary="" + tmp_dir="$(mktemp -d)" + trap cleanup EXIT + # Some shells (dash, busybox ash) skip the EXIT trap when killed by a signal; + # exiting from the signal handler makes sure cleanup runs everywhere. The exit + # codes are the usual 128 + signal number. + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + + if [ -n "${canary}" ]; then + download_canary_archive + else + download_archive + fi + verify_attestation + install_binary + check_path +} + +# Wrapped in a function so that a partially downloaded script never runs. +main "$@" diff --git a/userland/brush/source/scripts/install/tests/test_install.py b/userland/brush/source/scripts/install/tests/test_install.py new file mode 100644 index 0000000..9e1fa61 --- /dev/null +++ b/userland/brush/source/scripts/install/tests/test_install.py @@ -0,0 +1,392 @@ +"""Tests for scripts/install/install.sh, run against real GitHub releases and canary builds on ghcr.io (requires +network access). + +Each test pipes the installer into `sh -s --` (and `bash -s --`), just like `curl ... | sh` would. Failure modes +are simulated with shims for curl, gh, tar, and getconf placed ahead of the real tools on PATH. + +Usage: python3 -m pytest scripts/install/tests +(Set GH_TOKEN, e.g. to `$(gh auth token)`, to also verify a real build attestation.) +""" + +import json +import os +import platform +import re +import shutil +import subprocess +import urllib.request +from pathlib import Path + +import pytest + + +INSTALLER = Path(__file__).resolve().parent.parent / "install.sh" +# Deliberately not the latest release, so tests can tell pinned installs from latest ones. +VERSION = "0.3.0" + +# Shim name -> (tool it replaces, bash body). $REAL_CURL and $REAL_TAR name the real tools. +SHIMS = { + "bad-checksum": ( + "curl", + """ + "${REAL_CURL}" "$@" || exit + prev="" + for arg; do + if [[ ${prev} == -o && ${arg} == *.sha256 ]]; then + line="$(<"${arg}")" + first=0 && [[ ${line} == 0* ]] && first=1 + echo "${first}${line:1}" >"${arg}" + fi + prev="${arg}" + done + """, + ), + "bad-attestation": ( + "gh", + """ + [[ $* == *--help ]] && { echo "--source-ref"; exit 0; } + echo "simulated attestation failure" >&2 + exit 1 + """, + ), + # gh exits 4 when it has no credentials. + "gh-unauthenticated": ( + "gh", + """ + [[ $* == *--help ]] && { echo "--source-ref"; exit 0; } + echo "To get started with GitHub CLI, please run: gh auth login" >&2 + exit 4 + """, + ), + # Accepts every attestation, recording its arguments in $GH_ARGS_FILE. + "gh-records-args": ( + "gh", + """ + [[ $* == *--help ]] && { echo "--source-ref"; exit 0; } + echo "$*" >"${GH_ARGS_FILE}" + """, + ), + # e.g. the gh shipped by Ubuntu 24.04 and Debian 12: no `attestation` subcommand at all. + "gh-too-old": ( + "gh", + """ + echo 'unknown command "attestation" for "gh"' >&2 + exit 1 + """, + ), + "broken-binary": ( + "tar", + """ + "${REAL_TAR}" "$@" || exit + while [[ $# -gt 0 ]]; do + [[ $1 == -C ]] && printf '#!/bin/sh\\nexit 1\\n' >"$2/brush" + shift + done + """, + ), + "old-glibc": ("getconf", 'echo "glibc 2.17"'), + "minimum-glibc": ("getconf", 'echo "glibc 2.34"'), +} + +GH_INSTALLED = shutil.which("gh") is not None + +# The script is POSIX sh; test it under the system sh (dash on Debian/Ubuntu, busybox ash on +# Alpine) and under bash. On macOS, also under the ancient /bin/bash (3.2) if PATH has a newer one. +SHELLS = ["sh", "bash"] +if platform.system() == "Darwin" and Path(shutil.which("bash")).resolve() != Path("/bin/bash"): + SHELLS.append("/bin/bash") + + +@pytest.fixture(params=SHELLS) +def install(request, tmp_path): + """Runs the installer with the given args, shims, and environment overrides.""" + + def run(*args, shims=(), **env_overrides): + env = os.environ | { + "REAL_CURL": shutil.which("curl"), + "REAL_TAR": shutil.which("tar"), + } + for name in shims: + tool, body = SHIMS[name] + shim = tmp_path / "shims" / name / tool + shim.parent.mkdir(parents=True, exist_ok=True) + shim.write_text("#!/usr/bin/env bash\n" + body) + shim.chmod(0o755) + env["PATH"] = f"{shim.parent}{os.pathsep}{env['PATH']}" + for key, value in env_overrides.items(): # None unsets the variable + if value is None: + env.pop(key, None) + else: + env[key] = str(value) + + return subprocess.run( + [request.param, "-s", "--", *map(str, args)], + input=INSTALLER.read_text(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + + return run + + +def assert_succeeded(result): + assert result.returncode == 0, result.stdout + # All output should be the installer's own; anything else means some tool misbehaved. + assert all(line.startswith("brush-install: ") for line in result.stdout.splitlines()), ( + result.stdout + ) + + +def assert_failed(result, message): + assert result.returncode != 0, result.stdout + # Every failure should end with the installer's own error message. + last_line = result.stdout.splitlines()[-1] + assert last_line.startswith("brush-install: error: "), result.stdout + assert message in last_line, result.stdout + + +def brush_version(path): + return subprocess.run( + [path, "--version"], capture_output=True, text=True, check=True + ).stdout + + +def test_latest_release_to_default_dir(install, tmp_path): + home = tmp_path / "home" + # Without any other brush on PATH, so the note is about PATH rather than shadowing. + path = os.pathsep.join(d for d in os.environ["PATH"].split(os.pathsep) if not (Path(d) / "brush").exists()) + result = install(HOME=home, PATH=path, XDG_BIN_HOME=None) + assert_succeeded(result) + + bin_dir = home / ".local" / "bin" + version = brush_version(bin_dir / "brush") + assert version.startswith("brush ") + assert not version.startswith(f"brush {VERSION} ") + assert os.listdir(bin_dir) == ["brush"] + assert f"note: {bin_dir} is not in your PATH" in result.stdout + + +def test_pinned_version_replaces_existing_binary(install, tmp_path): + (tmp_path / "brush").write_text("old") + result = install("--version", f"v{VERSION}", "--dir", tmp_path) + assert_succeeded(result) + assert brush_version(tmp_path / "brush").startswith(f"brush {VERSION} ") + + +def test_xdg_bin_home_is_default_dir(install, tmp_path): + xdg_bin = tmp_path / "xdg-bin" + result = install("--version", VERSION, HOME=tmp_path / "home", XDG_BIN_HOME=xdg_bin) + assert_succeeded(result) + assert os.listdir(xdg_bin) == ["brush"] + + +@pytest.mark.skipif(platform.system() != "Linux", reason="glibc detection is Linux-only") +def test_old_glibc_falls_back_to_musl(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["old-glibc"]) + assert_succeeded(result) + assert "-unknown-linux-musl.tar.gz" in result.stdout + + +def host_glibc(): + name, version = platform.libc_ver() + return tuple(map(int, version.split("."))) if name == "glibc" else None + + +# The shim only steers target selection; the gnu build still has to run on the real libc here. +@pytest.mark.skipif(not host_glibc() or host_glibc() < (2, 34), reason="gnu build needs glibc 2.34+") +def test_minimum_glibc_selects_gnu_build(install, tmp_path): + # Exactly the minimum, so an off-by-one in the version comparison shows up too. + result = install("--version", VERSION, "--dir", tmp_path, shims=["minimum-glibc"]) + assert_succeeded(result) + assert "-unknown-linux-gnu.tar.gz" in result.stdout + + +@pytest.mark.skipif(not GH_INSTALLED, reason="gh is not installed") +# CI always provides GH_TOKEN, so never skip there: a missing token should fail, not skip. +@pytest.mark.skipif(not os.environ.get("GH_TOKEN") and not os.environ.get("CI"), reason="GH_TOKEN is not set") +def test_verifies_real_attestation(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, "--require-attestation") + assert_succeeded(result) + assert "verified GitHub build attestation" in result.stdout + + +# Runs on the CI images that deliberately lack gh (see .github/workflows/install-script.yaml). +@pytest.mark.skipif(GH_INSTALLED, reason="gh is installed") +def test_missing_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path) + assert_succeeded(result) + assert "note: GitHub CLI (gh) is not installed, so the build attestation wasn't checked" in result.stdout + + +def test_unauthenticated_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["gh-unauthenticated"]) + assert_succeeded(result) + assert ( + "note: GitHub CLI (gh) is not authenticated, so the build attestation wasn't checked" + in result.stdout + ) + + +def test_too_old_gh_warns_with_reason(install, tmp_path): + result = install("--version", VERSION, "--dir", tmp_path, shims=["gh-too-old"]) + assert_succeeded(result) + assert ( + "note: GitHub CLI (gh) is too old (2.68 or newer is needed), so the build attestation wasn't checked" + in result.stdout + ) + + +def test_notes_when_another_brush_comes_first_in_path(install, tmp_path): + earlier, dest = tmp_path / "earlier", tmp_path / "dest" + earlier.mkdir() + (earlier / "brush").write_text("#!/bin/sh\n") + (earlier / "brush").chmod(0o755) + + path = os.pathsep.join([str(earlier), str(dest), os.environ["PATH"]]) + result = install("--version", VERSION, "--dir", dest, PATH=path) + assert_succeeded(result) + assert f"note: running 'brush' will run {earlier / 'brush'}, not {dest / 'brush'}" in result.stdout + + +def test_no_path_note_when_dir_is_in_path_via_symlink(install, tmp_path): + dest, link = tmp_path / "dest", tmp_path / "link" + link.symlink_to(dest) + + path = os.pathsep.join([str(link), os.environ["PATH"]]) + result = install("--version", VERSION, "--dir", dest, PATH=path) + assert_succeeded(result) + # Only the attestation note is allowed (gh may be missing or unauthenticated here). + assert not [line for line in result.stdout.splitlines() if "note:" in line and "attestation" not in line] + + +def test_latest_canary(install, tmp_path): + result = install("--canary", "--dir", tmp_path) + assert_succeeded(result) + assert "ghcr.io/reubeno/brush-canary:" in result.stdout + assert "+canary " in brush_version(tmp_path / "brush") + + +@pytest.mark.skipif(not GH_INSTALLED, reason="gh is not installed") +@pytest.mark.skipif(not os.environ.get("GH_TOKEN") and not os.environ.get("CI"), reason="GH_TOKEN is not set") +def test_verifies_real_canary_attestation(install, tmp_path): + result = install("--canary", "--dir", tmp_path, "--require-attestation") + assert_succeeded(result) + assert "verified GitHub build attestation" in result.stdout + + +def newest_canary_commit(): + """Returns the commit of the newest canary build, from its manifest on ghcr.io.""" + package = "reubeno/brush-canary" + with urllib.request.urlopen(f"https://ghcr.io/token?scope=repository:{package}:pull") as response: + token = json.load(response)["token"] + # Every build publishes all targets, so any target's tag names the same commit. + request = urllib.request.Request( + f"https://ghcr.io/v2/{package}/manifests/x86_64-unknown-linux-musl", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.oci.image.manifest.v1+json"}, + ) + with urllib.request.urlopen(request) as response: + return json.load(response)["annotations"]["org.opencontainers.image.revision"] + + +def test_pinned_canary_attestation_checks_commit(install, tmp_path): + commit = newest_canary_commit() + args_file = tmp_path / "gh-args" + result = install("--commit", commit, "--dir", tmp_path / "bin", shims=["gh-records-args"], GH_ARGS_FILE=args_file) + assert_succeeded(result) + assert re.search(rf"canary build of commit {commit}, published \d{{4}}-\d\d-\d\dT", result.stdout), result.stdout + assert f"--source-digest {commit}" in args_file.read_text() + assert f"(git:{commit[:7]}" in brush_version(tmp_path / "bin" / "brush") + + +def test_help(install): + result = install("--help", "--bogus") + assert result.returncode == 0, result.stdout + assert result.stdout.startswith("Installs brush"), result.stdout + assert "--require-attestation" in result.stdout + + +def test_unset_home_without_dir(install): + assert_failed(install("--version", VERSION, HOME=None, XDG_BIN_HOME=None), "HOME is not set") + + +@pytest.mark.parametrize( + "args, message", + [ + (["--bogus"], "unknown option: --bogus"), + (["--version"], "--version requires a value"), + (["--dir"], "--dir requires a value"), + (["--commit"], "--commit requires a value"), + (["--commit", "739a15d"], "--commit needs a full 40-character commit hash"), + (["--commit", "0" * 39 + "g"], "--commit needs a full 40-character commit hash"), + (["--canary", "--version", VERSION], "--version can't be combined with --canary or --commit"), + ], + ids=["unknown-option", "missing-version", "missing-dir", "missing-commit", "short-commit", "non-hex-commit", + "canary-and-version"], +) +def test_bad_arguments(install, args, message): + assert_failed(install(*args), message) + + +@pytest.mark.parametrize( + "args, shims, message", + [ + pytest.param( + ["--version", "0.0.0"], [], "failed to download", id="nonexistent-version" + ), + pytest.param( + ["--commit", "0" * 40], [], "no canary build of brush found", id="nonexistent-commit" + ), + pytest.param( + ["--version", VERSION], ["bad-checksum"], "checksum mismatch", id="bad-checksum" + ), + pytest.param( + ["--version", VERSION], + ["bad-attestation"], + "attestation verification failed", + id="bad-attestation", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + ["gh-unauthenticated"], + "cannot verify build attestation: GitHub CLI (gh) is not authenticated", + id="require-attestation-unauthenticated", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + ["gh-too-old"], + "cannot verify build attestation: GitHub CLI (gh) is too old", + id="require-attestation-too-old", + ), + pytest.param( + ["--version", VERSION, "--require-attestation"], + [], + "cannot verify build attestation: GitHub CLI (gh) is not installed", + id="require-attestation-no-gh", + # A real gh can't be hidden from PATH, so this only runs where gh is absent. + marks=pytest.mark.skipif(GH_INSTALLED, reason="gh is installed"), + ), + ], +) +def test_failure_installs_nothing(install, tmp_path, args, shims, message): + dest = tmp_path / "dest" + assert_failed(install(*args, "--dir", dest, shims=shims), message) + assert not dest.exists() + + +def test_unwritable_dir(install, tmp_path): + # A path beneath a regular file can't be created, even by root (as in CI containers). + (tmp_path / "file").write_text("") + dest = tmp_path / "file" / "bin" + assert_failed(install("--version", VERSION, "--dir", dest), f"cannot write to {dest}") + + +def test_broken_binary_keeps_existing_one(install, tmp_path): + dest = tmp_path / "dest" + dest.mkdir() + (dest / "brush").write_text("old") + result = install("--version", VERSION, "--dir", dest, shims=["broken-binary"]) + assert_failed(result, "build failed to run on this system") + assert os.listdir(dest) == ["brush"] + assert (dest / "brush").read_text() == "old" diff --git a/userland/brush/source/scripts/start-dev-container.py b/userland/brush/source/scripts/start-dev-container.py new file mode 100755 index 0000000..1a505f0 --- /dev/null +++ b/userland/brush/source/scripts/start-dev-container.py @@ -0,0 +1,137 @@ +#!/bin/env python3 +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument( + "--rebuild-container-image", + help="Rebuild the devcontainer image and recreate the container before entering", + dest="rebuild_container_image", + action="store_true", +) +parser.add_argument( + "--restart-container", + help="Restart the devcontainer before entering", + dest="restart_container", + action="store_true", +) +parser.add_argument( + "--command", + help="Command to run", + dest="command", + default="bash", +) +parser.add_argument( + "--quiet", + help="Only generate minimal output", + dest="quiet", + action="store_true", +) +parser.add_argument( + "--env-file", + help="Environment file to pass into the container", + dest="env_file", + type=str, +) + +args = parser.parse_args() + +if args.rebuild_container_image: + # One implies the other. + args.restart_container = True + +this_script_dir = os.path.dirname(os.path.abspath(__file__)) +source_root = os.path.join(this_script_dir, "..") + +if not os.path.isdir(os.path.join(source_root, ".devcontainer")): + sys.stderr.write("error: couldn't find repo root\n") + sys.exit(1) + +if not shutil.which("devcontainer"): + sys.stderr.write( + "error: couldn't find devcontainer CLI; did you install it as a prerequisite?\n" + ) + sys.exit(1) + +devcontainer_cli_args = [ + "devcontainer", + "up", + "--workspace-folder", + source_root, +] + +if args.rebuild_container_image or args.restart_container: + restart_args = devcontainer_cli_args.copy() + + if args.rebuild_container_image: + restart_args.append("--build-no-cache") + if args.restart_container: + restart_args.append("--remove-existing-container") + + if args.rebuild_container_image: + sys.stderr.write("Rebuilding devcontainer image...\n") + else: + sys.stderr.write("Restarting devcontainer...\n") + + subprocess.run(restart_args, check=True, capture_output=args.quiet) + +if not args.quiet: + sys.stderr.write("Ensuring devcontainer is up...\n") + +up_result = subprocess.run(devcontainer_cli_args, check=False, capture_output=True) + +if up_result.returncode != 0: + sys.stderr.write("error: devcontainer start-up failed\n") + sys.stderr.write(up_result.stderr.decode("utf-8")) + sys.exit(1) + +result_data = json.loads(up_result.stdout.decode("utf-8")) + +if "outcome" not in result_data: + sys.stderr.write("error: couldn't determine outcome of devcontainer start-up\n") + sys.exit(1) + +if result_data["outcome"] != "success": + sys.stderr.write("error: devcontainer start-up failed\n") + sys.exit(1) + +if "containerId" not in result_data: + sys.stderr.write("error: couldn't determine container ID\n") + sys.exit(1) + +if "remoteWorkspaceFolder" not in result_data: + sys.stderr.write("error: couldn't determine remote workspace folder\n") + sys.exit(1) + +container_id = result_data["containerId"] +remote_workspace_folder = result_data["remoteWorkspaceFolder"] + +if not args.quiet: + sys.stderr.write("Launching shell in devcontainer...\n") + +exec_args = [ + "docker", + "container", + "exec", + "-it", + "--workdir", + remote_workspace_folder, +] + +if args.env_file: + exec_args += ["--env-file", args.env_file] + +exec_args.append(container_id) + +exec_args += shlex.split(args.command) + +exec_result = subprocess.run(exec_args, check=False) + +sys.stderr.write("Command exited; devcontainer will remain running.\n") + +sys.exit(exec_result.returncode) diff --git a/userland/brush/source/scripts/test-across-shells.sh b/userland/brush/source/scripts/test-across-shells.sh new file mode 100755 index 0000000..df1c5b4 --- /dev/null +++ b/userland/brush/source/scripts/test-across-shells.sh @@ -0,0 +1,218 @@ +#!/bin/bash +set -euo pipefail + +# Script to test shell script execution across different shells with detailed tracing +# Usage: test-across-shells.sh --output-dir=DIR --oracle-shell=SHELL --test-shell=SHELL SCRIPT [ARGS...] + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +usage() { + cat >&2 </ + oracle/ + stdout.txt Standard output from oracle shell + stderr.txt Standard error from oracle shell + trace.txt Execution trace (set -x output) + exit_code.txt Exit code from oracle shell + test/ + stdout.txt Standard output from test shell + stderr.txt Standard error from test shell + trace.txt Execution trace (set -x output) + exit_code.txt Exit code from test shell + +The script sets BASH_XTRACEFD=3 to redirect trace output to fd 3, enables +tracing with 'set -x', and sets PS4 to include script name and line number. +EOF + exit 1 +} + +# Parse command-line arguments +output_dir="" +oracle_shell="bash" +test_shell="" +script_path="" +declare -a script_args=() +parsing_options=true + +while [[ $# -gt 0 ]]; do + if [[ "$parsing_options" == true ]]; then + case "$1" in + --output-dir=*) + output_dir="${1#*=}" + shift + ;; + --oracle-shell=*) + oracle_shell="${1#*=}" + shift + ;; + --test-shell=*) + test_shell="${1#*=}" + shift + ;; + --help|-h) + usage + ;; + --) + # Stop parsing options, everything after this is for the script + parsing_options=false + shift + ;; + -*) + echo -e "${RED}Error: Unknown option: $1${NC}" >&2 + usage + ;; + *) + # First non-option argument is the script path + script_path="$1" + parsing_options=false + shift + ;; + esac + else + # We've stopped parsing options + if [[ -z "$script_path" ]]; then + script_path="$1" + else + script_args+=("$1") + fi + shift + fi +done + +# Validate required arguments +if [[ -z "$output_dir" ]]; then + echo -e "${RED}Error: --output-dir is required${NC}" >&2 + usage +fi + +if [[ -z "$test_shell" ]]; then + echo -e "${RED}Error: --test-shell is required${NC}" >&2 + usage +fi + +if [[ -z "$script_path" ]]; then + echo -e "${RED}Error: SCRIPT path is required${NC}" >&2 + usage +fi + +if [[ ! -f "$script_path" ]]; then + echo -e "${RED}Error: Script file not found: $script_path${NC}" >&2 + exit 1 +fi + +# Verify shells exist and are executable +for shell_name in oracle_shell test_shell; do + shell_path="${!shell_name}" + if ! command -v "$shell_path" >/dev/null 2>&1; then + echo -e "${RED}Error: Shell not found or not executable: $shell_path${NC}" >&2 + exit 1 + fi +done + +# Create output directory structure +mkdir -p "$output_dir/oracle" +mkdir -p "$output_dir/test" + +# Log the parsed configuration +echo -e "${BLUE}📋 Configuration:${NC}" +echo " Oracle shell: $oracle_shell" +echo " Test shell: $test_shell" +echo " Script: $script_path" +if [[ ${#script_args[@]} -gt 0 ]]; then + echo " Arguments: ${script_args[*]}" +else + echo " Arguments: (none)" +fi +echo " Output dir: $output_dir" +echo "" + +# Function to execute script under a given shell +execute_with_shell() { + local shell_path="$1" + local output_subdir="$2" + local stdout_file="$output_dir/$output_subdir/stdout.txt" + local stderr_file="$output_dir/$output_subdir/stderr.txt" + local trace_file="$output_dir/$output_subdir/trace.txt" + local exit_code_file="$output_dir/$output_subdir/exit_code.txt" + + # Execute the shell with: + # - BASH_XTRACEFD=3 set in the environment + # - PS4 set to include script name and line number + # - fd 3 redirected to trace file + # - -x flag to enable tracing + # - stdout to stdout file + # - stderr to stderr file + # shellcheck disable=SC2016 # PS4 is expanded by the traced shell. + env BASH_XTRACEFD=3 PS4='+${BASH_SOURCE:-}:${LINENO:-}: ' \ + "$shell_path" -x "$script_path" "${script_args[@]}" \ + >"$stdout_file" \ + 2>"$stderr_file" \ + 3>"$trace_file" + + local exit_code=$? + echo "$exit_code" > "$exit_code_file" + return $exit_code +} + +# Execute under oracle shell +echo -e "${BLUE}🔍 Executing under oracle shell ($oracle_shell)...${NC}" +if execute_with_shell "$oracle_shell" "oracle"; then + oracle_exit=0 + echo -e "${GREEN}✅ Oracle shell completed successfully (exit code: 0)${NC}" +else + oracle_exit=$? + echo -e "${YELLOW}⚠️ Oracle shell exited with code: $oracle_exit${NC}" +fi + +# Execute under test shell +echo -e "${BLUE}🧪 Executing under test shell ($test_shell)...${NC}" +if execute_with_shell "$test_shell" "test"; then + test_exit=0 + echo -e "${GREEN}✅ Test shell completed successfully (exit code: 0)${NC}" +else + test_exit=$? + echo -e "${YELLOW}⚠️ Test shell exited with code: $test_exit${NC}" +fi + +# Report results +echo "" +echo -e "📁 Execution complete. Output written to: $output_dir" +echo " Oracle shell exit code: $oracle_exit" +echo " Test shell exit code: $test_exit" +echo "" + +if [[ $oracle_exit -eq $test_exit ]]; then + echo -e "${GREEN}✅ Exit codes match ✓${NC}" +else + echo -e "${RED}❌ Exit codes differ ✗${NC}" +fi + +# Exit with non-zero if the test results differ +if [[ $oracle_exit -ne $test_exit ]]; then + exit 1 +fi + +exit 0 diff --git a/userland/brush/source/scripts/test-code-coverage.sh b/userland/brush/source/scripts/test-code-coverage.sh new file mode 100755 index 0000000..7554d17 --- /dev/null +++ b/userland/brush/source/scripts/test-code-coverage.sh @@ -0,0 +1,17 @@ +#!/usr/bin/bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +workspace_root="$(realpath "${script_dir}/..")" + +export CARGO_TARGET_DIR="${workspace_root}/target/cov" + +cd "${workspace_root}" +# shellcheck source=/dev/null +source <(cargo llvm-cov show-env --export-prefix) + +cargo llvm-cov clean --workspace + +cargo test -- --show-output || true + +cargo llvm-cov report --html --ignore-filename-regex "target/.*" diff --git a/userland/brush/source/sgconfig.yml b/userland/brush/source/sgconfig.yml new file mode 100644 index 0000000..e242be1 --- /dev/null +++ b/userland/brush/source/sgconfig.yml @@ -0,0 +1,5 @@ +# ast-grep (https://ast-grep.github.io) project config: structural lint rules, +# run by the ast-grep hooks in .pre-commit-config.yaml. +ruleDirs: [.config/ast-grep/rules] +testConfigs: + - testDir: .config/ast-grep/rule-tests diff --git a/userland/brush/source/xtask/Cargo.toml b/userland/brush/source/xtask/Cargo.toml new file mode 100644 index 0000000..08d4ef8 --- /dev/null +++ b/userland/brush/source/xtask/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "xtask" +publish = false +version = "0.1.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "xtask" +path = "src/main.rs" +bench = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.102" +serde = { version = "1.0.228", features = ["derive"] } +clap = { version = "4.6.0", features = ["derive"] } +serde_json = "1.0.149" +xshell = "0.2.7" +tempfile = "3.27.0" +winnow-args = { version = "0.6.0", default-features = false } +winnow-args-man = "0.6.0" +winnow-args-markdown = "0.6.0" +winnow-args-spec = "0.6.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.184" +# Used by the unix-only containerized e2e runner to read back each adapter's JUnit. +junit-parser = "1.7.0" diff --git a/userland/brush/source/xtask/src/analyze.rs b/userland/brush/source/xtask/src/analyze.rs new file mode 100644 index 0000000..7bd5acc --- /dev/null +++ b/userland/brush/source/xtask/src/analyze.rs @@ -0,0 +1,208 @@ +//! Analysis commands for benchmarks and API diffing. +//! +//! This module provides tools for: +//! - Running performance benchmarks with optional output capture +//! - Comparing public API changes between branches using `cargo-public-api` + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use xshell::{Shell, cmd}; + +/// Run analysis and comparison tools. +#[derive(Parser)] +pub enum AnalyzeCommand { + /// Run benchmarks and output results. + Bench(BenchArgs), + /// Compare public API against a base branch. + PublicApi(PublicApiArgs), +} + +/// Arguments for benchmark analysis. +#[derive(Parser)] +pub struct BenchArgs { + /// Output file for benchmark results (bencher format). + #[clap(long, short = 'o')] + output: Option, +} + +/// Arguments for public API analysis. +#[derive(Parser)] +pub struct PublicApiArgs { + /// Base branch to compare against (e.g., 'main' or 'origin/main'). + #[clap(long, short = 'b', default_value = "origin/main")] + base: String, + + /// Output directory for API diff reports. + #[clap(long, short = 'o', default_value = "reports")] + output_dir: PathBuf, +} + +/// Run an analysis command. +pub fn run(cmd: &AnalyzeCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match cmd { + AnalyzeCommand::Bench(args) => run_bench(&sh, args, verbose), + AnalyzeCommand::PublicApi(args) => run_public_api(&sh, args, verbose), + } +} + +/// Run benchmarks using `cargo bench`. +/// +/// When an output file is specified, benchmarks are run with `--output-format bencher` +/// to produce machine-readable output suitable for CI comparison tools. +fn run_bench(sh: &Shell, args: &BenchArgs, verbose: bool) -> Result<()> { + eprintln!("Running benchmarks..."); + + if let Some(output) = &args.output { + // Run with output capture to file using tee + if verbose { + eprintln!("Running: cargo bench --workspace --benches -- --output-format bencher"); + } + let bench_output = cmd!( + sh, + "cargo bench --workspace --benches -- --output-format bencher" + ) + .read() + .context("Benchmarks failed")?; + + // Write to file + sh.write_file(output, &bench_output)?; + // Also print to stdout + println!("{bench_output}"); + } else { + // Run without file output + if verbose { + eprintln!("Running: cargo bench --workspace --benches"); + } + cmd!(sh, "cargo bench --workspace --benches") + .run() + .context("Benchmarks failed")?; + } + + eprintln!("Benchmarks completed."); + Ok(()) +} + +/// Analyze public API changes between the current branch and a base branch. +/// +/// This uses `cargo-public-api` to generate diffs for each library crate, +/// then formats them into markdown reports using a Python script. +/// Requires nightly Rust and `cargo-public-api` to be installed. +fn run_public_api(sh: &Shell, args: &PublicApiArgs, verbose: bool) -> Result<()> { + eprintln!("Analyzing public API against {}...", args.base); + + // Ensure cargo-public-api is available + if verbose { + eprintln!("Running: cargo +nightly public-api --version"); + } + cmd!(sh, "cargo +nightly public-api --version") + .run() + .context("cargo-public-api not installed. Install with: cargo install cargo-public-api")?; + + // Create output directories + let diffs_dir = args.output_dir.join("diffs"); + let reports_dir = args.output_dir.clone(); + + sh.create_dir(&diffs_dir)?; + sh.create_dir(&reports_dir)?; + + // Get list of library crates + if verbose { + eprintln!("Running: ./scripts/enum-lib-crates.sh"); + } + let crates_output = cmd!(sh, "./scripts/enum-lib-crates.sh") + .read() + .context("Failed to enumerate library crates")?; + + let crates: Vec<&str> = crates_output.lines().collect(); + + if crates.is_empty() { + eprintln!("No library crates found."); + return Ok(()); + } + + let base = &args.base; + + // Track failures to report at the end + let mut failed_crates: Vec = Vec::new(); + + for crate_name in &crates { + eprintln!("Analyzing crate: {crate_name}"); + + let diff_file = diffs_dir.join(format!("{crate_name}.txt")); + let report_file = reports_dir.join(format!("api-diff-{crate_name}.md")); + + let diff_path = diff_file.display().to_string(); + let report_path = report_file.display().to_string(); + + // Run public-api diff. + // The -sss flags are shorthand for three levels of --simplified output, + // which suppresses less relevant API details (blanket impls, auto traits, etc.) + // to focus on the most important public API changes. + // We use ignore_status() because diff returns non-zero if there are differences. + if verbose { + eprintln!("Running: cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD"); + } + let diff_result = cmd!( + sh, + "cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD" + ) + .ignore_status() + .read(); + + match diff_result { + Ok(diff_output) => { + sh.write_file(&diff_file, &diff_output)?; + + // Format the report using the Python script + if verbose { + eprintln!( + "Running: ./scripts/format-api-diff-report.py {diff_path} -p {crate_name}" + ); + } + let format_result = cmd!( + sh, + "./scripts/format-api-diff-report.py {diff_path} -p {crate_name}" + ) + .read(); + + match format_result { + Ok(report) => { + if report.trim().is_empty() { + eprintln!(" No API changes for {crate_name}"); + } else { + sh.write_file(&report_file, &report)?; + eprintln!(" Report written to {report_path}"); + } + } + Err(e) => { + eprintln!(" Error: Failed to format report for {crate_name}: {e}"); + failed_crates.push((*crate_name).to_string()); + } + } + } + Err(e) => { + eprintln!(" Error: Failed to analyze {crate_name}: {e}"); + failed_crates.push((*crate_name).to_string()); + } + } + } + + eprintln!( + "Public API analysis complete. Reports in: {}", + reports_dir.display() + ); + + // Return error if any crates failed + if !failed_crates.is_empty() { + anyhow::bail!( + "Public API analysis failed for: {}", + failed_crates.join(", ") + ); + } + + Ok(()) +} diff --git a/userland/brush/source/xtask/src/check.rs b/userland/brush/source/xtask/src/check.rs new file mode 100644 index 0000000..9e8273a --- /dev/null +++ b/userland/brush/source/xtask/src/check.rs @@ -0,0 +1,312 @@ +//! Check commands for code quality validation. +//! +//! This module provides various code quality checks that can be run individually +//! or as part of a CI workflow. Each check wraps an external tool and provides +//! consistent error handling and verbose output. +//! +//! Some checks require additional tools to be installed: +//! - `cargo-udeps`: Unused dependency detection (`cargo install cargo-udeps`, requires +//! nightly; `check unused-deps --toolchain ` picks which one, `nightly` by default) +//! - `prek`: Runs everything defined in `.pre-commit-config.yaml` -- typos, +//! zizmor, lychee, cargo-deny, and the file hygiene hooks (`cargo binstall prek`) +//! +//! Where a check is defined: checks that need project knowledge (which crates +//! sit above the workspace MSRV, how schemas are regenerated) or a component of +//! the pinned toolchain (rustfmt, clippy) live here. Third-party tools whose +//! versions nothing else pins -- including cargo subcommands such as +//! cargo-deny, which are ordinary crates rather than toolchain components -- +//! are declared in `.pre-commit-config.yaml`, where Dependabot keeps them +//! current and CI's hooks workflow runs the same `cargo xtask check hooks` a +//! contributor runs. `cargo-udeps` is the exception: its upstream hook is +//! `language: system`, so a hook entry would pin nothing, and it needs nightly. +//! +//! Note that hooks may rewrite files, exactly as they do when run as git hooks; +//! `check hooks` is a fixer, not a read-only check. + +use anyhow::{Context, Result}; +use clap::Parser; +use xshell::{Shell, cmd}; + +mod crates; + +/// Run code quality checks. +#[derive(Parser)] +pub enum CheckCommand { + /// Check that the code compiles. + Build(BuildArgs), + /// Check that every published crate follows the workspace's structural + /// rules (e.g. carries a LICENSE symlink). + Crates, + /// Check code formatting. + Fmt, + /// Run the hooks defined in `.pre-commit-config.yaml`: file hygiene, + /// spelling, workflow analysis, links, dependency audit. May rewrite files. + Hooks(HooksArgs), + /// Run clippy lints. + Lint, + /// Check that generated schemas are up-to-date. + Schemas, + /// Check for unused dependencies (requires nightly). + UnusedDeps(UnusedDepsArgs), +} + +/// Options for the build check. +#[derive(Default, Parser)] +pub struct BuildArgs { + /// Only check crates that build with the workspace-wide MSRV; for use when + /// the check is being run with the oldest toolchain the workspace as a + /// whole supports. + #[clap(long = "workspace-msrv")] + workspace_msrv: bool, +} + +/// Options for the hooks check. +#[derive(Default, Parser)] +pub struct HooksArgs { + /// Hooks to run, by id or by alias as listed in `.pre-commit-config.yaml` + /// (e.g. `spelling`, `links`, `workflows`, `deps`). Runs every hook when + /// omitted. + hooks: Vec, +} + +/// Options for the unused dependency check. +#[derive(Parser)] +pub struct UnusedDepsArgs { + /// Nightly toolchain to run cargo-udeps with, as passed to `cargo +` + /// (e.g. `nightly-2026-10-31`). + #[clap(long, value_name = "NAME", default_value = "nightly")] + toolchain: String, +} + +/// Run a check command. +pub fn run(cmd: &CheckCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match cmd { + CheckCommand::Fmt => check_fmt(&sh, verbose), + CheckCommand::Lint => check_lint(&sh, verbose), + CheckCommand::UnusedDeps(args) => check_unused_deps(&sh, args, verbose), + CheckCommand::Build(args) => check_build(&sh, args, verbose), + CheckCommand::Crates => check_crates(&sh), + CheckCommand::Schemas => check_schemas(&sh, verbose), + CheckCommand::Hooks(args) => check_hooks(&sh, args, verbose), + } +} + +/// Fails with install instructions when prek is missing, so that an absent tool +/// reads as an absent tool rather than as a failing check. +fn ensure_prek(sh: &Shell) -> Result<()> { + cmd!(sh, "prek --version") + .quiet() + .ignore_stdout() + .ignore_stderr() + .run() + .context( + "prek was not found on PATH. It runs the linters pinned in \ + .pre-commit-config.yaml. Install it with one of:\n \ + cargo binstall prek\n uv tool install prek\n brew install prek", + ) +} + +/// Runs hooks from `.pre-commit-config.yaml` over the whole tree. An empty +/// `hooks` slice runs all of them. +fn run_hooks(sh: &Shell, hooks: &[String], verbose: bool) -> Result<()> { + // prek and pre-commit set this in every hook's environment. The pre-push + // hook runs `cargo xtask ci quick`; if that ever becomes `ci full`, or a + // hook otherwise reaches back here, fail instead of recursing forever. + if std::env::var_os("PRE_COMMIT").is_some() { + anyhow::bail!("refusing to run the pre-commit hooks from inside a pre-commit hook"); + } + + ensure_prek(sh)?; + + let mut args = vec!["run", "--all-files"]; + args.extend(hooks.iter().map(String::as_str)); + + if verbose { + eprintln!("Running: prek {}", args.join(" ")); + } + + cmd!(sh, "prek {args...}").run()?; + Ok(()) +} + +fn check_fmt(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking code formatting..."); + if verbose { + eprintln!("Running: cargo fmt --check --all"); + } + cmd!(sh, "cargo fmt --check --all") + .run() + .context("Format check failed")?; + eprintln!("Format check passed."); + Ok(()) +} + +fn check_lint(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Running clippy..."); + let mut args = vec!["clippy", "--workspace", "--all-features", "--all-targets"]; + if verbose { + args.push("--verbose"); + eprintln!("Running: cargo {}", args.join(" ")); + } + cmd!(sh, "cargo {args...}") + .run() + .context("Clippy check failed")?; + eprintln!("Clippy check passed."); + Ok(()) +} + +fn check_unused_deps(sh: &Shell, args: &UnusedDepsArgs, verbose: bool) -> Result<()> { + eprintln!("Checking for unused dependencies (requires nightly)..."); + let toolchain = format!("+{}", args.toolchain); + if verbose { + eprintln!("Running: cargo {toolchain} udeps --workspace --all-targets --all-features"); + } + cmd!( + sh, + "cargo {toolchain} udeps --workspace --all-targets --all-features" + ) + .run() + .context("Unused dependency check failed")?; + eprintln!("Unused dependency check passed."); + Ok(()) +} + +/// Turns a `rust-version` value into something orderable. +fn msrv_key(version: &str) -> Vec { + version + .split('.') + .map(|part| part.parse().unwrap_or(0)) + .collect() +} + +/// Finds the workspace crates that declare a `rust-version` higher than the +/// lowest one in the workspace, and so can't be built with the oldest toolchain +/// the workspace as a whole supports. Derived from cargo metadata so that the +/// manifests remain the only place this is recorded. +fn crates_above_workspace_msrv(sh: &Shell) -> Result> { + #[derive(serde::Deserialize)] + struct Metadata { + packages: Vec, + } + + #[derive(serde::Deserialize)] + struct Package { + name: String, + rust_version: Option, + } + + // `--no-deps` narrows the output to workspace members. + let json = cmd!(sh, "cargo metadata --no-deps --format-version 1") + .quiet() + .read() + .context("Failed to read cargo metadata")?; + let metadata: Metadata = + serde_json::from_str(&json).context("Failed to parse cargo metadata")?; + + let Some(workspace_msrv) = metadata + .packages + .iter() + .filter_map(|p| p.rust_version.as_deref()) + .map(msrv_key) + .min() + else { + return Ok(Vec::new()); + }; + + Ok(metadata + .packages + .iter() + .filter(|p| { + p.rust_version + .as_deref() + .is_some_and(|v| msrv_key(v) > workspace_msrv) + }) + .map(|p| p.name.clone()) + .collect()) +} + +fn check_build(sh: &Shell, args: &BuildArgs, verbose: bool) -> Result<()> { + eprintln!("Checking that code compiles..."); + + let excluded = if args.workspace_msrv { + crates_above_workspace_msrv(sh)? + } else { + Vec::new() + }; + if !excluded.is_empty() { + eprintln!( + "Skipping crates with a higher MSRV than the workspace: {}", + excluded.join(", ") + ); + } + + let mut args = vec!["check", "--all-features", "--all-targets", "--workspace"]; + for name in &excluded { + args.push("--exclude"); + args.push(name); + } + if verbose { + args.push("--verbose"); + eprintln!("Running: cargo {}", args.join(" ")); + } + cmd!(sh, "cargo {args...}") + .run() + .context("Build check failed")?; + eprintln!("Build check passed."); + Ok(()) +} + +fn check_schemas(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking generated schemas..."); + + // Regenerate schemas to a temporary state to compare against committed versions. + if verbose { + eprintln!( + "Running: cargo run --package xtask -- gen schema config --out schemas/config.schema.json" + ); + } + cmd!( + sh, + "cargo run --package xtask -- gen schema config --out schemas/config.schema.json" + ) + .run() + .context("Failed to regenerate schemas")?; + + // Check for drift by capturing the diff output. + // We don't use --exit-code here because we want to capture and display the + // actual differences to help the user understand what changed. + if verbose { + eprintln!("Running: git diff schemas/"); + } + let diff_output = cmd!(sh, "git diff schemas/") + .read() + .context("Failed to run git diff on schemas directory")?; + + if !diff_output.is_empty() { + // Show the user exactly what changed so they can understand the drift. + eprintln!("\nSchema drift detected. The following changes were found:\n"); + eprintln!("{diff_output}"); + anyhow::bail!( + "Generated schemas are out of date. Please run 'cargo xtask gen schema config --out schemas/config.schema.json' and commit the changes." + ); + } + + eprintln!("Schema check passed."); + Ok(()) +} + +fn check_crates(sh: &Shell) -> Result<()> { + eprintln!("Checking crate structure..."); + crates::check(sh).context("Crate structure check failed")?; + eprintln!("Crate structure check passed."); + Ok(()) +} + +fn check_hooks(sh: &Shell, args: &HooksArgs, verbose: bool) -> Result<()> { + eprintln!("Running pre-commit hooks..."); + run_hooks(sh, &args.hooks, verbose).context("Pre-commit hooks failed")?; + eprintln!("Pre-commit hooks passed."); + Ok(()) +} diff --git a/userland/brush/source/xtask/src/check/crates.rs b/userland/brush/source/xtask/src/check/crates.rs new file mode 100644 index 0000000..4c2be77 --- /dev/null +++ b/userland/brush/source/xtask/src/check/crates.rs @@ -0,0 +1,105 @@ +//! Structural rules that every published workspace crate must satisfy: files it +//! must carry, metadata it must declare. Add a rule by writing a function with +//! the [`Rule`] signature and listing it in [`RULES`]. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use xshell::{Shell, cmd}; + +/// A published workspace crate, as seen by the rules. +pub struct Crate { + /// The package name. + pub name: String, + /// The crate's directory (the one holding its `Cargo.toml`). + pub dir: PathBuf, +} + +/// Checks one crate, given the workspace root. Returns `Ok(Some(problem))` for +/// a violation; `Err` is reserved for failing to perform the check at all. +type Rule = fn(&Path, &Crate) -> Result>; + +/// Every rule, with the name it is reported under. +const RULES: &[(&str, Rule)] = &[("LICENSE symlink", license_symlink)]; + +/// Runs every rule against every published crate, reporting all violations +/// before failing. +pub fn check(sh: &Shell) -> Result<()> { + let (workspace_root, crates) = published_crates(sh)?; + + let mut violations = 0; + for krate in crates { + for (rule_name, rule) in RULES { + if let Some(problem) = rule(&workspace_root, &krate) + .with_context(|| format!("Failed to check {rule_name} for {}", krate.name))? + { + eprintln!(" {}: {rule_name}: {problem}", krate.name); + violations += 1; + } + } + } + + if violations > 0 { + anyhow::bail!("{violations} crate rule violation(s) found"); + } + Ok(()) +} + +/// Returns the workspace root and the workspace members that are published to +/// crates.io. +fn published_crates(sh: &Shell) -> Result<(PathBuf, Vec)> { + #[derive(serde::Deserialize)] + struct Metadata { + workspace_root: PathBuf, + packages: Vec, + } + + #[derive(serde::Deserialize)] + struct Package { + name: String, + manifest_path: PathBuf, + // `null` when publishable anywhere; `[]` for `publish = false`. + publish: Option>, + } + + // `--no-deps` narrows the output to workspace members. + let json = cmd!(sh, "cargo metadata --no-deps --format-version 1") + .quiet() + .read() + .context("Failed to read cargo metadata")?; + let metadata: Metadata = + serde_json::from_str(&json).context("Failed to parse cargo metadata")?; + + let crates = metadata + .packages + .into_iter() + .filter(|p| p.publish.as_ref().is_none_or(|r| !r.is_empty())) + .filter_map(|p| { + let dir = p.manifest_path.parent()?.to_path_buf(); + Some(Crate { name: p.name, dir }) + }) + .collect(); + + Ok((metadata.workspace_root, crates)) +} + +/// The crate must carry a `LICENSE` that is a symlink to the workspace's +/// `LICENSE`, so `cargo package` ships the license text. +/// +/// A no-op off Unix: Git for Windows checks symlinks out as plain files by +/// default, so the link can't be judged from the working tree there. +fn license_symlink(workspace_root: &Path, krate: &Crate) -> Result> { + if cfg!(not(unix)) { + return Ok(None); + } + + let path = krate.dir.join("LICENSE"); + let shown = path.strip_prefix(workspace_root).unwrap_or(&path).display(); + + let expected = workspace_root + .join("LICENSE") + .canonicalize() + .context("Failed to find the workspace LICENSE")?; + let ok = path.is_symlink() && path.canonicalize().is_ok_and(|p| p == expected); + Ok((!ok).then(|| format!("{shown} must be a symlink to the workspace LICENSE"))) +} diff --git a/userland/brush/source/xtask/src/ci.rs b/userland/brush/source/xtask/src/ci.rs new file mode 100644 index 0000000..9995f31 --- /dev/null +++ b/userland/brush/source/xtask/src/ci.rs @@ -0,0 +1,224 @@ +//! CI workflow commands that aggregate multiple checks and tests. +//! +//! This module provides composite workflows that run multiple checks in sequence: +//! +//! ## Quick workflow (`cargo xtask ci quick`) +//! +//! Fast inner-loop checks (~7s warm cache) for rapid iteration, using nothing +//! but the Rust toolchain: +//! 1. **Format check** - Fast, catches formatting issues early +//! 2. **Crate structure check** - Published crates carry required files (e.g. LICENSE) +//! 3. **Build check** - Ensures code compiles with all features +//! 4. **Lint check** - Clippy warnings that should be addressed +//! 5. **Unit tests** - Fast tests excluding integration test binaries +//! +//! ## Full workflow (`cargo xtask ci full`) +//! +//! Comprehensive validation (~60s warm cache) before opening a pull request: +//! 1. All quick workflow checks +//! 2. **Pre-commit hooks** - File hygiene, spelling, workflow analysis, links, +//! and the cargo-deny audit: everything in `.pre-commit-config.yaml` +//! 3. **Schema check** - Verifies generated schemas are up-to-date +//! 4. **Integration tests** - Full workspace tests including compat tests +//! +//! The ordering is intentional: fast checks run first to provide quick feedback, +//! with slower comprehensive tests running last. +//! +//! `ci full` reproduces every CI check that does not need a different machine or +//! toolchain. What it deliberately does not cover, because CI alone can: the +//! three-OS matrix, the MSRV toolchain, cross-compilation, coverage, benchmarks, +//! the bash-completion suite, the nightly-only `check unused-deps` and +//! `analyze public-api`, `CodeQL`, and SARIF upload. + +use anyhow::Result; +use clap::Parser; + +use crate::check::{self, BuildArgs, CheckCommand, HooksArgs}; +use crate::test::{ + self, BinaryArgs, IntegrationTestArgs, TestCommand, TestSubcommand, UnitTestArgs, +}; + +/// Type alias for a named step in a CI workflow. +type Step<'a> = (&'a str, Box Result<()> + 'a>); + +/// Run CI workflows. +#[derive(Parser)] +pub enum CiCommand { + /// Run quick inner-loop checks: fmt, build, lint, unit tests (~7s warm). + /// + /// Use this for rapid iteration during development. Needs no tools beyond + /// the Rust toolchain, which is why it is also what the optional pre-push + /// git hook runs. + Quick(QuickArgs), + + /// Run the full workflow: quick + pre-commit hooks, schemas, integration tests (~60s warm). + /// + /// This runs every check that should pass before opening a pull request. + /// Requires `prek` in addition to the Rust toolchain; pass `--no-hooks` + /// to skip the prek-backed portion, which includes the cargo-deny audit. + Full(FullArgs), +} + +/// Arguments for quick workflow. +#[derive(Parser)] +pub struct QuickArgs { + /// Continue running checks even if one fails. + #[clap(short = 'k', long)] + continue_on_error: bool, +} + +/// Arguments for the full workflow. +#[derive(Parser)] +pub struct FullArgs { + /// Continue running checks even if one fails. + #[clap(short = 'k', long)] + continue_on_error: bool, + + /// Skip the pre-commit hooks (including the cargo-deny audit), which + /// require prek to be installed. + #[clap(long)] + no_hooks: bool, +} + +/// Run a CI workflow command. +pub fn run(cmd: &CiCommand, verbose: bool) -> Result<()> { + match cmd { + CiCommand::Quick(args) => run_quick(args, verbose), + CiCommand::Full(args) => run_full(args, verbose), + } +} + +/// Create a `TestCommand` for unit tests. +fn make_unit_test_command() -> TestCommand { + TestCommand { + binary_args: BinaryArgs { + brush_path: None, + profile: crate::common::BuildProfile::Debug, + debug: false, + release: false, + }, + subcommand: TestSubcommand::Unit(UnitTestArgs::default()), + } +} + +/// Create a `TestCommand` for integration tests. +fn make_integration_test_command() -> TestCommand { + TestCommand { + binary_args: BinaryArgs { + brush_path: None, + profile: crate::common::BuildProfile::Debug, + debug: false, + release: false, + }, + subcommand: TestSubcommand::Integration(IntegrationTestArgs::default()), + } +} + +/// The checks shared by every workflow, fastest first. +fn quick_steps(verbose: bool) -> Vec> { + vec![ + ( + "Format check", + Box::new(move || check::run(&CheckCommand::Fmt, verbose)), + ), + ( + "Crate structure check", + Box::new(move || check::run(&CheckCommand::Crates, verbose)), + ), + ( + "Build check", + Box::new(move || check::run(&CheckCommand::Build(BuildArgs::default()), verbose)), + ), + ( + "Lint check", + Box::new(move || check::run(&CheckCommand::Lint, verbose)), + ), + ( + "Unit tests", + Box::new(move || test::run(&make_unit_test_command(), verbose)), + ), + ] +} + +/// Run quick inner-loop checks (~7s warm cache). +fn run_quick(args: &QuickArgs, verbose: bool) -> Result<()> { + eprintln!("Running quick checks...\n"); + run_steps( + &quick_steps(verbose), + args.continue_on_error, + "Quick checks", + ) +} + +/// Run the full pre-PR workflow (~60s warm cache). +fn run_full(args: &FullArgs, verbose: bool) -> Result<()> { + eprintln!("Running full checks...\n"); + + let mut steps = quick_steps(verbose); + + if args.no_hooks { + eprintln!("Skipping pre-commit hooks (--no-hooks).\n"); + } else { + steps.push(( + "Pre-commit hooks", + Box::new(move || check::run(&CheckCommand::Hooks(HooksArgs::default()), verbose)), + )); + } + + steps.extend([ + ( + "Schema check", + Box::new(move || check::run(&CheckCommand::Schemas, verbose)) + as Box Result<()>>, + ), + ( + "Integration tests", + Box::new(move || test::run(&make_integration_test_command(), verbose)), + ), + ]); + + run_steps(&steps, args.continue_on_error, "Full checks") +} + +/// Run a series of steps, optionally continuing on error. +fn run_steps(steps: &[Step<'_>], continue_on_error: bool, workflow_name: &str) -> Result<()> { + let mut failures: Vec<&str> = Vec::new(); + + for (name, step) in steps { + eprintln!("\n{}", "=".repeat(60)); + eprintln!("Running: {name}"); + eprintln!("{}\n", "=".repeat(60)); + + if let Err(e) = step() { + eprintln!("\n❌ {name} failed: {e}"); + if !continue_on_error { + // `main` prints the full cause chain of a propagated error. + return Err(e); + } + // Nothing downstream ever sees this error, so print its cause + // chain here; that is where install hints and tool output live. + for cause in e.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + failures.push(name); + } else { + eprintln!("\n✅ {name} passed"); + } + } + + if !failures.is_empty() { + eprintln!("\n{}", "=".repeat(60)); + eprintln!("{workflow_name} completed with failures:"); + for name in &failures { + eprintln!(" ❌ {name}"); + } + eprintln!("{}", "=".repeat(60)); + anyhow::bail!("{} check(s) failed", failures.len()); + } + + eprintln!("\n{}", "=".repeat(60)); + eprintln!("✅ All {workflow_name} passed!"); + eprintln!("{}", "=".repeat(60)); + + Ok(()) +} diff --git a/userland/brush/source/xtask/src/common.rs b/userland/brush/source/xtask/src/common.rs new file mode 100644 index 0000000..492bc1c --- /dev/null +++ b/userland/brush/source/xtask/src/common.rs @@ -0,0 +1,167 @@ +//! Common utilities shared across xtask commands. +//! +//! This module provides shared functionality for: +//! - Build profile selection (debug vs release) +//! - Workspace root and target directory discovery +//! - Brush binary location for test commands + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use xshell::{Shell, cmd}; + +/// Build profile for selecting which binary to use. +#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq)] +pub enum BuildProfile { + /// Debug build (target/debug/). + #[default] + Debug, + /// Release build (target/release/). + Release, +} + +impl BuildProfile { + /// Returns the target subdirectory name for this profile. + #[cfg(unix)] + #[must_use] + pub const fn target_dir_name(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + } + } +} + +/// Find the workspace root directory. +/// +/// This walks up from the xtask crate directory to find the workspace root +/// (the directory containing the top-level Cargo.toml with [workspace]). +#[cfg(unix)] +pub fn find_workspace_root() -> Result { + // Start from the xtask crate directory + let xtask_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + // The workspace root is the parent of xtask/ + let workspace_root = xtask_dir + .parent() + .context("Failed to find workspace root (parent of xtask)")?; + + Ok(workspace_root.to_path_buf()) +} + +/// The parts of `cargo metadata` that xtask reads. +#[derive(serde::Deserialize)] +struct Metadata { + target_directory: PathBuf, + packages: Vec, +} + +#[derive(serde::Deserialize)] +struct Package { + name: String, + version: String, +} + +fn cargo_metadata() -> Result { + let sh = Shell::new()?; + // `--no-deps` skips resolving the dependency graph. + let json = cmd!(sh, "cargo metadata --no-deps --format-version 1") + .quiet() + .read() + .context("Failed to read cargo metadata")?; + serde_json::from_str(&json).context("Failed to parse cargo metadata") +} + +/// Find the directory cargo builds into, wherever `CARGO_TARGET_DIR`, +/// `build.target-dir` or the default puts it. +pub fn find_target_dir() -> Result { + Ok(cargo_metadata()?.target_directory) +} + +/// Find the version of the workspace's package `name`. +pub fn find_package_version(name: &str) -> Result { + cargo_metadata()? + .packages + .into_iter() + .find(|package| package.name == name) + .map(|package| package.version) + .with_context(|| format!("No package named {name} in the workspace")) +} + +/// Find the brush binary path for the given build profile. +/// +/// If `override_path` is provided, it is used directly (after validation). +/// Otherwise, the binary is located in the workspace's target directory +/// based on the specified profile. +#[cfg(unix)] +pub fn find_brush_binary( + override_path: Option<&PathBuf>, + profile: BuildProfile, +) -> Result { + let binary_path = if let Some(path) = override_path { + path.clone() + } else { + let binary_name = if cfg!(windows) { "brush.exe" } else { "brush" }; + find_target_dir()? + .join(profile.target_dir_name()) + .join(binary_name) + }; + + // Canonicalize to get absolute path and verify existence + let canonical_path = binary_path.canonicalize().with_context(|| { + format!( + "Brush binary not found at: {} (profile: {:?}). Did you run `cargo build{}`?", + binary_path.display(), + profile, + if profile == BuildProfile::Release { + " --release" + } else { + "" + } + ) + })?; + + Ok(canonical_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn test_profile_dir_names() { + assert_eq!(BuildProfile::Debug.target_dir_name(), "debug"); + assert_eq!(BuildProfile::Release.target_dir_name(), "release"); + } + + #[cfg(unix)] + #[test] + fn test_find_workspace_root() { + let root = find_workspace_root(); + assert!(root.is_ok(), "Should find workspace root"); + let root = root.unwrap(); + // The workspace root should contain Cargo.toml + assert!(root.join("Cargo.toml").exists()); + // And it should contain the xtask directory + assert!(root.join("xtask").exists()); + } + + #[test] + fn test_find_target_dir() { + // Wherever cargo was told to build, this test was built there. (The + // running executable's path has its symlinks resolved.) + let exe = std::env::current_exe().unwrap().canonicalize().unwrap(); + assert!(exe.starts_with(find_target_dir().unwrap().canonicalize().unwrap())); + } + + #[test] + fn test_find_package_version() { + assert_eq!( + find_package_version("xtask").unwrap(), + env!("CARGO_PKG_VERSION") + ); + assert!(find_package_version("no-such-package").is_err()); + } +} diff --git a/userland/brush/source/xtask/src/e2e.rs b/userland/brush/source/xtask/src/e2e.rs new file mode 100644 index 0000000..23558f6 --- /dev/null +++ b/userland/brush/source/xtask/src/e2e.rs @@ -0,0 +1,1676 @@ +//! Containerized end-to-end tests that run real applications' own shell-integration +//! suites against the shell under test. +//! +//! Each adapter lives in `e2e//`: a Dockerfile whose entrypoint runs the suite and +//! writes `log.txt` plus `JUnit` XML under `/results`. This module builds and runs those +//! images, then decides each adapter's result from the `JUnit` it left behind, weighed +//! against the adapter's `xfail-list.txt`. See `e2e/README.md` for the full contract. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs; +use std::io::{IsTerminal, Write as _}; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use clap::Args; +use clap::builder::styling::{AnsiColor, Style}; + +use crate::common::find_workspace_root; +use crate::test::BinaryArgs; + +/// Arguments for containerized end-to-end tests. +#[derive(Args, Clone)] +pub struct E2eArgs { + /// Applications to test. Runs all adapters except the opt-in ones (blesh, bash-tests, + /// bash-completion) when omitted. + #[clap(value_name = "APP")] + apps: Vec, + + /// Add an opt-in adapter to the default selection. May be repeated. + #[clap(long = "with", value_name = "APP", conflicts_with = "apps")] + with: Vec, + + /// Test the applications against Bash instead of brush. + #[clap(long, conflicts_with = "shell")] + baseline: bool, + + /// Test the applications against another shell binary. + #[clap(long, value_name = "PATH", conflicts_with = "baseline")] + shell: Option, + + /// Seconds an adapter's container may run before it is killed. Bounds a hung shell: only + /// ble.sh bounds itself, and its suite alone takes about nine minutes. + #[clap(long, value_name = "SECS", default_value_t = 1800)] + timeout: u64, + + /// Root directory for retained run directories containing per-application results. + #[clap(long, value_name = "DIR", default_value = "target/e2e")] + results_dir: PathBuf, + + /// Write a Markdown dashboard of the run (one row per adapter) to this file, for CI to post. + #[clap(long, value_name = "PATH")] + summary_output: Option, + + /// Arguments passed to a single selected adapter, after `--`. + #[clap(last = true, value_name = "ARG")] + adapter_args: Vec, +} + +pub fn run(binary_args: &BinaryArgs, args: &E2eArgs, verbose: bool) -> Result<()> { + let workspace = find_workspace_root()?; + let e2e_dir = workspace.join("e2e"); + let apps = select_e2e_apps(&e2e_dir, args)?; + let shell = resolve_shell(binary_args, args)?; + // Read up front: an unreadable list should fail before any container time is spent, not + // halfway through a run whose earlier reports would then be lost. `--baseline`/`--shell` + // runs carry no expectations, since those describe brush. + let expectations: Vec> = if args.baseline || args.shell.is_some() { + vec![BTreeSet::new(); apps.len()] + } else { + apps.iter() + .map(|app| read_test_list(&e2e_dir.join(app).join("xfail-list.txt"))) + .collect::>()? + }; + + let results_root = if args.results_dir.is_absolute() { + args.results_dir.clone() + } else { + workspace.join(&args.results_dir) + }; + let docker = std::env::var_os("DOCKER").unwrap_or_else(|| "docker".into()); + let results_root = create_e2e_results_root(&results_root)?; + + let shell_name = shell.as_deref().map_or_else( + || "bash".to_owned(), + |path| { + path.file_name() + .unwrap_or(path.as_os_str()) + .to_string_lossy() + .into_owned() + }, + ); + eprintln!( + " Starting {} e2e adapter(s) against {shell_name}", + apps.len() + ); + eprintln!( + " Results {}// (log.txt, junit/*.xml)", + results_root.display() + ); + + let overall = Instant::now(); + let terminal = std::io::stderr().is_terminal(); + let color = terminal + && std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty()) + && std::env::var_os("TERM").is_none_or(|value| value != "dumb"); + // On a terminal, name the adapter while it runs: blesh alone takes about nine minutes, and a + // silent wait that long reads as a hang. Piped output skips the marker so logs stay clean, as + // does `--verbose`, whose streamed output would scroll over it anyway. + let progress = terminal && !verbose; + let run = E2eRun { + docker: &docker, + e2e_dir: &e2e_dir, + results_root: &results_root, + shell: shell.as_deref(), + adapter_args: &args.adapter_args, + timeout: Duration::from_secs(args.timeout), + verbose, + }; + let mut reports = Vec::new(); + for (app, expected) in apps.iter().zip(&expectations) { + if progress { + let style = e2e_style(color, AnsiColor::Cyan); + eprint!(" {style}run {style:#} [ ] {app}\r"); + let _ = std::io::stderr().flush(); + } + let started = Instant::now(); + let outcome = run_e2e_adapter(&run, app); + let elapsed = started.elapsed(); + let results = results_root.join(app); + let mut report = AdapterReport::new( + app.clone(), + // Shown to the user, so prefer the short workspace-relative form. + results + .strip_prefix(&workspace) + .unwrap_or(&results) + .to_path_buf(), + elapsed, + outcome, + read_junit_summary(&results, expected, args.adapter_args.is_empty()), + ); + report.select_args = read_select_args(&e2e_dir.join(app))?; + if e2e_dir.join(app).join(REPORT_ONLY).is_file() { + report.make_report_only(); + } + eprintln!("{}", report.line(color)); + reports.push(report); + } + + // Written before the summary, which fails the process when an adapter did: the dashboard is + // most needed then. + if let Some(path) = &args.summary_output { + fs::write(path, render_markdown(&reports, overall.elapsed())) + .with_context(|| format!("failed to write summary: {}", path.display()))?; + } + report_e2e_results(&reports, overall.elapsed(), color) +} + +/// The run as a Markdown dashboard: a one-line verdict, a table with one row per adapter, then a +/// collapsible list of names for each thing worth acting on. A clean run is the verdict and the +/// table alone. +fn render_markdown(reports: &[AdapterReport], elapsed: Duration) -> String { + use std::fmt::Write as _; + + /// Blank for zero, so the eye lands on the counts that matter. + fn cell(n: usize) -> String { + if n == 0 { String::new() } else { n.to_string() } + } + + let failed = reports.iter().filter(|report| report.failed).count(); + let mut totals = JunitSummary::default(); + for summary in reports.iter().filter_map(|report| report.summary.as_ref()) { + totals.tests += summary.tests; + totals.xfailed += summary.xfailed; + totals.failures.extend_from_slice(&summary.failures); + totals.skips.extend_from_slice(&summary.skips); + } + + // Known gaps keep a pass from reading as all clear, as the terminal's yellow does. + let verdict = |failed: bool, gaps: bool| { + if failed { + "❌" + } else if gaps { + "🟡" + } else { + "✅" + } + }; + let excused = reports.iter().any(AdapterReport::has_excused_failures); + let mut md = String::from("## 🧪 End-to-end suites\n\n"); + let _ = write!( + md, + "**{} {}/{} adapters passed** · {} tests · {} ✅ ({})", + verdict(failed > 0, totals.xfailed > 0 || excused), + reports.len() - failed, + reports.len(), + totals.tests, + totals.passed(), + totals.pass_rate(), + ); + for (n, emoji) in [ + (totals.failures.len(), "❌"), + (totals.xfailed, "❎"), + (totals.skips.len(), "⏩"), + ] { + if n > 0 { + let _ = write!(md, " · {n} {emoji}"); + } + } + let _ = writeln!(md, " · ⏱ {}\n", format_duration(elapsed)); + + md.push_str("| | adapter | % pass | ✅ pass | ❌ fail | ❎ xfail | ⏩ skip | ⏱ time |\n"); + md.push_str("|:--:|---------|-------:|--------:|--------:|---------:|--------:|--------:|\n"); + for report in reports { + let (status, cells) = match &report.summary { + Some(summary) => ( + if report.has_excused_failures() { + "📊" + } else { + verdict(report.failed, summary.xfailed > 0) + }, + [ + summary.pass_rate(), + cell(summary.passed()), + cell(summary.failures.len()), + cell(summary.xfailed), + cell(summary.skips.len()), + ], + ), + None => ("💥", std::array::from_fn(|_| "—".to_owned())), + }; + let _ = writeln!( + md, + "| {status} | {} | {} | {} | {} | {} | {} | {} |", + report.app, + cells[0], + cells[1], + cells[2], + cells[3], + cells[4], + format_duration(report.elapsed) + ); + } + + for report in reports { + render_markdown_details(&mut md, report); + } + md +} + +/// The first lines of a runner's failure message, indented for a code block inside a list item. +/// pytest and minitest put the assertion here; the traceback stays in the log. +fn excerpt(message: &str) -> String { + const LINES: usize = 6; + const WIDTH: usize = 200; + let lines: Vec<&str> = message.lines().map(str::trim_end).collect(); + let mut shown: Vec = lines + .iter() + .take(LINES) + .map(|line| { + let mut line = (*line).to_owned(); + if line.chars().count() > WIDTH { + line = line.chars().take(WIDTH).chain("…".chars()).collect(); + } + format!(" {line}") + }) + .collect(); + if lines.len() > LINES { + shown.push(" …".to_owned()); + } + shown.join("\n") +} + +/// The collapsible lists under the table for one adapter: the test names grouped by what to do +/// about them, and the run error when no test failure explains a failed adapter. +fn render_markdown_details(md: &mut String, report: &AdapterReport) { + use std::fmt::Write as _; + + let app = &report.app; + let names = |md: &mut String, open: bool, heading: &str, names: &[String]| { + let open = if open { " open" } else { "" }; + let _ = writeln!(md, "\n{heading}\n"); + for name in names { + let _ = writeln!(md, "- `{name}`"); + // Only failures carry a message; for them, what the runner said and how to see it + // again, without opening any archive. + if let Some(message) = report + .summary + .as_ref() + .and_then(|summary| summary.failure_messages.get(name)) + { + let message = excerpt(message); + if !message.is_empty() { + let _ = writeln!(md, " ```\n{message}\n ```"); + } + let _ = writeln!(md, " Reproduce: `{}`", report.reproduce(name)); + } + } + md.push_str("

S(z3QFHVbWx^!1zP83oH@@o}Id|;e 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�Q+d->;r)^4a&Vt?e) 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 A5dZ)H literal 0 HcmV?d00001 diff --git a/userland/brush/source/docs/extras/brand/mascot/turtle-light.png b/userland/brush/source/docs/extras/brand/mascot/turtle-light.png new file mode 100644 index 0000000000000000000000000000000000000000..8c68420885cc72094847690c2a6c461bcc44d2b6 GIT binary patch literal 197574 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�#aBX>L(9kWZw7L7iJa+kmkEP91 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%0DwSG7@)Re{9hpe&>Db1u;;W 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 literal 0 HcmV?d00001 diff --git a/userland/brush/source/docs/how-to/README.md b/userland/brush/source/docs/how-to/README.md new file mode 100644 index 0000000..d852fa6 --- /dev/null +++ b/userland/brush/source/docs/how-to/README.md @@ -0,0 +1,8 @@ +# How-to guides + +* [How to build](build.md) +* [How to run tests](run-tests.md) +* [How to run benchmarks](run-benchmarks.md) +* [How to release](release.md) +* [How to upgrade the MSRV](upgrade-msrv.md) +* [How to record "tapes"](record-tapes.md) diff --git a/userland/brush/source/docs/how-to/build.md b/userland/brush/source/docs/how-to/build.md new file mode 100644 index 0000000..864a6fa --- /dev/null +++ b/userland/brush/source/docs/how-to/build.md @@ -0,0 +1,5 @@ +# How to build and run + +1. Install Rust toolchain. We recommend using [rustup](https://rustup.rs/). +1. Build `brush`: `cargo build` +1. Run `brush`: `cargo run` diff --git a/userland/brush/source/docs/how-to/record-tapes.md b/userland/brush/source/docs/how-to/record-tapes.md new file mode 100644 index 0000000..ac6f2e4 --- /dev/null +++ b/userland/brush/source/docs/how-to/record-tapes.md @@ -0,0 +1,16 @@ +# How to record "tapes" + +Under the `docs/demos` directory of this repo, we have some `.tape` files checked in. +These are interactive scripts for recording screencast-style demos of `brush` using +the [`VHS` tool](https://github.com/charmbracelet/vhs). + +## Install `vhs` + +You first need to install `vhs`. For consistency, we've found it easiest to install +`golang` and then follow the instructions on the `vhs` github page to install via +`go install`. (Also note that there are some native prerequisites required.) + +## Run `vhs` + +To run `vhs` against the `.tape` file you may need to use `VHS_NO_SANDBOX=1`. For more +details see [this issue on GitHub](https://github.com/charmbracelet/vhs/issues/504). diff --git a/userland/brush/source/docs/how-to/release.md b/userland/brush/source/docs/how-to/release.md new file mode 100644 index 0000000..9e57156 --- /dev/null +++ b/userland/brush/source/docs/how-to/release.md @@ -0,0 +1,15 @@ +# How to release + +_(This is only relevant for project maintainers.)_ + +* Install [release-plz](https://github.com/MarcoIeni/release-plz) +* Checkout the `main` branch (with a clean working tree). +* Refresh the compatibility-test count badge in `README.md` if it has drifted (round down to the nearest hundred; count `- name:` entries under `brush-shell/tests/cases/compat`). +* Run: `release-plz update`. Review its changes, notable including the changelog updates. +* PR through any generated changes with a `chore: prepare release` commit summary. +* After the changes have merged into `main`, update your local `main` branch. +* Acquire GitHub and `crates.io` tokens that have sufficient permissions to publish. +* Authenticate with `crates.io` by running: `cargo login`. +* Run: `release-plz release --backend github --git-token `. +* Update the published GitHub release to include an auto-generated changelog. +* Run: `cargo install --locked brush-shell` to verify the release. diff --git a/userland/brush/source/docs/how-to/run-benchmarks.md b/userland/brush/source/docs/how-to/run-benchmarks.md new file mode 100644 index 0000000..5926909 --- /dev/null +++ b/userland/brush/source/docs/how-to/run-benchmarks.md @@ -0,0 +1,31 @@ +# How to run benchmarks + +## Using xtask (Recommended) + +The project provides `cargo xtask` commands for running benchmarks: + +```bash +# Run benchmarks +cargo xtask analyze bench + +# Run benchmarks and save output to a file +cargo xtask analyze bench --output benchmarks.txt +``` + +## Manual Approach (Alternate) + +To run performance benchmarks: + +```bash +cargo bench --workspace --benches +``` + +## Collecting flamegraphs + +To collect flamegraphs from performance benchmarks (running for 10 seconds): + +```bash +cargo bench --workspace --benches -- --profile-time 10 +``` + +The flamegraphs will be created as `.svg` files and placed under `target/criterion//profile`. diff --git a/userland/brush/source/docs/how-to/run-tests.md b/userland/brush/source/docs/how-to/run-tests.md new file mode 100644 index 0000000..9dfe980 --- /dev/null +++ b/userland/brush/source/docs/how-to/run-tests.md @@ -0,0 +1,48 @@ +# How to run tests + +## Using xtask (Recommended) + +The project provides `cargo xtask` commands for running tests: + +```bash +# Run unit tests (fast tests excluding integration binaries) +cargo xtask test unit + +# Run integration tests (all workspace tests including compat tests) +cargo xtask test integration + +# Run tests with code coverage +cargo xtask test integration --coverage --coverage-output codecov.xml +``` + +## CI Workflows + +For comprehensive validation, use the CI workflows: + +```bash +# Quick inner-loop checks (~7s warm): fmt, build, lint, unit tests +cargo xtask ci quick + +# Full workflow (~60s warm): quick + pre-commit hooks, schemas, integration tests +cargo xtask ci full +``` + +## Manual Approach (Alternate) + +To run all workspace tests: + +```bash +cargo test --workspace +``` + +To run just bash compatibility tests: + +```bash +cargo test --test brush-compat-tests +``` + +To run a specific compatibility test case + +```bash +cargo test --test brush-compat-tests -- '' +``` diff --git a/userland/brush/source/docs/how-to/upgrade-msrv.md b/userland/brush/source/docs/how-to/upgrade-msrv.md new file mode 100644 index 0000000..3f89b34 --- /dev/null +++ b/userland/brush/source/docs/how-to/upgrade-msrv.md @@ -0,0 +1,142 @@ +# How to upgrade MSRV + +This document outlines the process for upgrading the Minimum Supported Rust Version (MSRV) for the +`brush` project. + +## Overview + +Before upgrading, review the [MSRV Policy](../reference/msrv-policy.md). The workspace is split into +two tiers, and which one you are raising decides both the process and the bar you have to clear: + +- **Library tier** (`brush-core`, `brush-parser`, `brush-builtins`, and the other embeddable + crates): governed by the workspace-wide `rust-version`. Raising it reaches every downstream + consumer, and the policy's age requirement applies. +- **Application tier** (`brush`, `brush-shell`, `brush-interactive`, `brush-fuzz`): each declares its + own `rust-version`. Raise it only when a dependency forces it, in the same change that takes the + dependency. + +## Raising an application-tier crate's MSRV + +This is the common case: a dependency of the interactive front end has moved. + +### 1. Raise the crate and everything that depends on it + +Set `rust-version` in the crate's own `Cargo.toml` to the version the dependency requires, and no +higher. Cargo rejects a package whose dependency declares a higher `rust-version` than it does, so +dependents have to move with it. In practice `brush-interactive` pulls `brush-shell`, which pulls +`brush`. + +The manifests are the only place the number appears. Nothing else needs editing: CI derives the set +of higher-MSRV crates from `cargo metadata`. + +### 2. Verify both CI legs locally + +Run the MSRV leg against the MSRV toolchain, not whatever is active. Only the pinned toolchain +proves the library tier still compiles where it claims to: + +```bash +# The workspace `rust-version`, read from the root manifest. +msrv=$(grep -m1 '^rust-version' Cargo.toml | cut -d'"' -f2) +rustup toolchain install "$msrv" + +# What the stable leg runs: every crate, current toolchain. +cargo xtask check build + +# What the MSRV leg runs; the higher-MSRV crates drop out automatically. +cargo "+$msrv" xtask check build --workspace-msrv +``` + +The second command prints the crates it skipped. If that list does not match what you just changed, +something is declared in the wrong place. + +To see the other half of the split, ask the MSRV toolchain to build a crate you just raised. Cargo +should refuse by name: + +```bash +cargo "+$msrv" check -p brush-shell +# error: rustc is not supported by the following packages: +# brush-interactive@ requires rustc +``` + +### 3. Confirm the library tier did not move + +```bash +cargo metadata --no-deps --format-version 1 | + jq -r '.packages[] | "\(.name) \(.rust_version)"' +``` + +Every library-tier crate should still report the workspace value. If one moved, a dependency crossed +the tier boundary, and that is a policy decision rather than a mechanical update. + +## Raising the workspace MSRV + +### 1. Find all MSRV references + +```bash +grep -r "" . +``` + +The workspace MSRV is specified in: + +- `Cargo.toml` (the `rust-version` field under `[workspace.package]`) +- `.github/workflows/ci.yaml` (the CI test matrix) +- `.github/copilot-instructions.md` (GitHub Copilot instructions) + +Note that the `rust-version` values in the application-tier manifests are deliberately independent. +Leave them alone unless the workspace value has caught up with or passed them, in which case the +crate no longer needs its own declaration and should go back to `rust-version.workspace = true`. + +### 2. Update MSRV references + +Update all occurrences to the new version. + +### 3. Verify the build + +```bash +cargo + xtask check build --workspace-msrv +``` + +Confirm the value the library tier will publish, which is what downstream consumers actually see. +`rust-version.workspace = true` is resolved when the crate is packaged, so read it back out of the +package rather than trusting the source manifest: + +```bash +cargo package --no-verify -p brush-core +tar -xzOf target/package/brush-core-*.crate '*/Cargo.toml' | grep rust-version +``` + +### 4. Run tests + +```bash +cargo + test --workspace +``` + +### 5. Run static checks + +After upgrading MSRV, it's important to rerun all static checks, as newer Rust versions may introduce +new lints and clippy warnings that weren't present in the previous MSRV. These warnings need to be +resolved to maintain code quality. + +Run clippy with all warnings treated as errors: + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Also run other static checks such as formatting: + +```bash +cargo fmt --all -- --check +``` + +**Note**: Upgrading MSRV often enables new clippy lints (particularly in nursery categories) that may +flag code patterns that were previously acceptable. Review and fix these warnings, as they often +suggest improvements like adding `const` to functions or other optimizations that are newly available +in the updated Rust version. + +### 6. Update documentation + +When merging the MSRV update: + +- Call out the update in an appropriate Conventional Commit commit description +- Include justification for the change in the release notes diff --git a/userland/brush/source/docs/reference/README.md b/userland/brush/source/docs/reference/README.md new file mode 100644 index 0000000..c7cff27 --- /dev/null +++ b/userland/brush/source/docs/reference/README.md @@ -0,0 +1,12 @@ +# Reference + +These documents serve as reference material for the `brush` project. + +* [Configuration file](configuration.md) +* [Experimental features](experimental.md) +* [zsh-style hooks](zsh-hooks.md) +* [Integration testing](integration-testing.md) +* [Minimum Supported Rust Version (MSRV) policy](msrv-policy.md) +* [Compatibility](compatibility.md) +* [Examples](examples.md) +* [Related projects](related-projects.md) diff --git a/userland/brush/source/docs/reference/compatibility.md b/userland/brush/source/docs/reference/compatibility.md new file mode 100644 index 0000000..f56e08e --- /dev/null +++ b/userland/brush/source/docs/reference/compatibility.md @@ -0,0 +1,222 @@ +# `bash` Compatibility Reference + +This document details `brush`'s compatibility with `bash`, including supported features, known limitations, and how to report issues. + +## Overview + +`brush` aims for high compatibility with `bash`. We validate this through **2,500+ compatibility test cases** that compare behavior against `bash` as an oracle, plus [end-to-end suites](../../e2e/README.md) that test popular shell tools (e.g., fzf, atuin, starship, etc.) with `brush`. + +**Compatibility snapshot:** Production-ready for most use cases. Your `.bashrc`, aliases, functions, and completions should "just work." + +## Fully Supported Features ✅ + +### Shell Syntax & Control Flow + +- `if`/`then`/`elif`/`else`/`fi` conditionals +- `for`, `while`, `until` loops +- Arithmetic `for` loops: `for ((i=0; i<10; i++))` +- `case`/`esac` pattern matching +- `&&`, `||` conditional execution +- Subshells `()` and command grouping `{}` +- Pipelines and pipeline negation `!` +- Coprocesses: `coproc { ... }` and `coproc NAME { ... }` + +### Expansions + +- Brace expansion: `{a,b,c}`, `{1..10}`, `{a..z}` +- Parameter expansion: `${var:-default}`, `${var:+set}`, `${var#pattern}`, `${var%pattern}`, `${var//find/replace}`, etc. +- Command substitution: `$(cmd)`, `` `cmd` `` +- Arithmetic expansion: `$((expr))` +- Process substitution: `<(cmd)`, `>(cmd)` +- Tilde expansion: `~`, `~user` +- Globbing: `*`, `?`, `[...]` +- Extended globbing: `?(pat)`, `*(pat)`, `+(pat)`, `@(pat)`, `!(pat)` +- `globstar`: `**` recursive matching + +### Builtins (50+) + +- **I/O:** `echo`, `printf`, `read`, `mapfile`/`readarray` +- **Variables:** `declare`, `local`, `export`, `unset`, `readonly`, `typeset` +- **Control:** `break`, `continue`, `return`, `exit` +- **Navigation:** `cd`, `pushd`, `popd`, `dirs`, `pwd` +- **Jobs:** `jobs`, `fg`, `bg`, `wait`, `kill` +- **Completion:** `complete`, `compgen`, `compopt` +- **History:** `history`, `fc` +- **Testing:** `test`, `[`, `[[` +- **Sourcing & introspection:** `.`, `source`, `eval`, `caller` +- **Misc:** `alias`, `unalias`, `hash`, `type`, `command`, `builtin`, `enable`, `help`, `times`, `ulimit`, `umask`, `trap`, `shopt`, `set`, `shift`, `getopts` + +### Arrays + +- Indexed arrays: `arr=(a b c)`, `${arr[0]}`, `${arr[@]}` +- Associative arrays: `declare -A map` +- Array slicing: `${arr[@]:start:length}` +- Array operations: `${#arr[@]}`, `${!arr[@]}`, `${!arr[*]}` + +### Job Control + +- Background execution: `cmd &` +- Suspend/resume: Ctrl+Z, `fg`, `bg` +- Job listing: `jobs` +- Process groups and pipelines + +### Dynamic Variables + +- `RANDOM`, `SRANDOM` +- `LINENO`, `FUNCNAME`, `BASH_SOURCE` +- `EPOCHSECONDS`, `EPOCHREALTIME` +- `SECONDS` +- `PWD`, `OLDPWD` +- `BASH_VERSINFO`, `BASH_VERSION` + +### Programmable Completion + +- Compatible with [`bash-completion`](https://github.com/scop/bash-completion) +- Git, Docker, systemctl, etc. completions work out of the box +- `complete`, `compgen`, `compopt` builtins + +### Redirection + +- Standard: `>`, `>>`, `<`, `2>&1` +- Here documents: `<&n`, `<&n`, `n>&m` +- Process substitution redirects: `>(cmd)`, `<(cmd)` +- Clobber control: `>|`, `set -o noclobber` + +## Partially Supported Features 🔷 + +### Traps + +| Status | Feature | +|--------|----------| +| ✅ | `DEBUG` trap | +| ✅ | `ERR` trap | +| ✅ | `EXIT` trap | +| 🔷 | Signal traps (`SIGINT`, `SIGTERM`, etc.) — in progress | + +### Key Bindings (`bind`) + +| Status | Feature | +|--------|---------| +| ✅ | Basic `bind` support | +| ✅ | `bind -x` for custom key-bound commands | +| 🔷 | Advanced bind features — in progress | + +### Shell Options + +| Status | Feature | +|--------|---------| +| ✅ | Common options: `errexit`, `pipefail`, `extglob`, `globstar`, `noclobber`, `nounset`, `failglob` | +| 🔷 | Less common options — in progress | + +## Not Yet Supported Features 🚧 + +These features are on our roadmap but not yet implemented: + +### `select` Statement + +The `select` builtin for creating menu-driven scripts is not yet implemented. + +```bash +# Not yet supported +select opt in "Option A" "Option B" "Quit"; do + case $opt in + "Option A") echo "A";; + "Option B") echo "B";; + "Quit") break;; + esac +done +``` + +### `wait -n` + +The `wait -n` option to wait for the next background job to complete is not implemented. + +```bash +# Not yet supported +job1 & +job2 & +wait -n # Wait for whichever finishes first +``` + +### `$!` for Background Commands + +Bash expands `$!` to the process ID of the most recent asynchronous command. +Brush currently does not reliably provide this value for background commands, +so scripts that start a job with `cmd &` and immediately use `$!` may see an +empty value. + +```bash +# Not yet fully supported +sleep 10 & +kill "$!" +``` + +### `BASH_COMMAND` Variable + +The special variable `BASH_COMMAND` that contains the currently executing command is currently only available in trap contexts. + +### `disown` and `logout` + +These job control builtins are not yet implemented. + +## Known Edge Cases + +These areas have known differences from `bash` in edge cases. Most users won't encounter these, but they're documented for completeness. + +### IFS (Input Field Separator) + +There are ~10 known edge cases where IFS word splitting behavior differs from `bash`, particularly around: + +- Non-whitespace IFS characters and empty field creation +- Mixed whitespace and non-whitespace IFS +- Leading/trailing delimiter handling + +### `printf` Format Specifiers + +Some advanced `printf` format specifiers behave differently (~8 known cases), particularly features not supported by the underlying `uucore` library. + +### Arithmetic Expressions + +- Division by zero handling may differ in `errexit` mode +- `$(( exit N ))` syntax edge case + +### Aliases + +Some complex alias expansion scenarios differ from `bash` (see GitHub issues [#57](https://github.com/reubeno/brush/issues/57), [#286](https://github.com/reubeno/brush/issues/286)). + +## Test Suite Statistics + +- **Total test cases:** 2,500+ (plus a small set of `brush`-specific cases) +- **Known failures:** ~470, marked `known_failure` in the case files so a fix that makes one pass is reported rather than absorbed +- **Largest groups of known failures:** nameref behavior, `declare` edge cases, `BASH_COMMAND` inside traps, signal handling, `wait` with job specs, and alias expansion + +The test suite runs on every PR and compares behavior against `bash` as an oracle. + +## Version Compatibility + +`brush` targets compatibility with **`bash` 5.3+**. Behavior may differ from older `bash` versions (3.x, 4.x) in some areas. + +## Reporting Compatibility Issues + +Found a script that works in `bash` but not in `brush`? + +1. **Check existing issues:** [GitHub Issues](https://github.com/reubeno/brush/issues) +2. **Create a minimal reproducer:** Reduce to the smallest failing script +3. **File an issue** with: + - The script or command that fails + - Expected behavior (what `bash` does) + - Actual behavior (what `brush` does) + - Your platform (Linux/macOS/etc.) + +## Tracking Progress + +- **GitHub Issues:** Track specific compatibility work +- **Test Suite:** 2,500+ tests run on every PR +- **This Document:** Updated as features are implemented + +## Related Resources + +- [`bash` Reference Manual](https://www.gnu.org/software/bash/manual/) +- [POSIX Shell Specification](https://pubs.opengroup.org/onlinepubs/9699919799/) +- [`brush` Test Cases](https://github.com/reubeno/brush/tree/main/brush-shell/tests/cases) diff --git a/userland/brush/source/docs/reference/configuration.md b/userland/brush/source/docs/reference/configuration.md new file mode 100644 index 0000000..e672063 --- /dev/null +++ b/userland/brush/source/docs/reference/configuration.md @@ -0,0 +1,121 @@ +# Configuration File + +brush supports an optional TOML configuration file that allows you to customize shell behavior without command-line arguments. + +## File Location + +`brush` looks for the configuration file at: + +- **Linux/macOS**: `${XDG_CONFIG_HOME}/brush/config.toml`* +- **Windows**: `%APPDATA%\brush\config.toml` + +> [!NOTE] +> On Linux/macOS falls back to `~/.config/brush/config.toml` if `XDG_CONFIG_HOME` is undefined. + +You can override this location with the `--config` flag: + +```bash +brush --config /path/to/custom/config.toml +``` + +To disable configuration file loading entirely, use: + +```bash +brush --no-config +``` + +## Configuration Priority + +Settings are applied in the following order (later values override earlier ones): + +1. **Defaults** - Built-in default values +2. **Configuration file** - Values from `config.toml` +3. **Command-line arguments** - Flags passed to brush + +## File Format + +The configuration file uses [TOML](https://toml.io/) format. All settings are optional; brush uses sensible defaults for any unspecified values. + +### Example Configuration + +```toml +[ui] +syntax-highlighting = true + +[experimental] +zsh-hooks = true +terminal-shell-integration = true +``` + +## Available Settings + +### `[ui]` Section + +User interface settings. + +| Setting | Type | Default | CLI flag | Description | +|-----------------------|---------|---------|-------------------------|----------------------------------------------| +| `syntax-highlighting` | boolean | see below | `--enable-highlighting` | Enable syntax highlighting in the input line | + +> The default value of `syntax-highlighting` depends on how `brush-shell` +> was built: `true` when built with the `experimental` Cargo feature, +> `false` otherwise. CLI flags take precedence over the configuration +> file. + +Syntax highlighting is supported by the `reedline` and `vt` input backends. +The `--disable-color` flag also disables syntax highlighting. + +### `[experimental]` Section + +Experimental features that may change or be removed in future versions. +Each setting has an equivalent command-line flag; CLI flags take +precedence over the configuration file. See the +[experimental features reference](experimental.md) for details on each +feature. + +| Setting | Type | Default | CLI flag | Description | +|------------------------------|---------|---------|----------------------------------|---------------------------------------| +| `zsh-hooks` | boolean | `false` | `--enable-zsh-hooks` | Enable zsh-style preexec/precmd hooks | +| `terminal-shell-integration` | boolean | `false` | `--enable-terminal-integration` | Enable terminal shell integration | + +## JSON Schema + +A JSON Schema for the configuration file is available at [`schemas/config.schema.json`](../../schemas/config.schema.json). This can be used with editors that support schema-based validation and autocompletion for TOML files. + +### Using the Schema with VS Code + +To enable schema validation in VS Code with the [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) extension, add this to your `config.toml`: + +```toml +#:schema https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json + +[ui] +syntax-highlighting = true +``` + +The `#:schema` directive tells the editor where to find the schema for validation and autocompletion. + +### Using the Schema with Other Editors + +Many editors support JSON Schema for TOML files. Consult your editor's documentation for how to associate a schema with a file. You can reference the schema via: + +- **URL**: `https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json` +- **Local path**: Point to `schemas/config.schema.json` in your brush source checkout + +## Sample Configuration + +A sample configuration file is available at [`samples/config.toml`](../../samples/config.toml) in the brush repository. You can copy this file to get started: + +```bash +# Linux/macOS +mkdir -p ~/.config/brush +cp samples/config.toml ~/.config/brush/config.toml +``` + +## Forward Compatibility + +brush ignores unknown settings in the configuration file. This allows configuration files to be shared across different versions of brush without causing errors. + +## Error Handling + +If the configuration file cannot be read or parsed, brush logs an error message and continues with default settings. The shell will still start normally. diff --git a/userland/brush/source/docs/reference/examples.md b/userland/brush/source/docs/reference/examples.md new file mode 100644 index 0000000..adc71da --- /dev/null +++ b/userland/brush/source/docs/reference/examples.md @@ -0,0 +1,18 @@ +# Examples + +Each crate that has something to demonstrate keeps runnable examples in its `examples/` directory. Run one with: + +```bash +cargo run --package --example +``` + +A few need a Cargo feature, noted below. + +| Example | Crate | What it shows | Useful when | +|---|---|---|---| +| [`readme`](../../brush-core/examples/readme.rs) | `brush-core` | The shortest embedding: build a `Shell`, run a string of shell code, check the result. This is the snippet in the repository README; a test keeps the two identical. | You want the minimum needed to run shell code inside your own program. | +| [`call-func`](../../brush-core/examples/call-func.rs) | `brush-core` | Define a shell function from Rust, list the functions the shell knows, invoke one with arguments, and redirect its stdout using `ExecutionParameters`. | Your program calls into user-supplied shell code and needs control over its I/O. | +| [`custom-builtin`](../../brush-clap-adapter/examples/custom-builtin.rs) | `brush-clap-adapter` | A builtin implemented in Rust and registered alongside the standard ones: the `ParseArgs` and `Builtin` traits, arguments parsed by `clap` through `brush-clap-adapter`, `thiserror` errors mapped to exit codes, and access to shell state and I/O through the execution context. | You want to extend the shell with commands specific to your application. | +| [`serde`](../../brush-parser/examples/serde.rs) | `brush-parser` (`--features serde`) | Parse a script and serialize the syntax tree to JSON, then deserialize it back. | Tooling that inspects, stores, or transmits shell syntax trees. | +| [`miette`](../../brush-parser/examples/miette.rs) | `brush-parser` (`--features diagnostics`) | Parse a file given on the command line and render any parse error as a [miette](https://github.com/zkat/miette) diagnostic with source spans. | Editors, linters, and other tools that need to show where a parse failed. | +| [`gen`](../../brush-shell/examples/gen.rs) | `brush-shell` (`--features schema`) | Generate the config file's JSON schema, which needs the shell linked. Driven by `cargo xtask gen schema config`. | You're a maintainer regenerating distributed artifacts. | diff --git a/userland/brush/source/docs/reference/experimental.md b/userland/brush/source/docs/reference/experimental.md new file mode 100644 index 0000000..305c8e7 --- /dev/null +++ b/userland/brush/source/docs/reference/experimental.md @@ -0,0 +1,153 @@ +# Experimental Features + +`brush` ships several features that are intentionally marked as +**experimental**. They are usable today, but their interface or behavior +may evolve based on feedback before being stabilized. This page is the +canonical index of what's currently experimental and how to opt in. + +Experimental features fall into two categories: + +1. **Build-time experiments** — additional functionality gated behind + Cargo feature flags. To get them, you build `brush-shell` with the + relevant feature(s) enabled. +2. **Run-time experiments** — features that ship in standard builds but + are off by default and enabled through configuration. + +> Names, defaults, and semantics of experimental features may change +> between releases. + +--- + +## Build-time experiments (Cargo features) + +These are flags on the `brush-shell` crate. You can enable them +individually, or pull in the whole set with the umbrella `experimental` +feature. + +```bash +# Enable everything experimental +cargo install --locked brush-shell --features experimental + +# Or pick and choose +cargo install --locked brush-shell --features experimental-bundled-coreutils +``` + +### `experimental-bundled-coreutils` + +Bundles a configurable subset of [`uutils/coreutils`](https://github.com/uutils/coreutils) +implementations directly into `brush-shell` as builtins. Useful when: + +- shipping `brush` into containers, embedded systems, or other + environments where a standalone coreutils package is inconvenient or + unavailable, +- distributing a single self-contained `brush` binary that doesn't + rely on host utilities being present. + +When enabled (via the umbrella `experimental` feature, or directly), the +full set of supported utilities is bundled. When building the +`brush-coreutils-builtins` crate directly, individual utilities can be +selected via `coreutils.` features (e.g., `coreutils.cat`, +`coreutils.ls`); the `coreutils.all` feature enables all of them. + +Bundled utilities run in-process and take precedence over external +executables of the same name on `PATH` when invoked unqualified. As with +any builtin, you can bypass the in-process implementation with +`command ` or by giving an explicit path. + +### `experimental-builtins` + +Pulls in the [`brush-experimental-builtins`](../../brush-experimental-builtins) +crate, which provides additional builtins that are too new or too +narrow-purpose to ship in the default builtin set. Currently this +includes: + +- **`save`** — serializes the current shell state to JSON on stdout. + Primarily intended for debugging and tooling. ⚠️ The serialized state + may include sensitive information (variable values, command history, + environment). + +### `experimental-load` + +Enables `serde`-based serialization support in `brush-core`, and adds a +`--load ` command-line flag that restores shell state from a JSON +file previously produced by the experimental [`save`](#experimental-builtins) +builtin (or by other tooling that emits the same format). State loaded +this way overrides non-UI command-line options. Useful for tooling +built on top of `brush-core` that wants to snapshot or transfer shell +state. + +### `experimental-parser` + +Switches on the in-development [`winnow`](https://crates.io/crates/winnow)-based +parser scaffolding in `brush-parser`, and adds an `--experimental-parser` +command-line flag that selects it at runtime. This parser is not yet the +production parser; the existing PEG parser remains the default. Enable +this only if you're contributing to or experimenting with the parser +work. + +--- + +## Run-time experiments (configuration) + +These features ship in standard builds but are off by default. Each one +can be enabled either through the optional [TOML configuration +file](configuration.md) (under the `[experimental]` section, persistent +across sessions) or through a command-line flag at startup (one-shot). + +When both are specified, command-line flags take precedence over the +configuration file. + +```toml +[experimental] +zsh-hooks = true +terminal-shell-integration = true +``` + +| Feature | TOML setting (`[experimental]`) | Command-line flag | +|---------|---------------------------------|-------------------| +| zsh-style hooks | `zsh-hooks = true` | `--enable-zsh-hooks` | +| Terminal shell integration | `terminal-shell-integration = true` | `--enable-terminal-integration` | + +### `zsh-hooks` + +Enables zsh-style `precmd` and `preexec` hook functions, registered through +the `precmd_functions` and `preexec_functions` arrays. This is what prompt +frameworks, command timers, and history tools expect; in stock bash the +same effect requires `DEBUG`/`PROMPT_COMMAND` plumbing. + +brush implements the hooks natively, with bash semantics, taking +[bash-preexec](https://github.com/rcaloras/bash-preexec) as the reference for +what the hooks mean in a bash shell. It also claims bash-preexec's inclusion +guards, so an integration that sources bash-preexec (or inlines a copy of it, +as atuin does) finds it already loaded and leaves the hooks to brush rather +than dispatching them a second time. + +Enable persistently with `zsh-hooks = true` under `[experimental]` in +`config.toml`, or per-invocation with `brush --enable-zsh-hooks`. + +See [zsh-style hooks](zsh-hooks.md) for the full contract: what each hook +sees, which entries are dispatched, and where brush differs from zsh and +from bash-preexec. + +### `terminal-shell-integration` + +Emits standard terminal shell-integration escape sequences (semantic +prompt and command boundary marking) that modern terminal emulators — +including VS Code, iTerm2, WezTerm, and others — use to enable features +like command navigation, exit-status display, and selective output +copying. This is off by default to avoid emitting escape sequences in +terminals that don't recognize them. + +Enable persistently with `terminal-shell-integration = true` under +`[experimental]` in `config.toml`, or per-invocation with +`brush --enable-terminal-integration`. + +--- + +## Reporting feedback + +Experimental features are the place where your feedback is most valuable +— they exist precisely because we want to iterate on them before +stabilizing. If you try one and find a rough edge, missing capability, +or behavior that surprises you, please [file an +issue](https://github.com/reubeno/brush/issues). diff --git a/userland/brush/source/docs/reference/integration-testing.md b/userland/brush/source/docs/reference/integration-testing.md new file mode 100644 index 0000000..3bf4208 --- /dev/null +++ b/userland/brush/source/docs/reference/integration-testing.md @@ -0,0 +1,17 @@ +# Integration testing + +Our approach to integration testing relies heavily on using test oracles to provide the "correct" answers/expectations for test cases. In practice, we use existing alternate shell implementations as oracles. + +Test cases are defined in YAML files. The test cases defined in a given file comprise a test case set. Running the integration tests for this project executes test case sets in parallel. + +```yaml +name: "Example tests" +cases: + - name: "Basic usage" + stdin: | + echo hi +``` + +This defines a new test case set with the name "Example tests". It contains one defined test case called "Basic usage". This test case will launch the shell without any additional custom arguments (beyond a few standard ones to disable processing default profiles and rc files), write "echo hi" (with a trailing newline) to stdin of the shell, and then close that stream. The test harness will capture the shell's stdout, stderr, and exit code. After repeating these steps with the test oracle, each of these 3 data are compared. An error is flagged if any of the 3 differ. + +Test cases are run with the working directory initialized to a temporary directory. The contents of the temporary directory are inspected after the shell-under-test has exited, and compared against their counterparts in the oracle's run. This enables easy checking of files created, deleted, or mutated as side effects of running the test case. diff --git a/userland/brush/source/docs/reference/msrv-policy.md b/userland/brush/source/docs/reference/msrv-policy.md new file mode 100644 index 0000000..1744b3c --- /dev/null +++ b/userland/brush/source/docs/reference/msrv-policy.md @@ -0,0 +1,106 @@ +# Minimum Supported Rust Version (MSRV) Policy + +## Overview + +The `brush` project maintains a conservative MSRV policy to balance two key concerns: + +1. **Binary distribution**: Users building `brush` from source should not need a bleeding-edge compiler +2. **Library usage**: Downstream projects depending on `brush` crates should not face aggressive MSRV increases + +These concerns pull against each other, and they do not apply evenly across the workspace. The crates +that other projects embed are held to a stricter standard than the ones that exist to produce the +shell itself. The workspace is therefore split into two tiers. + +## Tiers + +### Library tier + +`brush-core`, `brush-parser`, `brush-builtins`, `brush-coreutils-builtins`, and +`brush-experimental-builtins` are the reusable pieces. Other projects embed them to get a shell +runtime, a bash-compatible parser, or a set of builtins without taking on brush's interactive front +end. Their dependency sets are deliberately narrow for the same reason. + +These crates inherit `rust-version` from `[workspace.package]` in the root `Cargo.toml`. That value +is the project's MSRV, and it is the one the rules below are mostly about. + +`xtask` and `brush-test-harness` inherit the same value. Neither is published, but `cargo xtask` is +how CI runs the MSRV check, so `xtask` has to compile with the MSRV toolchain. That is why `xtask` +must not depend on `brush-shell`: the tool that drives the build cannot sit downstream of the thing +it builds. + +### Application tier + +`brush`, `brush-shell`, `brush-interactive`, and `brush-fuzz` exist to produce, host, and exercise +the shell. `brush-shell` and `brush-interactive` are published as libraries, but their reason to +exist is the interactive front end, and they carry its dependencies: line editing, terminal +handling, completion. Those dependencies raise their own MSRVs on a schedule the project does not +control, and refusing to follow would mean freezing the interactive experience on whatever line +editor was current at the time. + +These crates declare `rust-version` in their own manifests, above the workspace value. Those +manifests are the only record of it; no other file in the tree repeats the number. + +## Policy + +### Updating the library tier + +We **do not** update the workspace MSRV proactively. Updates only occur when: + +- A meaningful set of language features or capabilities becomes available that provides clear value + to the project +- The return-on-investment justifies the potential impact on users and downstream dependencies + +When we do update, we move to a Rust version that is **at least 4-6 months old** at the time of the +update. This ensures: + +- Sufficient time for the Rust version to stabilize +- Wide availability in package managers and development environments +- Reduced friction for users building from source + +### Updating the application tier + +An application-tier crate raises its `rust-version` only when a dependency it needs actually +requires it, and only as far as that dependency requires. We do not round up to current stable, and +we do not move a crate ahead of the workspace speculatively. The commit that raises the value should +be the commit that takes the dependency, so the reason is visible in one place rather than inferred +later. + +The 4-6 month age requirement does not apply to this tier; the forcing dependency's own MSRV decides +the number. In exchange, a raise is confined to the crates that need it. If only `brush-interactive` +requires a newer toolchain, `brush-shell` and `brush` follow because they depend on it, and the +library tier does not move at all. + +### Communication + +MSRV changes are always: + +- Explicitly documented in release notes +- Considered a notable change requiring user awareness +- Announced with clear justification for the update + +A library-tier change is the more significant of the two, because it reaches every downstream +consumer. An application-tier change affects people building the shell from source, and should say +which dependency forced it. + +## Enforcement + +CI runs the source-code checks against both the toolchain pinned in `rust-toolchain.toml` (which +Dependabot keeps at the latest stable release) and the workspace MSRV. On the MSRV leg, the build +check runs as: + +```bash +cargo xtask check build --workspace-msrv +``` + +Cargo refuses to build any package whose `rust-version` exceeds the active toolchain, so the +application-tier crates cannot be part of that leg. The flag asks `cargo metadata` which workspace +members declare a `rust-version` above the lowest one in the workspace and excludes exactly those. +No list of crate names exists anywhere in the tooling, so moving a crate between tiers needs no +change to it. Without the flag, every crate is checked, which is what the pinned-toolchain leg wants. + +## Rationale + +This split recognizes that `brush` serves two different audiences. A project embedding `brush-core` +wants a stable, predictable compiler requirement and gets one. A person building the shell wants a +current line editor and working terminal integration, and that has a cost the embedding audience +should not have to pay. diff --git a/userland/brush/source/docs/reference/related-projects.md b/userland/brush/source/docs/reference/related-projects.md new file mode 100644 index 0000000..b32a0b0 --- /dev/null +++ b/userland/brush/source/docs/reference/related-projects.md @@ -0,0 +1,9 @@ +# Related projects + +Other POSIX-ish or modern shells implemented in Rust and other non-C/C++ languages: + +* [nushell](https://www.nushell.sh/) +* [fish](https://fishshell.com) +* [Oils](https://github.com/oils-for-unix/oils) +* [mvdan/sh](https://github.com/mvdan/sh) +* [rusty_bash](https://github.com/shellgei/rusty_bash) diff --git a/userland/brush/source/docs/reference/zsh-hooks.md b/userland/brush/source/docs/reference/zsh-hooks.md new file mode 100644 index 0000000..1491602 --- /dev/null +++ b/userland/brush/source/docs/reference/zsh-hooks.md @@ -0,0 +1,112 @@ +# zsh-style hooks (`precmd` and `preexec`) + +An [experimental feature](experimental.md). Enable it persistently with +`zsh-hooks = true` under `[experimental]` in `config.toml`, or per-invocation +with `brush --enable-zsh-hooks`. + +zsh defines these hooks, and prompt frameworks, command timers, and history +tools (atuin, starship, and friends) are built on them. brush provides a +bash-native adaptation: the hooks live in the shell itself, with bash semantics, +rather than in the `DEBUG`/`PROMPT_COMMAND` plumbing stock bash needs to +approximate them. + +[bash-preexec](https://github.com/rcaloras/bash-preexec) is the adaptation of +the same hooks the ecosystem already builds on, so it is what settles the +details zsh leaves open for a bash shell, and what brush interoperates with — +see [below](#relationship-to-bash-preexec). + +## The hooks + +| Registry | When it runs | Arguments | +|----------|--------------|-----------| +| `precmd_functions` | before each prompt is displayed, ahead of `PROMPT_COMMAND` | none | +| `preexec_functions` | after a command line is read, before it runs | the command line, as `$1` | + +The two arrays start out as `(precmd)` and `(preexec)`, so defining a function by +either name is enough. Register more by appending — `preexec_functions+=(my_fn)` — +and they run in array order. Each array is read once per dispatch, so a hook that +edits one is heeded from the next dispatch on. + +Every entry names a **shell function**. The name is looked up as a single, +already-expanded word: never re-split, glob-expanded, or resolved against `PATH`. +An entry that isn't a shell function is skipped quietly, so a hook may be +registered before it is defined. + +Hooks are active only in interactive shells, in the `$-` sense that also decides +whether rc files are loaded. `brush -s < script` does not qualify and claims none +of the bash-preexec state below; `brush -i -s < script` does, so a hook that reads +standard input there consumes the script's own lines. + +`preexec` fires only for a line that runs a command: blank lines, comment-only +lines, and syntax errors dispatch nothing, matching the `DEBUG` trap bash-preexec +dispatches from. Only lines the user typed dispatch it — a command run from a key +binding does not. + +## What a hook sees + +- `$?`, `PIPESTATUS`, and `$_` hold what the last command left behind. All three + are restored before every hook and again afterwards, so nothing a hook runs is + visible to the next hook, to `PROMPT_COMMAND`, or to the next command. +- A hook that fails has its error reported; the remaining hooks still run. Under `set -e` a + non-zero return exits the shell instead, exactly as it does from `PROMPT_COMMAND`. +- A hook that calls `exit` exits the shell. Nothing after it runs: no later hook, + no `PROMPT_COMMAND`, and not the command line `preexec` was dispatched for. + +## Relationship to bash-preexec + +The two must not both be live: bash-preexec's `DEBUG` trap would dispatch the +same hooks a second time. So brush claims bash-preexec's interlock as well as +its behavior — the `bash_preexec_imported` and legacy `__bp_imported` inclusion +guards, along with the two arrays, are set before profile and rc files load. A +copy of bash-preexec sourced afterwards — including the one tools such as atuin inline into their +`init` output — finds itself already loaded and returns, leaving the hooks to +brush rather than installing its `DEBUG`-trap emulation. +[ble.sh](https://github.com/akinomyoga/ble.sh) takes the same approach. + +All four are set the way `name=value` sets a variable: nothing is exported, so a +child bash still gets the real implementation, but one that already existed keeps +its attributes. A guard or registry inherited from the environment therefore stays +exported. + +Because brush seeds the arrays before rc files rather than appending after them, +an rc file that appends gets `(precmd mine)`, where sourcing bash-preexec after +the same append would give `(mine precmd)`. + +Clearing the guards is not a supported way to hand the hooks back. brush keeps dispatching, +so a bash-preexec sourced after they are cleared installs itself over the same registries and +every hook runs twice. To use the real bash-preexec, leave this feature off. + +The claim covers bash-preexec's documented surface and no more: the two hook +names, the two arrays, and the inclusion guards. None of its `__bp_*` internals +exist, and `precmd` hooks are not reachable through `PROMPT_COMMAND` the way its +dispatcher is. + +### `BP_PIPESTATUS` is not provided + +bash-preexec offers `BP_PIPESTATUS` because its hooks run from `PROMPT_COMMAND`, +by which point the real `PIPESTATUS` is gone. brush restores `PIPESTATUS` itself, +so a hook reads it directly. There is no copy, and none is planned. A hook that +must work under both can fall back in one expansion: + +```bash +statuses=("${BP_PIPESTATUS[@]:-${PIPESTATUS[@]}}") +``` + +## Differences + +| | brush | zsh | bash-preexec | +|---|---|---|---| +| `preexec` `$1` | the line as typed, interior newlines included (the trailing one is trimmed) | the same | what `history 1` reports, which under `cmdhist` joins the command onto one line | +| `preexec` `$2` and `$3` | not passed | single-line and fully-expanded forms of the command | not passed | +| entries that aren't shell functions | skipped | skipped | run, when `type -t` finds a builtin or a program | +| `PIPESTATUS` inside a hook | the real one | n/a | gone; `BP_PIPESTATUS` holds a copy | + +brush matches zsh on `$1`: zsh passes the string the user typed, verbatim +(verified against zsh 5.9). A hook that needs the command on one line can read +`history 1` itself — it is already in the history when `preexec` runs. + +Only functions run because that is what zsh does and what bash-preexec's own +usage notes tell you to register. Admitting programs would let a misspelled or +not-yet-defined function name fall through to a same-named program on `PATH` and +run it before every prompt. Wrap such a command in a function +(`log_cmd() { logger "$1"; }`) and register that. diff --git a/userland/brush/source/e2e/README.md b/userland/brush/source/e2e/README.md new file mode 100644 index 0000000..ec45a1f --- /dev/null +++ b/userland/brush/source/e2e/README.md @@ -0,0 +1,180 @@ +# End-to-end tests against real applications + +Each subdirectory here runs a real application's *own* shell-integration test +suite inside a container, with the shell under test swapped in for `bash`. +This catches interactive-shell regressions (bindings, `bind -x`, `READLINE_*`, +completion, history) that unit tests can't. + +```bash +cargo build +cargo xtask test e2e fzf # one adapter against brush +cargo xtask test e2e # default adapters (excludes blesh, bash-tests, bash-completion) +cargo xtask test e2e --with bash-completion # default adapters plus an opt-in one +cargo xtask test e2e blesh # opt in to the slow ble.sh suite +cargo xtask test e2e --baseline fzf # baseline against Bash +cargo xtask test e2e --shell /path/to/sh fzf # any other binary +cargo xtask test e2e fzf -- -n /ctrl_r/ # adapter-specific arguments +cargo xtask test --release e2e fzf # use target/release/brush +cargo xtask test e2e --verbose fzf # stream the runner's own output +cargo xtask test e2e --timeout 600 fzf # bound each adapter (default 1800s) +cargo xtask test e2e --summary-output e2e.md # also write the run as a Markdown dashboard +``` + +A run reports one line per adapter and a summary, read back from the JUnit +each runner writes: + +``` + Starting 7 e2e adapter(s) against brush + Results target/e2e/run-ABC123 + PASS [ 1.2s] starship 3 tests + FAIL [ 13.3s] atuin 5 tests, 1 failed + PASS [ 542.2s] blesh 9 tests, 9 xfailed +------------ + Summary [ 11m35.0s] 7 adapter(s): 6 passed, 1 failed + + atuin failed: ctrl-r search runs the selected command + -> target/e2e/run-ABC123/atuin/log.txt +``` + +Results land in `target/e2e/run-//`: `log.txt` (full runner output), +`junit/*.xml` (JUnit, for CI upload), and any adapter-specific diagnostic logs +(such as PTY transcripts). Each invocation creates and prints a fresh run directory, +retaining previous runs and leaving existing files untouched, including when using +`--results-dir`. Missing, malformed, or empty JUnit reports fail the adapter, as does an adapter that runs longer +than `--timeout` (default 1800s): its container is killed, so a hung shell cannot hang the run. +The runner's own output goes to `log.txt` rather than the terminal; `--verbose` +streams it instead. +Multi-adapter runs continue after failures and report them together at the end. +On a terminal, statuses are green for clean passes, yellow for passes with expected +failures, and red for failures. Redirected output, `TERM=dumb`, and a nonempty +`NO_COLOR` disable color. + +They need Docker and a built shell binary. CI runs the default selection on every +PR (the `e2e-tests` job in `ci.yaml`, against the release binary the build job +produced) and posts the `--summary-output` dashboard to the PR comment, alongside +the coverage and benchmark reports, with each failing adapter's `log.txt` in the +job log and every run directory in the `e2e-logs` artifact. Because the `xfail-list.txt` +files are the committed baseline, a PR that fixes or breaks a test shows it in its own +diff; no run against `main` is needed. `blesh` is excluded from the default selection +because compatibility failures and timeouts make it take several minutes against +brush. Run it explicitly with `cargo xtask test e2e blesh`, or select one file with +`cargo xtask test e2e blesh -- util`. Its per-file timeout remains 180 seconds. +`bash-tests` is likewise opt-in, until it has shown itself reliable. Neither runs in CI. +`bash-completion` is opt-in because its ~2000 tests take several minutes; CI adds it with +`--with bash-completion`, and it is report-only (below), so it cannot fail the job on test results. + +## Adapter contract + +An adapter is a directory `e2e//` containing a `Dockerfile` (build +context is `e2e/`, so it can `COPY shim /e2e/bin`) whose entrypoint: + +1. **Receives the shell** via `$SHELL_UNDER_TEST`, an absolute path to a + binary bind-mounted into the container (`/shell/`). It's built on + the host, so the image's glibc must be at least the host's. Most apps + hard-code `bash`, so the shared `shim/bash` exec's `$SHELL_UNDER_TEST`; + put `/e2e/bin` first on `PATH`. When the variable is unset the shim + runs the real `bash`, which gives the baseline run. If the suite has its + own knob for which shell to test (atuin's `ATUIN_TEST_BASH`), set that + instead and skip the shim; a shim on `PATH` also captures test tooling + written in bash (tmux, say), which must not run under the shell under test. + A suite that takes a shell *path* has nowhere to put a command-line flag; turn + the behavior on through brush's config file (`$HOME/.config/brush/config.toml`, + and `HOME` is `/tmp` in the container) instead, which bash ignores and so leaves + the baseline run alone. +2. **Writes results** to `/results`: `log.txt` plus JUnit XML under + `junit/`. Write whatever else helps debugging there too. +3. **Exits non-zero** when any test fails. Entrypoints pipe the runner through + `tee`, so exit `${PIPESTATUS[0]}` rather than `$?`, which is `tee`'s status. + Known failures are not the adapter's problem: the runner reads them from + `xfail-list.txt` (below) and decides the result from the JUnit, so an adapter that + exits non-zero purely because of expected failures still passes. + +The container runs as the host user with `HOME=/tmp`, so `chmod` anything +the tests write into. + +## Reproducing a failure + +The dashboard names each failing test with the command that runs it alone, built from the +adapter's `select-args.txt`: the adapter arguments that select one test, with `{test}` standing +for its name (fzf: `-n /{test}/`). Adapters without the file are assumed to take pytest's +`-k {test}`, since that is what every adapter's own tests use. + +## Tracking known failures + +Every dashboard row shows the pass rate: passed tests over those that ran (skips excluded, xfails +included). + +An adapter containing a `report-only` file (a comment saying why) is tracked by that rate alone: +its failing tests are listed, collapsed, and its row is marked 📊, but they do not fail the +adapter; nor does the non-zero exit they cause. A run that produced no results still fails it: an +image that does not build, a timeout, or missing JUnit. Such a suite needs no `xfail-list.txt`; +the runner's own expected failures (`@pytest.mark.xfail`) are counted as xfailed as usual. +Everything below applies to the other adapters. + +Two lists per adapter, both optional, both `#`-commented, one test name per line. + +`xfail-list.txt` is the default home for anything that fails today. The test still **runs**; +the runner matches it against the JUnit and counts it as `xfailed` instead of a failure. +Crucially it also fails the run when such a test **passes** ("passed unexpectedly") or when an +entry **matches no test that ran** ("stale xfail entry" -- renamed, removed, or moved to +`skip-list.txt`), so a gap that closes is reported rather than absorbed, and the list cannot rot. +Stale detection needs the whole suite, so it is skipped when adapter arguments select a subset. Because the matching happens over JUnit, one mechanism +covers every runner. Name a test by the `name` its JUnit carries, or as `classname::name`. +These expectations apply only to brush runs; `--baseline` and `--shell` runs use +no brush expectations and fail on any reported test failure. + +`skip-list.txt` is for tests that must **not** run: they hang, or they cannot exercise the shell +at all (nvm has one with a `/bin/zsh` shebang). This is the exception -- prefer `xfail-list.txt`, +because a skipped test reports nothing when it starts working -- and listing one in both files +fails the run as a stale xfail entry. The runner reports skipped tests by name, so a suite that +starts skipping something itself does not pass unnoticed. + +Applying the list is mostly handled for you: `lib/run_pytest.sh` exports the file as +`$E2E_SKIP_LIST` and loads `lib/e2e_skip.py`, a pytest plugin that skips any test named in it. +Adapters that must decide before collection (blesh) or at image build time (nvm, which takes the +execute bit off) parse `$E2E_SKIP_LIST` themselves; fzf turns it into a minitest `--exclude` +regex, since its runner is upstream's. + +Every entry in either list needs a comment saying why, with an issue link where one exists. + +## Shared adapter code + +Every adapter's own tests are pytest, so `lib/` (copied to `/e2e/lib`, on `PYTHONPATH`) carries +what they would otherwise each reinvent: + +| | | +|-----|-------| +| `run_pytest.sh` | the pytest half of the contract: JUnit, log, skip list, exit status. Source it and call `e2e_run_pytest ` | +| `rcfile.py` | `write_rc()`: a known prompt and no `HISTFILE`, then the app's own integration lines | +| `pty_shell.py` | drives a shell over a raw PTY (pexpect) and matches its output stream. The default | +| `tmux_shell.py` | drives a shell inside tmux (via `libtmux`) and matches the *rendered screen*. For full-screen TUIs, whose redraws a byte stream cannot answer questions about | +| `e2e_skip.py` | the skip-list pytest plugin, plus `parse()` for adapters that must skip before collection | + +## Rules + +1. **Never copy upstream files into this tree.** Clone the application at + build time, pinned to a commit (not a branch). +2. **The Dockerfile is our own environment recipe.** Deriving its package + list from upstream's Dockerfile or CI config is fine; say so in a comment, + with the source and license. +3. **The entrypoint adapts upstream's runner to the results contract.** If the + runner can't emit JUnit itself, add a reporter there (fzf: `minitest-ci`). +4. **Not every app has a suite to borrow.** When upstream only tests its own + code (atuin, say), the adapter brings its own tests: a small terminal-driven + suite that installs the app's hooks into the shell under test and checks the + observable behavior. The contract is the same; only the author differs. + +## Adapters + +| app | notes | +|-----|-------| +| fzf | upstream `test/test_shell_integration.rb`, `TestBash` only; minitest + tmux, JUnit via `minitest-ci` | +| atuin | our own suite in `atuin/tests/` (pytest + tmux), written to be upstreamable. atuin's bash integration is bash-preexec, so the adapter turns on brush's `zsh-hooks` through a config file to exercise the native hooks rather than the `DEBUG`-trap emulation | +| bash-tests | bash's own regression suite (`tests/run-*`), from a bash-5.3 build that also provides its helper binaries and the `--baseline` shell; one pytest case per `run-*` script, which is sourced verbatim, on a terminal, with `diff` replaced by a function that captures the output for byte-exact comparison against the `.right` file. `PLATFORM_DIFFS` in `tests/test_bash.py` excuses the few differences bash itself shows on Linux. Failures report how many expected lines match, and `log.txt` ends with the total across the run. Args go to pytest: `-- -k alias`, `-- --subset minimal` for the scripts `run-minimal` runs, `-- --script-timeout 60` (default 30s per script) | +| bash-completion | upstream's `test/t` pytest suite (tag 2.18.0), in upstream's own rolling `fedoradev` test image, which has the commands under test installed; built with `make` as upstream's CI does, and run under `xvfb-run`. Report-only: upstream's `xfail` markers are its only expectations. Args go to pytest: `-- -k test_ls` | +| blesh | upstream `ble.sh --test`, one pytest case per test section with a process-group timeout (`BLESH_TEST_TIMEOUT`, default 180s); sections are read from ble.sh's own build output, so a section added upstream runs rather than being missed; full output and leftover per-section artifacts are retained; args select sections: `cargo xtask test e2e blesh -- util`. Every section is currently expected to fail: ble.sh gets far enough to emit no section summary at all | +| mise | selected upstream bash activation tests (pytest), run directly against the shell under test | +| nvm | upstream `test/fast/Listing versions` suite (urchin) plus interactive regression coverage for [#1173](https://github.com/reubeno/brush/issues/1173) | +| oh-my-git | our own suite in `oh-my-git/tests/` (pytest + direct PTY); upstream has no tests or releases, so the scripts are cloned at a pinned commit | +| starship | our own suite in `starship/tests/` (pytest + direct PTY) | +| zoxide | our own suite in `zoxide/tests/` (pytest + direct PTY) | diff --git a/userland/brush/source/e2e/atuin/Dockerfile b/userland/brush/source/e2e/atuin/Dockerfile new file mode 100644 index 0000000..9a66bfc --- /dev/null +++ b/userland/brush/source/e2e/atuin/Dockerfile @@ -0,0 +1,26 @@ +# Environment for running atuin's bash shell-integration tests (see tests/). atuin itself is +# a pinned release binary; the test suite is our own, written to be upstreamable. +FROM ubuntu:26.04 +ARG ATUIN_VERSION=18.21.0 +# Bump these together with ATUIN_VERSION; they are the release's own .tar.gz.sha256 entries. +ARG ATUIN_SHA256_X86_64=434d82b4c5f691b6f0423f30a4b931df4afded19b05a795a280192fdf8a61a2d +ARG ATUIN_SHA256_AARCH64=dbdee27f7fc67b92e770d1af89d3b421051499a058cd3e4698d123e0117fa950 +RUN apt-get update && apt-get install -y --no-install-recommends \ + tmux curl ca-certificates python3-pytest python3-libtmux \ + && rm -rf /var/lib/apt/lists/* +RUN case "$(uname -m)" in \ + x86_64) sha=$ATUIN_SHA256_X86_64 ;; \ + aarch64) sha=$ATUIN_SHA256_AARCH64 ;; \ + *) exit 1 ;; \ + esac \ + && target="$(uname -m)-unknown-linux-gnu" \ + && curl -fsSL -o /tmp/atuin.tar.gz \ + "https://github.com/atuinsh/atuin/releases/download/v${ATUIN_VERSION}/atuin-${target}.tar.gz" \ + && echo "$sha /tmp/atuin.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/atuin.tar.gz -C /usr/local/bin --strip-components=1 "atuin-${target}/atuin" \ + && rm /tmp/atuin.tar.gz +COPY atuin/entrypoint.sh atuin/skip-list.txt /e2e/ +COPY atuin/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/atuin/entrypoint.sh b/userland/brush/source/e2e/atuin/entrypoint.sh new file mode 100755 index 0000000..9702fdd --- /dev/null +++ b/userland/brush/source/e2e/atuin/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh + +# atuin's bash integration is bash-preexec: `atuin init bash` inlines a copy of it and +# registers into precmd_functions/preexec_functions. brush implements those hooks natively but +# behind an experimental flag, so without it this suite only ever exercises bash-preexec's own +# DEBUG-trap emulation. Turn it on through brush's config file rather than a command-line flag: +# the suite takes a shell path with nowhere to put a flag, and bash ignores the file, so the +# `--baseline` run is unaffected. Remove once the feature is on by default. +mkdir -p "$HOME/.config/brush" +cat >"$HOME/.config/brush/config.toml" <<'TOML' +[experimental] +zsh-hooks = true +TOML + +# atuin's suite has its own knob for the shell to test, so it needs no PATH shim (the shim +# would also capture tmux, which must not run under the shell under test). +export ATUIN_TEST_BASH="${SHELL_UNDER_TEST:-bash}" +e2e_run_pytest atuin "$@" diff --git a/userland/brush/source/e2e/atuin/skip-list.txt b/userland/brush/source/e2e/atuin/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/atuin/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/atuin/tests/README.md b/userland/brush/source/e2e/atuin/tests/README.md new file mode 100644 index 0000000..2346bb4 --- /dev/null +++ b/userland/brush/source/e2e/atuin/tests/README.md @@ -0,0 +1,19 @@ +# Shell integration tests + +End-to-end tests for `atuin init `, driven through a real terminal: each test +starts an interactive shell inside tmux with the integration loaded, sends keystrokes, +and checks the screen and the history database. + +```bash +pytest tests/ # needs pytest, tmux, atuin on PATH +pytest tests/ -k history +``` + +Environment: + +- `ATUIN_TEST_BASH` — bash binary to test (default: `bash` from `PATH`). +- `E2E_TEST_TIMEOUT` — seconds to wait for a screen condition (default: 10). + +Each test gets a fresh `HOME`, so atuin's config and database are isolated and no +sync is configured. The tmux driver is `lib/tmux_shell.py`, shared with the other +suites in this harness; per-shell test files (`test_atuin.py`) hold the scenarios. diff --git a/userland/brush/source/e2e/atuin/tests/test_atuin.py b/userland/brush/source/e2e/atuin/tests/test_atuin.py new file mode 100644 index 0000000..8665de0 --- /dev/null +++ b/userland/brush/source/e2e/atuin/tests/test_atuin.py @@ -0,0 +1,116 @@ +"""atuin shell integration: bash. + +Written to be upstreamable: nothing here knows which shell it is driving beyond +`$ATUIN_TEST_BASH`, and every assertion is about atuin's own observable behavior. +""" + +import os +import re +import subprocess +import time + +import pytest +from rcfile import write_rc +from tmux_shell import TIMEOUT, TmuxShell + + +PROMPT = "atuin-test>" + + +def atuin(*args): + """Runs atuin out of band, against the same database the shell under test is using.""" + # atuin insists on a session id even just to list. + env = os.environ | {"ATUIN_SESSION": os.environ.get("ATUIN_SESSION") or uuid()} + return subprocess.run( + ["atuin", *args], env=env, capture_output=True, text=True, check=False + ).stdout + + +def uuid(): + return subprocess.run(["atuin", "uuid"], capture_output=True, text=True, check=True).stdout.strip() + + +def wait_for_history(pattern): + """Waits until `atuin history list` matches. atuin records `history end` asynchronously.""" + search = re.compile(pattern, re.MULTILINE).search + deadline = time.monotonic() + TIMEOUT + while True: + listing = atuin("history", "list", "--format", "{exit} {command}") + if search(listing): + return + assert time.monotonic() < deadline, ( + f"timed out waiting for history /{pattern}/; history:\n{listing}" + ) + time.sleep(0.1) + + +@pytest.fixture +def shell(tmp_path, monkeypatch): + # Isolate atuin's config and database per test. + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + for name in ("XDG_CONFIG_HOME", "XDG_DATA_HOME", "ATUIN_SESSION", "ATUIN_HISTORY_ID", "TMUX"): + monkeypatch.delenv(name, raising=False) + + # Create and migrate the database once, up front: atuin migrates on first use, and two atuin + # processes racing to migrate a fresh database trip a UNIQUE constraint. Our out-of-band + # history queries would otherwise race the shell's own atuin at startup. + atuin("history", "list") + + rc = write_rc(tmp_path / "rc", PROMPT, 'eval "$(atuin init bash)"') + shell = TmuxShell(tmp_path / "tmux", os.environ["ATUIN_TEST_BASH"], rc, PROMPT) + yield shell + shell.close() + + +def test_init_binds_ctrl_r_and_up_arrow(shell, tmp_path): + macros = tmp_path / "macros" + shell.type_line(f"bind -s > {macros}") + bindings = macros.read_text() + assert r'"\C-r"' in bindings + assert r'"\e[A"' in bindings + + +def test_commands_are_recorded_with_their_exit_status(shell): + shell.type_line("echo recorded-one") + shell.wait_for("^recorded-one$") + shell.type_line("false") + # An empty command, so atuin's precmd fires `history end` for `false`, whose exit status it + # only records on the following prompt. + shell.type_line("") + wait_for_history("^0 echo recorded-one$") + wait_for_history("^1 false$") + + +def test_ctrl_r_search_runs_the_selected_command(shell): + shell.type_line("echo needle-in-history") + shell.wait_for("^needle-in-history$") + shell.type_line("") + wait_for_history("^0 echo needle-in-history$") + shell.send_key("C-r") + shell.wait_for("Atuin v") + shell.send_text("needle-in") + shell.wait_for(r"^\s*>.*echo needle-in-history") + shell.send_key("Enter") + shell.wait_for_prompt() + # The command was run again: two outputs on screen. + shell.wait_for_screen_count("^needle-in-history$", 2) + + +def test_escaping_ctrl_r_search_keeps_the_current_line(shell): + shell.send_text("echo keep-me") + shell.send_key("C-r") + shell.wait_for("Atuin v") + shell.send_key("Escape") + shell.wait_for(r"^atuin-test> echo keep-me$") + + +def test_up_arrow_opens_search(shell): + shell.type_line("echo up-arrow-entry") + shell.wait_for("^up-arrow-entry$") + shell.send_key("Up") + shell.wait_for("Atuin v") + shell.send_key("Escape") + shell.wait_for_prompt() diff --git a/userland/brush/source/e2e/atuin/xfail-list.txt b/userland/brush/source/e2e/atuin/xfail-list.txt new file mode 100644 index 0000000..057ca50 --- /dev/null +++ b/userland/brush/source/e2e/atuin/xfail-list.txt @@ -0,0 +1,11 @@ +# Tests that run but are expected to fail, one pytest test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. + +# The Ctrl-R search opens and the selected entry does reach the command line, but Enter does not +# run it: the screen ends with the recalled `echo needle-in-history` sitting at the prompt, so its +# output appears once rather than twice. atuin's init also prints `atuin-bind: unknown keymap ''` +# at startup, but that is not what fails here -- the two sibling tests that open the search and +# escape out of it both pass. Tracked under the umbrella binding issue, +# https://github.com/reubeno/brush/issues/380 +test_ctrl_r_search_runs_the_selected_command diff --git a/userland/brush/source/e2e/bash-completion/Dockerfile b/userland/brush/source/e2e/bash-completion/Dockerfile new file mode 100644 index 0000000..c30c2e2 --- /dev/null +++ b/userland/brush/source/e2e/bash-completion/Dockerfile @@ -0,0 +1,17 @@ +# Environment for bash-completion's own test suite (test/t, pytest). The base is upstream's own +# test image, which has the commands under test installed; it is deliberately unpinned, to track +# upstream, which this adapter's `report-only` policy tolerates. The suite is built as upstream's +# test/docker/entrypoint.sh does, since some completions (aliases such as scp, python3) exist only +# after `make`. +FROM ghcr.io/scop/bash-completion/test:fedoradev +ARG BASH_COMPLETION_REF=2.18.0 +RUN git clone -q --depth 1 --branch "$BASH_COMPLETION_REF" \ + https://github.com/scop/bash-completion.git /bash-completion \ + && cd /bash-completion && autoreconf -i && ./configure -q && make -s -j"$(nproc)" \ + && chmod -R a+rwX /bash-completion \ + && chmod a+w /etc/passwd /etc/group +COPY bash-completion/entrypoint.sh /e2e/ +COPY lib /e2e/lib +ENV PYTHONPATH=/e2e/lib +# Some completions (xrandr, say) need an X display. +ENTRYPOINT ["xvfb-run", "-a", "/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/bash-completion/entrypoint.sh b/userland/brush/source/e2e/bash-completion/entrypoint.sh new file mode 100755 index 0000000..d77ac2a --- /dev/null +++ b/userland/brush/source/e2e/bash-completion/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +# The runner's host uid has no account here, which user and group completions (chown, compgen -u) +# and the suite itself (getpwuid) need. +if ! getent passwd "$(id -u)" >/dev/null; then + echo "e2e:x:$(id -u):$(id -g)::${HOME}:/bin/bash" >>/etc/passwd + getent group "$(id -g)" >/dev/null || echo "e2e:x:$(id -g):" >>/etc/group +fi +# The suite takes a command line, not a path, so brush's own flags can go here. Unset, the suite +# runs the image's bash, which is the baseline. +case ${SHELL_UNDER_TEST-} in + '') ;; + */brush*) export BASH_COMPLETION_TEST_BASH="$SHELL_UNDER_TEST --noprofile --no-config --input-backend=basic" ;; + *) export BASH_COMPLETION_TEST_BASH="$SHELL_UNDER_TEST --noprofile" ;; +esac +cd /bash-completion/test || exit 1 +E2E_TESTS=t e2e_run_pytest bash-completion -n auto "$@" diff --git a/userland/brush/source/e2e/bash-completion/report-only b/userland/brush/source/e2e/bash-completion/report-only new file mode 100644 index 0000000..b2f2327 --- /dev/null +++ b/userland/brush/source/e2e/bash-completion/report-only @@ -0,0 +1,4 @@ +# Report-only: test failures are counted in the dashboard (with a pass rate) but do not fail the +# run. Upstream's own @pytest.mark.xfail markers are the only expectations; there is no +# xfail-list.txt. The run still fails when the image cannot be built, the suite times out, or it +# leaves no JUnit. diff --git a/userland/brush/source/e2e/bash-tests/Dockerfile b/userland/brush/source/e2e/bash-tests/Dockerfile new file mode 100644 index 0000000..281d345 --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/Dockerfile @@ -0,0 +1,31 @@ +# Environment for bash's own regression suite (tests/run-*). Bash is built from source, pinned +# to the bash-5.3 release: the build provides the helper binaries the suite needs (recho, zecho, +# printenv, xcase) and a reference bash whose output matches the `.right` files, which is what +# `--baseline` runs. +FROM ubuntu:26.04 +# When bumping this (and the tag below), run `cargo xtask test e2e --baseline bash-tests` first: it +# catches what brush runs cannot, such as a new test needing a locale or tool this image lacks. +# Then run against brush and refresh xfail-list.txt; the runner flags new failures and stale entries. +ARG BASH_REF=b8c60bc9ca365f8261fa97900b6fa939f6ebc303 +RUN apt-get update && apt-get install -y --no-install-recommends \ + bsdextrautils build-essential ca-certificates git locales python3-pytest \ + python3-pytest-xdist \ + && rm -rf /var/lib/apt/lists/* +# Every locale the tests switch to, legacy charsets included: they check `locale -a` for these +# names. SHIFT_JIS is not ASCII-compatible, which localedef warns about unless told not to. +RUN for spec in en_US:UTF-8 de_DE:UTF-8 fr_FR:ISO-8859-1 ru_RU:CP1251 zh_TW:BIG5 \ + zh_HK:BIG5-HKSCS ja_JP:SHIFT_JIS; do \ + localedef --no-warnings=ascii -i "${spec%:*}" -f "${spec#*:}" \ + "${spec%:*}.$(echo "${spec#*:}" | sed 's/SHIFT_JIS/SJIS/')" || exit 1; \ + done +# Savannah serves no uploadpack for arbitrary SHAs, so clone the tag and check it is the commit. +RUN git clone -q --depth 1 --branch bash-5.3 https://git.savannah.gnu.org/git/bash.git /bash \ + && test "$(git -C /bash rev-parse HEAD)" = "$BASH_REF" \ + && cd /bash && ./configure -q && make -s -j"$(nproc)" \ + && make -s recho zecho printenv xcase && cp recho zecho printenv xcase tests/ \ + && chmod -R a+rX /bash +COPY bash-tests/entrypoint.sh bash-tests/skip-list.txt /e2e/ +COPY bash-tests/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/bash-tests/entrypoint.sh b/userland/brush/source/e2e/bash-tests/entrypoint.sh new file mode 100755 index 0000000..f49eaba --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +# The baseline is the bash built alongside the suite, not the image's /bin/bash. +export THIS_SH="${SHELL_UNDER_TEST:-/bash/bash}" +e2e_run_pytest bash-tests -n auto "$@" diff --git a/userland/brush/source/e2e/bash-tests/skip-list.txt b/userland/brush/source/e2e/bash-tests/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/bash-tests/tests/conftest.py b/userland/brush/source/e2e/bash-tests/tests/conftest.py new file mode 100644 index 0000000..a5759eb --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/tests/conftest.py @@ -0,0 +1,46 @@ +import re +from pathlib import Path + + +def pytest_addoption(parser): + parser.addoption( + "--subset", + type=suite_members, + help="run only the scripts an upstream suite runs, e.g. `minimal` for run-minimal", + ) + parser.addoption( + "--script-timeout", + type=int, + default=30, + help="seconds each run script may take (default 30; slow scripts get a multiple)", + ) + + +def suite_members(name): + """The run scripts named in the `run-X|run-Y) echo $x ; sh $x ;;` arms of tests/run-.""" + members = set() + for line in (Path("/bash/tests") / f"run-{name}").read_text().splitlines(): + if "sh $x" in line and ")" in line: + members.update(re.findall(r"run-([\w-]+)", line.split(")")[0])) + return members + + +# Expected lines reproduced across the run, summed from each script's properties. Pass/fail per +# script hides progress within one; this shows it. Scripts that time out report no counts. +lines = {"matched_lines": 0, "expected_lines": 0} + + +def pytest_runtest_logreport(report): + # With xdist this runs in the controller, which receives each worker's user_properties. + if report.when == "call": + for name, value in report.user_properties: + if name in lines: + lines[name] += value + + +def pytest_terminal_summary(terminalreporter): + matched, expected = lines["matched_lines"], lines["expected_lines"] + if expected: + terminalreporter.write_line( + f"expected lines matched: {matched}/{expected} ({100 * matched / expected:.1f}%)" + ) diff --git a/userland/brush/source/e2e/bash-tests/tests/test_bash.py b/userland/brush/source/e2e/bash-tests/tests/test_bash.py new file mode 100644 index 0000000..bfe4977 --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/tests/test_bash.py @@ -0,0 +1,235 @@ +"""Bash's own regression suite (tests/run-*), one pytest case per run script. + +Each upstream `run-X` script runs `${THIS_SH} ./X.tests > ${BASH_TSTOUT}` and then diffs that +against `X.right`. Rather than re-parse those command lines, a case sources the script verbatim +with `diff` defined as a shell function that records what it was asked to compare; the comparison +itself happens here, after the shell's name is normalized out of its error messages. + +Every script runs with a terminal as its controlling terminal and stdin, as under `make tests` from +a shell: several tests depend on one (`read -e`, `test -t`, history, `bash -i`). Their output still +goes where the scripts redirect it, not to the terminal. + +Output is compared as bytes, not text: some `.right` files are not UTF-8, so it is decoded with +`surrogateescape`, which keeps each undecodable byte distinct instead of folding them all into +U+FFFD, and split on newlines only, so a missing final newline is a difference too. +""" + +import difflib +import fcntl +import os +import resource +import shutil +import signal +import struct +import subprocess +import termios +import threading +from pathlib import Path + +import pytest + +TESTS = Path("/bash/tests") +RESULTS = Path("/results/bash-tests") +THIS_SH = os.environ["THIS_SH"] + +# Multiples of --script-timeout: `jobs` sleeps and waits for about a minute in total. +SLOW_SCRIPTS = {"jobs": 4} + +# Output that differs on Linux for bash itself, keyed by run script: a test whose every differing +# hunk is a small one around a line with one of its markers passes. Any other hunk still fails it, +# so these cannot mask a regression elsewhere in the same test. +PLATFORM_DIFFS = { + # exec.right lists traps in the signal numbering where SIGTERM comes before SIGUSR1. + "execscript": ["trap -- "], + # glibc's printf reports this overflow, where .right expects it not to. + "printf": ["Value too large"], +} + +# `ulimit -u` beyond the hard limit is an expected error in builtins11.sub, but a container's +# limit is typically unlimited; give the tests a finite one. +soft, hard = resource.getrlimit(resource.RLIMIT_NPROC) +if hard == resource.RLIM_INFINITY: + hard = 1 << 20 + # RLIM_INFINITY is -1 here, so min() would keep it. + soft = hard if soft == resource.RLIM_INFINITY else min(soft, hard) + resource.setrlimit(resource.RLIMIT_NPROC, (soft, hard)) + +# `diff` as the run scripts call it: [flags] actual expected, numbered because run-dirstack makes +# two. The `diff -a x x` probe some scripts make, to see whether -a is supported, compares a file +# with itself and is not recorded. +CAPTURE_DIFF = r""" +diff() { + while [ "${1#-}" != "$1" ]; do shift; done + [ "$1" = "$2" ] && return 0 + CAPTURED=$((${CAPTURED:-0} + 1)) + cp "$1" "$CAPTURE_DIR/$CAPTURED.actual" + printf '%s\n' "$2" > "$CAPTURE_DIR/$CAPTURED.right" +} +""" + + +def run_scripts(): + # run-all and run-minimal run the others; run-gprof is not a test. + skip = {"run-all", "run-minimal", "run-gprof"} + return sorted( + p.name.removeprefix("run-") + for p in TESTS.glob("run-*") + if p.name not in skip and not p.name.endswith(("~", ".orig")) + ) + + +def normalize(output): + """Report the shell as `bash`, as the `.right` files do, whatever its path or name.""" + name = Path(THIS_SH).name + lines = [] + for line in lines_of(output.replace(f"{THIS_SH}:", "bash:")): + if line.startswith(f"{name}:"): + line = "bash:" + line.removeprefix(f"{name}:") + lines.append(line) + return lines + + +def lines_of(text): + return text.split("\n") + + +def read_lines(path): + return path.read_bytes().decode(errors="surrogateescape") + + +def printable(text): + """Text for a message or log, with undecodable bytes shown as `\\xNN`.""" + return text.encode(errors="surrogateescape").decode(errors="backslashreplace") + + +def matching_lines(expected, actual): + """How many expected lines the actual output reproduces, in order.""" + matcher = difflib.SequenceMatcher(None, expected, actual, autojunk=False) + return sum(block.size for block in matcher.get_matching_blocks()) + + +def explained(script, expected, actual): + markers = PLATFORM_DIFFS.get(script) + if not markers: + return False + for hunk in difflib.SequenceMatcher(None, expected, actual).get_grouped_opcodes(): + changed = [ + line + for tag, i1, i2, j1, j2 in hunk + if tag != "equal" + for line in expected[i1:i2] + actual[j1:j2] + if line + ] + marked = sum(any(marker in line for marker in markers) for line in changed) + # ponytail: the known differences are a marker line plus at most a couple of lines it + # displaces (blank lines not counted); anything larger is a real difference that happens + # to mention a marker. + if not marked or len(changed) > 3 * marked: + return False + return True + + +def run(script, cwd, env, timeout): + command = ["sh", "-c", f'{CAPTURE_DIFF}\n. ./"$1"', "sh", f"run-{script}"] + master, slave = os.openpty() + # A terminal's usual size, for output that lays itself out by it (select menus). + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + + def controlling_terminal(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + + process = subprocess.Popen( + command, cwd=cwd, env=env, stdin=slave, stdout=slave, stderr=slave, + preexec_fn=controlling_terminal, + ) + os.close(slave) + chunks = [] + + def drain(): + try: + while chunk := os.read(master, 65536): + chunks.append(chunk) + except OSError: + # EIO once the last holder of the slave side has gone: the terminal's end of output. + pass + + reader = threading.Thread(target=drain, daemon=True) + reader.start() + try: + wait(process, timeout) + finally: + reader.join(timeout=5) + os.close(master) + return b"".join(chunks) + + +def wait(process, timeout): + try: + process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + # The shell under test, and anything it started, is in the process group. + os.killpg(process.pid, signal.SIGKILL) + process.communicate() + pytest.fail(f"timed out after {timeout}s", pytrace=False) + + +@pytest.mark.parametrize("script", run_scripts()) +def test_bash(script, tmp_path, request, record_property): + if (subset := request.config.getoption("subset")) and script not in subset: + pytest.skip("not in --subset") + # A fresh copy per case: the scripts write into their working directory and TMPDIR. + work = tmp_path / "tests" + shutil.copytree(TESTS, work, symlinks=True) + capture = tmp_path / "capture" + capture.mkdir() + tmp = tmp_path / "tmp" + tmp.mkdir() + (tmp_path / "home").mkdir() + # What `make tests` and run-all export; BASH_ENV, SHELLOPTS and BASHOPTS they remove. The + # tests dir goes on PATH by absolute path too, for recho & co. after a test changes directory. + # HOME must not be /tmp, as the container has it: the dirstack tests expect /tmp not to read + # back as `~`. + env = {k: v for k, v in os.environ.items() if k not in ("BASH_ENV", "SHELLOPTS", "BASHOPTS")} + env.update( + THIS_SH=THIS_SH, BUILD_DIR="/bash", TMPDIR=str(tmp), PATH=f".:{work}:{env['PATH']}", + HOME=str(tmp_path / "home"), + BASH_TSTOUT=str(tmp_path / "tstout"), CAPTURE_DIR=str(capture), + ) + timeout = request.config.getoption("script_timeout") * SLOW_SCRIPTS.get(script, 1) + runner_output = run(script, work, env, timeout).decode(errors="replace") + if runner_output: + print(runner_output) + + captures = sorted(capture.glob("*.right"), key=lambda p: int(p.stem)) + assert captures, f"run-{script} never compared its output" + failures = [] + matched = total = 0 + for captured in captures: + right = captured.read_text().strip() + # The run scripts diff ` .right`; anything else means one changed shape. + assert right.endswith(".right"), f"run-{script} compared its output with {right!r}" + expected = lines_of(read_lines(work / right)) + actual = normalize(read_lines(captured.with_suffix(".actual"))) + total += len(expected) + diff = "\n".join(difflib.unified_diff(expected, actual, right, "actual", lineterm="")) + if not diff: + matched += len(expected) + continue + if explained(script, expected, actual): + matched += len(expected) + print(f"{right}: differences explained by PLATFORM_DIFFS:\n{printable(diff)}") + continue + matched += matching_lines(expected, actual) + RESULTS.mkdir(parents=True, exist_ok=True) + stem = Path(right).stem + (RESULTS / f"{stem}.actual").write_bytes( + "\n".join(actual).encode(errors="surrogateescape") + ) + (RESULTS / f"{stem}.diff").write_bytes((diff + "\n").encode(errors="surrogateescape")) + failures.append(f"output differs from {right}:\n{printable(diff)}") + record_property("matched_lines", matched) + record_property("expected_lines", total) + if failures: + failures.insert(0, f"{matched}/{total} expected lines match") + pytest.fail("\n".join(failures), pytrace=False) diff --git a/userland/brush/source/e2e/bash-tests/xfail-list.txt b/userland/brush/source/e2e/bash-tests/xfail-list.txt new file mode 100644 index 0000000..e522eca --- /dev/null +++ b/userland/brush/source/e2e/bash-tests/xfail-list.txt @@ -0,0 +1,86 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. +# +# Upstream bash scripts whose output brush does not yet match line for line. Each script covers a +# whole area (arrays, traps, ...), so one differing line fails it; the diff against its `.right` +# file is in the failure message and in bash-tests/.diff under the run's results directory. +test_bash[alias] +test_bash[appendop] +test_bash[arith-for] +test_bash[arith] +test_bash[array] +test_bash[assoc] +test_bash[attr] +test_bash[braces] +test_bash[builtins] +test_bash[case] +test_bash[casemod] +test_bash[complete] +test_bash[comsub-eof] +test_bash[comsub-posix] +test_bash[comsub2] +test_bash[comsub] +test_bash[cond] +test_bash[coproc] +test_bash[cprint] +test_bash[dbg-support2] +test_bash[dbg-support] +test_bash[dirstack] +test_bash[dollars] +test_bash[dynvar] +test_bash[errors] +test_bash[execscript] +test_bash[exp-tests] +test_bash[exportfunc] +test_bash[extglob2] +test_bash[extglob] +test_bash[func] +test_bash[glob-bracket] +test_bash[glob-test] +test_bash[globstar] +test_bash[heredoc] +test_bash[histexpand] +test_bash[history] +test_bash[ifs-posix] +test_bash[ifs] +test_bash[input-test] +test_bash[intl] +test_bash[invocation] +test_bash[iquote] +test_bash[jobs] +test_bash[mapfile] +test_bash[more-exp] +test_bash[nameref] +test_bash[new-exp] +test_bash[nquote5] +test_bash[nquote] +test_bash[parser] +test_bash[posix2] +test_bash[posixexp2] +test_bash[posixexp] +test_bash[posixpat] +test_bash[posixpipe] +test_bash[printf] +test_bash[procsub] +test_bash[quote] +test_bash[quotearray] +test_bash[read] +test_bash[redir] +test_bash[rsh] +test_bash[set-e] +test_bash[set-x] +test_bash[shopt] +test_bash[test] +test_bash[tilde2] +test_bash[tilde] +test_bash[trap] +test_bash[type] +test_bash[vredir] + +# brush writes U+FFFD where bash writes the raw, non-UTF-8 bytes that $'...' escapes produce. +test_bash[nquote4] + +# Hang under brush until the 30s per-script timeout kills them. +test_bash[getopts] +test_bash[varenv] diff --git a/userland/brush/source/e2e/blesh/Dockerfile b/userland/brush/source/e2e/blesh/Dockerfile new file mode 100644 index 0000000..b1fabde --- /dev/null +++ b/userland/brush/source/e2e/blesh/Dockerfile @@ -0,0 +1,16 @@ +# Environment for running ble.sh's own test suite (`ble.sh --test`, which is what upstream's +# `make check` runs; see https://github.com/akinomyoga/ble.sh/blob/master/.github/workflows/test.yml, +# BSD-3-Clause). Build needs git, GNU make and gawk (per upstream README). ble.sh is cloned and +# built at image build time, pinned below. less: the pager test expects it; procps: ps for hang diagnostics. +FROM ubuntu:26.04 +ARG BLESH_REF=63c23e99f1f7133ae57b79f87b16d1f68cd39884 +RUN apt-get update && apt-get install -y --no-install-recommends git make gawk less procps ca-certificates \ + python3 python3-pytest \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --filter=blob:none https://github.com/akinomyoga/ble.sh /blesh \ + && git -C /blesh checkout -q "$BLESH_REF" && make -C /blesh && chmod -R a+rwX /blesh +COPY blesh/entrypoint.sh blesh/skip-list.txt /e2e/ +COPY blesh/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/blesh/entrypoint.sh b/userland/brush/source/e2e/blesh/entrypoint.sh new file mode 100755 index 0000000..25e344d --- /dev/null +++ b/userland/brush/source/e2e/blesh/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +# ble.sh --test runs under whatever shell invokes it, so no PATH shim is needed. +export BLESH_TEST_SHELL="${SHELL_UNDER_TEST:-bash}" +# Arguments select test sections rather than pytest node ids, so they do not go to pytest. +export BLESH_TEST_FILES= +(($# == 0)) || printf -v BLESH_TEST_FILES '%s\n' "$@" +mkdir -p /results/progress +# shellcheck disable=SC2174 # /results already exists; only run/ needs mode 700. +mkdir -p -m 700 /results/run +export XDG_RUNTIME_DIR=/results/run RUST_BACKTRACE=1 +e2e_run_pytest blesh --tb=short diff --git a/userland/brush/source/e2e/blesh/select-args.txt b/userland/brush/source/e2e/blesh/select-args.txt new file mode 100644 index 0000000..f90365f --- /dev/null +++ b/userland/brush/source/e2e/blesh/select-args.txt @@ -0,0 +1 @@ +{test} diff --git a/userland/brush/source/e2e/blesh/skip-list.txt b/userland/brush/source/e2e/blesh/skip-list.txt new file mode 100644 index 0000000..d473fbe --- /dev/null +++ b/userland/brush/source/e2e/blesh/skip-list.txt @@ -0,0 +1,4 @@ +# Test files that must not run at all, one per line, named as in `ble.sh --test NAME` (bash, main, +# util, canvas, decode, edit, syntax, complete, keymap.vi), each with a comment saying why. +# Reserve this for files that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/blesh/tests/test_blesh.py b/userland/brush/source/e2e/blesh/tests/test_blesh.py new file mode 100644 index 0000000..36e56a6 --- /dev/null +++ b/userland/brush/source/e2e/blesh/tests/test_blesh.py @@ -0,0 +1,135 @@ +import logging +import os +import re +import shutil +import signal +import subprocess +import time +from pathlib import Path + +import pytest +from e2e_skip import parse + + +# ble.sh has no list of test sections: `ble.sh --test ` just sources lib/test-.sh. +# Its GNUmakefile decides which of those are sections by building them into out/lib, leaving +# include-only data (lib/test-canvas.GraphemeClusterTest.sh) behind. Reading the built directory +# therefore tracks upstream: a section added by a version bump runs instead of being missed. +SECTIONS = Path("/blesh/out/lib") +RESULTS = Path("/results") +RUN_DIR = RESULTS / "run" +PROGRESS_DIR = RESULTS / "progress" +ANSI = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") +SUMMARY = re.compile( + r"\[section\] (.+?): (\d+)/(\d+) \((\d+) fail, (\d+) crash, (\d+) skip\)" +) +LOG = logging.getLogger(__name__) + + +def selected_files(): + available = sorted(path.stem.removeprefix("test-") for path in SECTIONS.glob("test-*.sh")) + if not available: + raise RuntimeError(f"no ble.sh test sections under {SECTIONS}") + requested = [line for line in os.environ.get("BLESH_TEST_FILES", "").splitlines() if line] + if unknown := sorted(set(requested) - set(available)): + raise RuntimeError( + f"unknown ble.sh test section(s): {', '.join(unknown)}; " + f"available: {', '.join(available)}" + ) + # The skip list applies to an explicit selection too: a section that hangs hangs either way. + # Parsed here rather than by the shared pytest plugin: a section must be dropped before it is + # parametrized, or ble.sh would still be run for it. + skipped = parse(os.environ.get("E2E_SKIP_LIST", "")) + return [name for name in requested or available if name not in skipped] + + +def save_progress(name): + saved = [] + for source in (RUN_DIR / "blesh").glob("*.test/*"): + if not source.name.isdigit(): + continue + destination = PROGRESS_DIR / f"test-{name}" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + saved.append(str(destination)) + return saved + + +@pytest.fixture(scope="session", autouse=True) +def clean_artifacts(): + shutil.rmtree(PROGRESS_DIR, ignore_errors=True) + + +@pytest.mark.parametrize("name", selected_files()) +def test_upstream_file(name): + shutil.rmtree(RUN_DIR / "blesh", ignore_errors=True) + started = time.monotonic() + process = subprocess.Popen( + [os.environ["BLESH_TEST_SHELL"], "ble.sh", "--test", name], + cwd="/blesh/out", + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + start_new_session=True, + ) + timed_out = False + process_tree = "" + try: + timeout = int(os.environ.get("BLESH_TEST_TIMEOUT", "180")) + output, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + process_tree = subprocess.run( + [ + "ps", + "-s", + str(process.pid), + "-o", + "pid,stat,etime,wchan:24,args", + "--forest", + ], + capture_output=True, + text=True, + check=False, + ).stdout + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + LOG.debug("process group %s no longer exists during timeout cleanup", process.pid) + try: + # Bounded: a descendant that escaped the process group would otherwise hold the pipe + # open for as long as it lives, turning one hung section into a hung suite. + output, _ = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + output = "" + + progress = save_progress(name) + LOG.info( + "==> test-%s\n%s\n%s==> test-%s (%.1fs) exit=%s%s", + name, + output, + f"process tree:\n{process_tree}" if process_tree else "", + name, + time.monotonic() - started, + process.returncode, + f"\nsaved progress: {', '.join(progress)}" if progress else "", + ) + + summaries = SUMMARY.findall(ANSI.sub("", output)) + failed = [ + f"{section}: {passed}/{total} ({failures} fail, {crashes} crash)" + for section, passed, total, failures, crashes, _ in summaries + if passed != total or failures != "0" or crashes != "0" + ] + problems = [] + if timed_out: + problems.append("timed out") + elif process.returncode: + problems.append(f"exited with status {process.returncode}") + if not summaries: + problems.append("emitted no section summary") + problems.extend(failed) + assert not problems, "; ".join(problems) diff --git a/userland/brush/source/e2e/blesh/xfail-list.txt b/userland/brush/source/e2e/blesh/xfail-list.txt new file mode 100644 index 0000000..e432805 --- /dev/null +++ b/userland/brush/source/e2e/blesh/xfail-list.txt @@ -0,0 +1,14 @@ +# Tests that run but are expected to fail, one pytest test id per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. + +# ble.sh does not get far enough under brush to emit a section summary for any of its test files. +test_upstream_file[bash] +test_upstream_file[main] +test_upstream_file[util] +test_upstream_file[canvas] +test_upstream_file[decode] +test_upstream_file[edit] +test_upstream_file[syntax] +test_upstream_file[complete] +test_upstream_file[keymap.vi] diff --git a/userland/brush/source/e2e/fzf/Dockerfile b/userland/brush/source/e2e/fzf/Dockerfile new file mode 100644 index 0000000..e272053 --- /dev/null +++ b/userland/brush/source/e2e/fzf/Dockerfile @@ -0,0 +1,17 @@ +# Environment for running fzf's bash shell-integration tests. Package list derived from +# fzf's Dockerfile (https://github.com/junegunn/fzf/blob/master/Dockerfile, MIT license), +# trimmed to what the bash tests need. fzf itself is cloned at build time, pinned below. +FROM rubylang/ruby:4.0.8-noble +ARG FZF_REF=52f4319a72c17e123396cc3a2e6abf2e96e9d753 +RUN apt-get update && apt-get install -y --no-install-recommends git make golang tmux ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY fzf/Gemfile /e2e/Gemfile +RUN BUNDLE_GEMFILE=/e2e/Gemfile bundle install +RUN git clone --filter=blob:none https://github.com/junegunn/fzf /fzf \ + && git -C /fzf checkout -q "$FZF_REF" && cd /fzf && make install && chmod -R a+rwX /fzf +# Do not set default PS1 (same as upstream) +RUN rm -f /etc/bash.bashrc +COPY shim /e2e/bin +COPY fzf/entrypoint.sh fzf/skip-list.txt /e2e/ +ENV LANG=C.UTF-8 PATH=/e2e/bin:$PATH BUNDLE_GEMFILE=/e2e/Gemfile +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/fzf/Gemfile b/userland/brush/source/e2e/fzf/Gemfile new file mode 100644 index 0000000..ee02a09 --- /dev/null +++ b/userland/brush/source/e2e/fzf/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'minitest', '5.26.1' +gem 'minitest-ci' diff --git a/userland/brush/source/e2e/fzf/entrypoint.sh b/userland/brush/source/e2e/fzf/entrypoint.sh new file mode 100755 index 0000000..ffec625 --- /dev/null +++ b/userland/brush/source/e2e/fzf/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary (see ../shim/bash); +# results go to /results (junit XML + full log); exit status = test status. +set -uo pipefail +mkdir -p /results +cd /fzf || exit 1 +tmux new-session -d +# Turn skip-list.txt into a minitest --exclude regex. +skips=$(grep -v '^\s*\(#\|$\)' /e2e/skip-list.txt | paste -sd'|') +# Non-bash shells are excluded (rather than selected with -n) so callers can pass their own -n. +exclude="^(TestZsh|TestFish|TestNushell)#" +[[ -n $skips ]] && exclude="$exclude|^($skips)$" +ruby -Itest test/test_shell_integration.rb --verbose --exclude "/$exclude/" \ + --ci-report --ci-dir /results/junit "$@" 2>&1 | tee /results/log.txt +exit "${PIPESTATUS[0]}" diff --git a/userland/brush/source/e2e/fzf/select-args.txt b/userland/brush/source/e2e/fzf/select-args.txt new file mode 100644 index 0000000..f6057fd --- /dev/null +++ b/userland/brush/source/e2e/fzf/select-args.txt @@ -0,0 +1 @@ +-n /{test}/ diff --git a/userland/brush/source/e2e/fzf/skip-list.txt b/userland/brush/source/e2e/fzf/skip-list.txt new file mode 100644 index 0000000..4841328 --- /dev/null +++ b/userland/brush/source/e2e/fzf/skip-list.txt @@ -0,0 +1,17 @@ +# Tests that must not run at all, one "Class#method" per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. + +# Load-flaky in CI: the Tab that should exclude regular files shows NO RECORDS FOUND after ~11s. +# It passes in isolation and fails on main the same way (e.g. run 35480185117), so it cannot be +# xfailed without failing the run whenever it passes. It also hits a real bug: a no-match +# directory completion opens an empty Reedline menu (#1413). +TestBash#test_dir_completion + +# Load-flaky: under brush, fzf binds Ctrl-R with `bind -x`, and the test times out under CPU +# contention (e.g. run 36155337854) but passes in isolation. +TestBash#test_ctrl_r_delete + +# Load-flaky: fails in both CI full-suite runs but passes when selected alone locally. +# It cannot be xfailed because it passes intermittently, which would fail the E2E run. +TestBash#test_dynamic_completion_loader diff --git a/userland/brush/source/e2e/fzf/xfail-list.txt b/userland/brush/source/e2e/fzf/xfail-list.txt new file mode 100644 index 0000000..cdac309 --- /dev/null +++ b/userland/brush/source/e2e/fzf/xfail-list.txt @@ -0,0 +1,21 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. + +# The Alt-C widget does not change directory: the binding installs without error, but the tests +# see no directory change at all. Tracked under the umbrella fzf binding issue, +# https://github.com/reubeno/brush/issues/380 +test_alt_c +test_alt_c_absolute_cmd +test_alt_c_command +test_alt_c_symlink + +# Ctrl-R recall of a multi-line entry comes back empty: +# https://github.com/reubeno/brush/issues/738 +test_ctrl_r_multiline + +# Completion does not trigger for the second command of a `;` sequence. +test_completion_in_command_sequence + +# Background jobs run as in-process tasks, so `cmd &` prints "" and $! is empty. +test_process_completion diff --git a/userland/brush/source/e2e/lib/e2e_skip.py b/userland/brush/source/e2e/lib/e2e_skip.py new file mode 100644 index 0000000..09b6cbb --- /dev/null +++ b/userland/brush/source/e2e/lib/e2e_skip.py @@ -0,0 +1,25 @@ +"""Shared skip-list handling for the Python adapters. + +Load as a pytest plugin (`pytest -p e2e_skip`) and every test named in `$E2E_SKIP_LIST` is +skipped, so an adapter needs no skip code of its own. An adapter that must skip before +collection -- because the test would otherwise be generated at all -- calls `parse` directly. +""" + +import os + +import pytest + + +def parse(text): + """The entries in one of the `*-list.txt` files: one per line, `#` starts a comment.""" + return { + line + for raw in text.splitlines() + if (line := raw.strip()) and not line.startswith("#") + } + + +@pytest.fixture(autouse=True) +def _skip_listed(request): + if request.node.name in parse(os.environ.get("E2E_SKIP_LIST", "")): + pytest.skip("listed in skip-list.txt") diff --git a/userland/brush/source/e2e/lib/pty_shell.py b/userland/brush/source/e2e/lib/pty_shell.py new file mode 100644 index 0000000..fc1ff4f --- /dev/null +++ b/userland/brush/source/e2e/lib/pty_shell.py @@ -0,0 +1,61 @@ +import logging +import os +import time + +import pexpect + + +CURSOR_POSITION_QUERY = "\x1b[6n" +COMMAND_DONE = "__BRUSH_E2E_COMMAND_DONE__" +# Seconds to wait for any one expected string. The overall wait is bounded separately, since a +# shell that keeps asking where the cursor is would otherwise refresh this on every reply. +TIMEOUT = int(os.environ.get("E2E_TEST_TIMEOUT", "10")) +LOG = logging.getLogger(__name__) + + +def spawn(command, args, env, prompt): + child = pexpect.spawn( + command, + args, + dimensions=(30, 100), + encoding="utf-8", + env=env | {"BRUSH_E2E_COMMAND_DONE": COMMAND_DONE}, + timeout=TIMEOUT, + ) + LOG.info("shell startup:\n%s", expect_output(child, prompt)) + return child + + +def expect_output(shell, expected): + output = [] + deadline = time.monotonic() + TIMEOUT + while True: + try: + matched = shell.expect_exact([expected, CURSOR_POSITION_QUERY]) + except pexpect.exceptions.ExceptionPexpect: + output.append(shell.before) + LOG.exception("PTY error waiting for %r:\n%s", expected, "".join(output)) + raise + output.append(shell.before) + if matched == 0: + return "".join(output) + if time.monotonic() >= deadline: + LOG.error("PTY gave up waiting for %r:\n%s", expected, "".join(output)) + raise pexpect.exceptions.TIMEOUT( + f"still answering cursor-position queries after {TIMEOUT}s waiting for {expected!r}" + ) + # Claim the cursor is at the top left. Some prompts (starship) query it at every redraw + # and wait for the reply before printing anything. + shell.send("\x1b[1;1R") + + +def run(shell, prompt, command): + shell.sendline( + f"{command}; __brush_e2e_status=$?; " + 'printf "\\n%s\\n" "$BRUSH_E2E_COMMAND_DONE"; ' + '(exit "$__brush_e2e_status")' + ) + output = expect_output(shell, COMMAND_DONE) + output += expect_output(shell, prompt) + LOG.info("$ %s\n%s", command, output) + return output diff --git a/userland/brush/source/e2e/lib/rcfile.py b/userland/brush/source/e2e/lib/rcfile.py new file mode 100644 index 0000000..d6a4ef2 --- /dev/null +++ b/userland/brush/source/e2e/lib/rcfile.py @@ -0,0 +1,11 @@ +"""The rcfile every adapter's interactive shell starts from. + +Each suite needs the same two things before the app's own integration lines: a prompt it can +recognize on screen, and no history file, so tests neither read nor pollute the real one. +""" + + +def write_rc(path, prompt, *lines): + """Writes `path` and returns it. `prompt` is the PS1 text, without its trailing space.""" + path.write_text("".join([f'PS1="{prompt} "\n', "HISTFILE=\n", *(f"{line}\n" for line in lines)])) + return path diff --git a/userland/brush/source/e2e/lib/run_pytest.sh b/userland/brush/source/e2e/lib/run_pytest.sh new file mode 100644 index 0000000..79e5524 --- /dev/null +++ b/userland/brush/source/e2e/lib/run_pytest.sh @@ -0,0 +1,20 @@ +# shellcheck shell=bash +# The pytest half of the adapter contract (see ../README.md), shared because every adapter needs +# it identically: JUnit and a full log under /results, and the run's own exit status. +# +# Sourced by an entrypoint, which then calls `e2e_run_pytest [pytest args...]`. +# Tests are collected from `$E2E_TESTS`, default `/e2e/tests`, for suites that live elsewhere. + +e2e_run_pytest() { + local report=$1 + shift + mkdir -p /results/junit + # For lib/e2e_skip.py, and for the suites that must consult the list before collection. + E2E_SKIP_LIST=$(cat /e2e/skip-list.txt 2>/dev/null) + export E2E_SKIP_LIST + # Appended: an adapter may already have logged an earlier phase of its run (nvm). + python3 -m pytest -q -p no:cacheprovider -p e2e_skip -o junit_logging=all \ + --log-file="/results/$report.log" --log-file-level=INFO --log-file-format='%(message)s' \ + --junitxml="/results/junit/$report.xml" "$@" "${E2E_TESTS:-/e2e/tests}" 2>&1 | tee -a /results/log.txt + return "${PIPESTATUS[0]}" +} diff --git a/userland/brush/source/e2e/lib/tmux_shell.py b/userland/brush/source/e2e/lib/tmux_shell.py new file mode 100644 index 0000000..eb029d2 --- /dev/null +++ b/userland/brush/source/e2e/lib/tmux_shell.py @@ -0,0 +1,125 @@ +"""Drives an interactive shell inside tmux, for suites that assert on the rendered screen. + +`pty_shell.py` matches a byte stream, which is all a prompt or a hook needs. A full-screen TUI +-- atuin's ctrl-r search -- can only be checked against a terminal that actually drew it, with +its cursor addressing and redraws resolved, so those suites come through here. + +tmux itself is driven by libtmux, the binding tmuxp is built on; what is left here is only the +waiting, which is the part that is particular to testing a shell. +""" + +import logging +import os +import re +import shlex +import time + +from libtmux import Server + + +# Seconds to wait for any one screen state, as for the PTY suites. +TIMEOUT = int(os.environ.get("E2E_TEST_TIMEOUT", "10")) +LOG = logging.getLogger(__name__) + + +class TmuxShell: + """An interactive shell in a detached tmux session, on a socket private to one test.""" + + def __init__(self, socket, shell, rc, prompt, width=100, height=30): + self.prompt = prompt + self.server = Server(socket_path=str(socket)) + # tmux runs the command through `sh -c`, so the paths interpolated into it (the shell + # binary, the rcfile) have to survive another round of word splitting. + command = shlex.join([str(shell), "--noprofile", "--rcfile", str(rc)]) + session = self.server.new_session(window_command=command, x=width, y=height) + self.pane = session.active_pane + self.wait_for_prompt() + LOG.info("shell startup:\n%s", self.screen()) + + def close(self): + self.server.kill() + + def send_text(self, text): + """Types literal text, without running it.""" + self.pane.send_keys(text, enter=False, literal=True) + + def send_key(self, key): + """Sends one key as tmux names it: `Enter`, `C-r`, `Up`, `Escape`.""" + self.pane.send_keys(key, enter=False, literal=False) + + def type_line(self, line): + """Types a command line and runs it, returning once the shell has drawn a *new* prompt. + + Waiting for "a prompt" would not do: a command that prints nothing and leaves the prompt + unchanged (`bind -s > file`) leaves the screen exactly as it was, so the wait is satisfied + by the prompt the command was typed at -- before the shell has even read the keys -- and + the test then inspects an effect that has not happened yet. + + An empty line runs nothing and just waits for the next prompt, which is how a suite gives + a precmd hook its chance to fire. + """ + before = self.prompt_count() + if line: + self.send_text(line) + self.send_key("Enter") + self.wait_for_prompt_count(before + 1) + LOG.info("$ %s\n%s", line, self.screen()) + + def lines(self): + """The visible screen, trailing blank lines removed.""" + screen = self.pane.capture_pane() + while screen and not screen[-1]: + screen.pop() + return screen + + def screen(self): + return "\n".join(self.lines()) + + def prompt_count(self): + """How many prompts the visible screen holds, including the one being typed at.""" + return sum(1 for line in self.lines() if line.startswith(self.prompt)) + + def at_prompt(self): + """Whether the last line on screen is a prompt.""" + screen = self.lines() + return bool(screen) and screen[-1] == self.prompt + + def wait_for(self, pattern): + """Waits until some screen line matches `pattern`.""" + self._wait(lambda: self._matches(pattern) > 0, f"a line matching /{pattern}/") + + def wait_for_prompt(self): + """Waits for the shell to show a prompt as the last line.""" + self._wait(self.at_prompt, "a prompt on the last line") + + def wait_for_prompt_count(self, expected): + """Waits until the screen holds at least `expected` prompts and the last line is one.""" + self._wait( + lambda: self.at_prompt() and self.prompt_count() >= expected, f"{expected} prompt(s)" + ) + + def wait_for_screen_count(self, pattern, expected): + """Waits until exactly `expected` screen lines match `pattern`, failing fast past it.""" + + def counted(): + actual = self._matches(pattern) + # More than asked for will never come back down, so stop rather than wait it out. + assert actual <= expected, ( + f"expected {expected} line(s) matching /{pattern}/, found {actual}; " + f"screen:\n{self.screen()}" + ) + return actual == expected + + self._wait(counted, f"{expected} line(s) matching /{pattern}/") + + def _matches(self, pattern): + return sum(1 for line in self.lines() if re.search(pattern, line)) + + def _wait(self, ready, description): + """Polls until `ready()`, failing with the screen so the failure explains itself.""" + deadline = time.monotonic() + TIMEOUT + while not ready(): + assert time.monotonic() < deadline, ( + f"timed out after {TIMEOUT}s waiting for {description}; screen:\n{self.screen()}" + ) + time.sleep(0.1) diff --git a/userland/brush/source/e2e/mise/Dockerfile b/userland/brush/source/e2e/mise/Dockerfile new file mode 100644 index 0000000..920ad1a --- /dev/null +++ b/userland/brush/source/e2e/mise/Dockerfile @@ -0,0 +1,31 @@ +# Environment for running mise's own bash activation tests. Only tests that need no tool +# downloads are run. MISE_REF must stay the commit that tag v$MISE_VERSION points at, so the +# tests and the binary under them come from one release; check with +# `git ls-remote --tags https://github.com/jdx/mise 'v^{}'` when bumping. +FROM ubuntu:26.04 +ARG MISE_VERSION=2026.9.1 +ARG MISE_REF=8fe6385de7f73908ab5a6c9789f477a322eda3a5 +# Bump these together with MISE_VERSION; they are the release's own SHASUMS256.txt entries. +ARG MISE_SHA256_X64=c98423c8470d6dc416d9f7036d0646d8ef5ae92ad9186907f8fcc84cbe7db4ea +ARG MISE_SHA256_ARM64=0ef0a778eaa8599f3e90a8a0979c9fc3f79922cafb5fa6d39f366d974da33bba +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl git ca-certificates python3-pytest \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --filter=blob:none https://github.com/jdx/mise /mise \ + && git -C /mise checkout -q "$MISE_REF" && chmod -R a+rwX /mise +RUN case "$(uname -m)" in \ + x86_64) arch=x64; sha=$MISE_SHA256_X64 ;; \ + aarch64) arch=arm64; sha=$MISE_SHA256_ARM64 ;; \ + *) exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/mise \ + "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-${arch}" \ + && echo "$sha /tmp/mise" | sha256sum -c - \ + && install -m 755 /tmp/mise /usr/local/bin/mise \ + && rm /tmp/mise +COPY shim /e2e/bin +COPY mise/entrypoint.sh mise/skip-list.txt /e2e/ +COPY mise/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/mise/entrypoint.sh b/userland/brush/source/e2e/mise/entrypoint.sh new file mode 100755 index 0000000..5cf731d --- /dev/null +++ b/userland/brush/source/e2e/mise/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +export MISE_TEST_SHELL="${SHELL_UNDER_TEST:-bash}" +e2e_run_pytest mise "$@" diff --git a/userland/brush/source/e2e/mise/skip-list.txt b/userland/brush/source/e2e/mise/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/mise/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/mise/tests/test_mise.py b/userland/brush/source/e2e/mise/tests/test_mise.py new file mode 100644 index 0000000..48cbf7e --- /dev/null +++ b/userland/brush/source/e2e/mise/tests/test_mise.py @@ -0,0 +1,86 @@ +"""Selected upstream mise bash activation tests, run against the shell under test. + +Only tests that need no tool downloads are run. Each one is an upstream shell script sourced +alongside upstream's own `assert.sh`, so the assertions are mise's, not ours. +""" + +import os +import subprocess + +import pytest + + +UPSTREAM = "/mise/e2e" + + +@pytest.fixture +def run_upstream(tmp_path): + def run(test): + root = tmp_path / "root" + home = tmp_path / "home" + for path in (root, *(home / part for part in ( + ".config/mise", ".local/share/mise", ".local/state/mise", ".cache/mise" + ))): + path.mkdir(parents=True) + shell = os.environ["MISE_TEST_SHELL"] + result = subprocess.run( + [ + shell, "--noprofile", "--norc", "-euo", "pipefail", "-c", + f'cd "$1" && source {UPSTREAM}/assert.sh && source "$2"', + "--", str(root), f"{UPSTREAM}/{test}", + ], + env=os.environ | { + "HOME": str(home), + "MISE_CACHE_DIR": str(home / ".cache/mise"), + "MISE_CONFIG_DIR": str(home / ".config/mise"), + "MISE_DATA_DIR": str(home / ".local/share/mise"), + "MISE_EXPERIMENTAL": "1", + "MISE_STATE_DIR": str(home / ".local/state/mise"), + "MISE_TRUSTED_CONFIG_PATHS": str(root), + "MISE_YES": "1", + # The shim makes upstream's hard-coded `bash` the shell under test. + "PATH": f"/e2e/bin:{os.environ['PATH']}", + "SHELL": shell, + "TEST_DIR": f"{UPSTREAM}/{os.path.dirname(test)}", + "TEST_NAME": test, + "TEST_ROOT": UPSTREAM, + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"{result.stdout}{result.stderr}" + + return run + + +def test_bash_chpwd_hook_tolerates_nounset(run_upstream): + run_upstream("env/test_bash_chpwd_functions_nounset") + + +def test_bash_prompt_hook_runs_consecutively(run_upstream): + run_upstream("env/test_bash_consecutive_prompt_runs") + + +def test_bash_deactivate_clears_the_chpwd_hook(run_upstream): + run_upstream("env/test_bash_deactivate_clears_chpwd_hook") + + +def test_bash_activation_skips_only_the_first_prompt_hook(run_upstream): + run_upstream("env/test_bash_first_prompt_after_activate") + + +def test_bash_no_hook_env_leaves_the_environment_untouched(run_upstream): + run_upstream("env/test_bash_no_hook_env") + + +def test_bash_activation_exports_the_mise_executable(run_upstream): + run_upstream("cli/test_activate_export") + + +def test_bash_hook_env_preserves_path_reordering(run_upstream): + run_upstream("env/test_path_reorder_after_activate") + + +def test_bash_hook_env_manages_shell_aliases(run_upstream): + run_upstream("cli/test_shell_alias") diff --git a/userland/brush/source/e2e/mise/xfail-list.txt b/userland/brush/source/e2e/mise/xfail-list.txt new file mode 100644 index 0000000..6628d83 --- /dev/null +++ b/userland/brush/source/e2e/mise/xfail-list.txt @@ -0,0 +1,10 @@ +# Tests that run but are expected to fail, one pytest test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. + +# mise's status-aware activation uses `export -a`, which brush does not support yet: +# https://github.com/reubeno/brush/issues/1132 +test_bash_chpwd_hook_tolerates_nounset +test_bash_prompt_hook_runs_consecutively +test_bash_deactivate_clears_the_chpwd_hook +test_bash_activation_skips_only_the_first_prompt_hook diff --git a/userland/brush/source/e2e/nvm/Dockerfile b/userland/brush/source/e2e/nvm/Dockerfile new file mode 100644 index 0000000..21612db --- /dev/null +++ b/userland/brush/source/e2e/nvm/Dockerfile @@ -0,0 +1,30 @@ +# Environment for nvm's upstream version-listing tests. The suite is pinned to +# nvm v0.40.7; its runner is pinned to the version used by nvm itself. +FROM ubuntu:26.04 +ARG NVM_REF=f0b0c6bb0b281ceeb106c8cf9ab8fde141215092 +# Bump this together with the urchin version in the URL below; it is the sha256 of the +# npm tarball, cross-checked against the registry's own sha512 integrity field. +ARG URCHIN_SHA256=caa836710230bf0b82caa54375cd5609c7083336910b83202156e2424b4139d0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git python3-pexpect python3-pytest \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL -o /tmp/urchin.tgz https://registry.npmjs.org/urchin/-/urchin-0.0.5.tgz \ + && echo "$URCHIN_SHA256 /tmp/urchin.tgz" | sha256sum -c - \ + && tar -xzf /tmp/urchin.tgz -C /usr/local/bin --strip-components=1 package/urchin \ + && rm /tmp/urchin.tgz \ + && chmod +x /usr/local/bin/urchin +COPY nvm/skip-list.txt /e2e/skip-list.txt +# urchin runs every executable file in the suite, so skipping means taking the execute bit off. +# It happens here, as root: the container runs as the host user and could not chmod these itself. +# `chmod -R a+rwX` then leaves them alone, since capital X only affects what is already executable. +RUN git clone --filter=blob:none https://github.com/nvm-sh/nvm /nvm \ + && git -C /nvm checkout -q "$NVM_REF" \ + && grep -v '^[[:space:]]*\(#\|$\)' /e2e/skip-list.txt | while IFS= read -r test; do \ + chmod a-x "/nvm/test/fast/Listing versions/$test"; \ + done \ + && chmod -R a+rwX /nvm +COPY nvm/entrypoint.sh /e2e/entrypoint.sh +COPY nvm/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/nvm/entrypoint.sh b/userland/brush/source/e2e/nvm/entrypoint.sh new file mode 100755 index 0000000..3f9010e --- /dev/null +++ b/userland/brush/source/e2e/nvm/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +shell="${SHELL_UNDER_TEST:-bash}" +export NVM_TEST_SHELL="$shell" +mkdir -p /results/junit +suite="/nvm/test/fast/Listing versions" +urchin -f -s "$shell" "$suite" 2>&1 | tee /results/log.txt +upstream_status=${PIPESTATUS[0]} + +# urchin has no JUnit reporter, but it does write one " passed" / " failed" line per +# test to .urchin.log in the suite directory. That is ANSI-free and unambiguous, so synthesize the +# report from it rather than from the decorated stdout above; a single testcase for the whole +# suite would hide which upstream test regressed. +python3 - "$suite/.urchin.log" /results/junit/nvm-upstream.xml <<'PY' +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +log, out = (Path(arg) for arg in sys.argv[1:]) +suite = ET.Element("testsuite", name="nvm-upstream") +# A missing log means urchin died before running anything. The empty report that leaves behind is +# what reports it: a JUnit file with no cases in it cannot establish a passing run. +for line in log.read_text(errors="replace").splitlines() if log.exists() else []: + name, _, status = line.rpartition(" ") + if status not in ("passed", "failed"): + continue + case = ET.SubElement(suite, "testcase", classname="nvm-upstream", name=name) + if status == "failed": + ET.SubElement(case, "failure", message="see log.txt for this test's output") +suite.set("tests", str(len(suite))) +suite.set("failures", str(len(suite.findall("testcase/failure")))) +ET.ElementTree(suite).write(out, encoding="utf-8", xml_declaration=True) +PY + +# urchin's own exit status still decides: a crash before any test would leave an empty report. +(( upstream_status == 0 )) || echo "==> urchin exited $upstream_status" >> /results/log.txt + +e2e_run_pytest nvm-interactive "$@" +interactive_status=$? +exit "$((upstream_status || interactive_status))" diff --git a/userland/brush/source/e2e/nvm/skip-list.txt b/userland/brush/source/e2e/nvm/skip-list.txt new file mode 100644 index 0000000..16ae3a9 --- /dev/null +++ b/userland/brush/source/e2e/nvm/skip-list.txt @@ -0,0 +1,7 @@ +# Tests that must not run at all, one upstream test filename per line, each with a comment saying +# why. Reserve this for tests that hang or are irrelevant to the shell under test; anything that +# merely fails belongs in xfail-list.txt, so it keeps running and a fix is reported. +# urchin runs every executable file in the suite, so the image takes the execute bit off these. + +# Has a /bin/zsh shebang, so it would not exercise the shell under test at all. +Running 'nvm ls' should not show a trailing slash diff --git a/userland/brush/source/e2e/nvm/tests/test_nvm.py b/userland/brush/source/e2e/nvm/tests/test_nvm.py new file mode 100644 index 0000000..40e1abd --- /dev/null +++ b/userland/brush/source/e2e/nvm/tests/test_nvm.py @@ -0,0 +1,79 @@ +import os +import resource + +import pytest +from pty_shell import run, spawn +from rcfile import write_rc + + +PROMPT = "nvm-test> " + +# The versions the fixture fabricates, and the one it puts on PATH and makes the default. These +# are arbitrary: nvm reads them from the directory names the fixture creates, and the fake `node` +# in each just echoes its own name, so nothing here tracks real node releases. +VERSIONS = ("v20.11.0", "v22.14.0") +DEFAULT = VERSIONS[-1] + +# nvm's LTS codenames, one alias file each. Only their number matters: `nvm ls` resolves every +# alias, which is where #1173 leaked a file descriptor apiece. Each points at a version the +# fixture does *not* create, as an older LTS line does on a real installation, and those versions +# are generated on nvm's even-major cadence rather than copied from a table that would have to be +# kept current. +LTS_ALIASES = ( + "argon", "boron", "carbon", "dubnium", "erbium", "fermium", + "gallium", "hydrogen", "iron", "jod", "krypton", +) +UNINSTALLED = [f"v{4 + 2 * index}.0.0" for index in range(len(LTS_ALIASES))] + + +@pytest.fixture +def shell(tmp_path): + home = tmp_path / "home" + nvm_dir = tmp_path / "nvm" + home.mkdir() + for version in VERSIONS: + node = nvm_dir / "versions" / "node" / version / "bin" / "node" + node.parent.mkdir(parents=True) + node.write_text(f"#!/bin/sh\necho {version}\n") + node.chmod(0o755) + aliases = nvm_dir / "alias" + lts_aliases = aliases / "lts" + lts_aliases.mkdir(parents=True) + (aliases / "default").write_text(f"{DEFAULT}\n") + for name, version in zip(LTS_ALIASES, UNINSTALLED): + (lts_aliases / name).write_text(f"{version}\n") + + rc = write_rc( + tmp_path / "rc", + PROMPT.rstrip(), + "export NVM_NO_COLORS=--no-colors", + ". /nvm/nvm.sh", + ) + env = os.environ | { + "HOME": str(home), + "NVM_DIR": str(nvm_dir), + "PATH": f"{nvm_dir}/versions/node/{DEFAULT}/bin:{os.environ['PATH']}", + "TERM": "dumb", + } + soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) + child_limit = 128 if hard_limit == resource.RLIM_INFINITY else min(128, hard_limit) + resource.setrlimit(resource.RLIMIT_NOFILE, (child_limit, hard_limit)) + try: + child = spawn( + os.environ["NVM_TEST_SHELL"], + ["--noprofile", "--rcfile", str(rc)], + env=env, + prompt=PROMPT, + ) + finally: + resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, hard_limit)) + yield child + child.close(force=True) + + +def test_interactive_nvm_ls(shell): + output = run(shell, PROMPT, "nvm ls") + for version in VERSIONS: + assert version in output + assert "Too many open files" not in output + assert "nvm_print_alias_" not in output diff --git a/userland/brush/source/e2e/nvm/xfail-list.txt b/userland/brush/source/e2e/nvm/xfail-list.txt new file mode 100644 index 0000000..1e6ff2d --- /dev/null +++ b/userland/brush/source/e2e/nvm/xfail-list.txt @@ -0,0 +1,7 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. + +# Interactive `nvm ls` leaks file descriptors under brush: +# https://github.com/reubeno/brush/issues/1173 +test_interactive_nvm_ls diff --git a/userland/brush/source/e2e/oh-my-git/Dockerfile b/userland/brush/source/e2e/oh-my-git/Dockerfile new file mode 100644 index 0000000..9add013 --- /dev/null +++ b/userland/brush/source/e2e/oh-my-git/Dockerfile @@ -0,0 +1,15 @@ +# oh-my-git is two sourced scripts with no tests of their own, so this image clones them at a +# pinned commit and runs the small PTY-driven prompt suite in tests/. Upstream has no tags or +# releases; OMG_REF is master's head, unchanged since 2022. +FROM ubuntu:26.04 +ARG OMG_REF=8804d8bd763be329543bbe58f0377b28beb7e111 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git python3-pexpect python3-pytest \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --filter=blob:none https://github.com/arialdomartini/oh-my-git /opt/oh-my-git \ + && git -C /opt/oh-my-git checkout -q "$OMG_REF" +COPY oh-my-git/entrypoint.sh oh-my-git/skip-list.txt /e2e/ +COPY oh-my-git/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/oh-my-git/entrypoint.sh b/userland/brush/source/e2e/oh-my-git/entrypoint.sh new file mode 100755 index 0000000..edc1225 --- /dev/null +++ b/userland/brush/source/e2e/oh-my-git/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +export OMG_TEST_SHELL="${SHELL_UNDER_TEST:-bash}" +e2e_run_pytest oh-my-git "$@" diff --git a/userland/brush/source/e2e/oh-my-git/skip-list.txt b/userland/brush/source/e2e/oh-my-git/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/oh-my-git/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/oh-my-git/tests/test_oh_my_git.py b/userland/brush/source/e2e/oh-my-git/tests/test_oh_my_git.py new file mode 100644 index 0000000..7ef2d53 --- /dev/null +++ b/userland/brush/source/e2e/oh-my-git/tests/test_oh_my_git.py @@ -0,0 +1,109 @@ +import os +import subprocess + +import pytest +from pty_shell import run, spawn +from rcfile import write_rc + + +OMG = "/opt/oh-my-git/prompt.sh" +# The rcfile's own PS1. oh-my-git replaces it at every prompt with one of the two below, so seeing +# either proves its PROMPT_COMMAND reached build_prompt; it comes back only when a repo opts out. +ORIGINAL_PROMPT = "omg-original> " +NO_REPO_PROMPT = "omg-norepo> " +REPO_PROMPT = "omg-repo> " +BRANCH = "e2e-branch" +# oh-my-git's own symbols are private-use glyphs from a patched font; words are easier to match. +SETTINGS = ( + f"omg_ungit_prompt='{NO_REPO_PROMPT}'", + f"omg_second_line='{REPO_PROMPT}'", + "omg_has_untracked_files_symbol=UNTRACKED", + "omg_has_modifications_symbol=MODIFIED", +) + + +def git(repo, *args): + subprocess.run( + ["git", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", "-C", str(repo), *args], + check=True, + capture_output=True, + ) + + +@pytest.fixture +def home(tmp_path): + home = tmp_path / "home" + repo = home / "repo" + repo.mkdir(parents=True) + git(repo, "init", "-q", "-b", BRANCH) + (repo / "tracked").write_text("one\n") + git(repo, "add", "tracked") + git(repo, "commit", "-q", "-m", "init") + return home + + +@pytest.fixture +def start(tmp_path, home): + children = [] + + # Each test starts its own shell, so one whose rcfile differs (#471's) can still share this. + def start(*lines, prompt=NO_REPO_PROMPT): + rc = write_rc(tmp_path / "rc", ORIGINAL_PROMPT.rstrip(), *lines, 'cd "$HOME"') + child = spawn( + os.environ["OMG_TEST_SHELL"], + ["--noprofile", "--rcfile", str(rc)], + env=os.environ | {"HOME": str(home), "TERM": "dumb"}, + prompt=prompt, + ) + children.append(child) + return child + + yield start + for child in children: + child.close(force=True) + + +@pytest.fixture +def shell(start): + return start(*SETTINGS, f"source {OMG}") + + +def test_prompt_replaces_ps1_outside_a_repo(shell): + assert "function\r\n" in run(shell, NO_REPO_PROMPT, "type -t build_prompt") + + +def test_sourced_from_a_function_finds_base_sh(start): + # #471: prompt.sh finds base.sh (and build_prompt) through BASH_SOURCE[0], which must name + # prompt.sh itself rather than the rcfile whose function sourced it. + child = start(*SETTINGS, f"load_omg() {{ source {OMG}; }}", "load_omg") + assert "function\r\n" in run(child, NO_REPO_PROMPT, "type -t build_prompt") + + +def test_prompt_shows_the_branch_inside_a_repo(shell): + output = run(shell, REPO_PROMPT, "cd repo") + assert f"({BRANCH})" in output + assert "UNTRACKED" not in output and "MODIFIED" not in output + + +def test_prompt_flags_untracked_and_modified_files(shell, home): + run(shell, REPO_PROMPT, "cd repo") + (home / "repo" / "tracked").write_text("two\n") + (home / "repo" / "new").touch() + output = run(shell, REPO_PROMPT, "true") + assert "UNTRACKED" in output and "MODIFIED" in output + + +def test_repo_can_turn_the_prompt_off(shell, home): + # build_prompt `exit`s from its command substitution here, after echoing the original PS1. + git(home / "repo", "config", "oh-my-git.enabled", "false") + run(shell, ORIGINAL_PROMPT, "cd repo") + + +def test_prompt_callback_output_is_included(shell): + run(shell, "CALLBACK" + NO_REPO_PROMPT, "omg_prompt_callback() { echo CALLBACK; }") + + +def test_command_number_in_the_prompt(start): + # #471's reporter keeps `[\#]` in the prompt oh-my-git falls back to outside a repo. + child = start("omg_ungit_prompt='[\\#]omg-norepo> '", f"source {OMG}", prompt="[1]omg-norepo> ") + run(child, "[2]omg-norepo> ", "true") diff --git a/userland/brush/source/e2e/oh-my-git/xfail-list.txt b/userland/brush/source/e2e/oh-my-git/xfail-list.txt new file mode 100644 index 0000000..3d5ed1f --- /dev/null +++ b/userland/brush/source/e2e/oh-my-git/xfail-list.txt @@ -0,0 +1,3 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. diff --git a/userland/brush/source/e2e/shim/bash b/userland/brush/source/e2e/shim/bash new file mode 100755 index 0000000..7f0f01e --- /dev/null +++ b/userland/brush/source/e2e/shim/bash @@ -0,0 +1,5 @@ +#!/bin/sh +# e2e shim: applications' test suites invoke "bash" by name; route that to the +# shell under test. Falls back to the real bash so the same image can produce +# a baseline run. +exec "${SHELL_UNDER_TEST:-/bin/bash}" "$@" diff --git a/userland/brush/source/e2e/starship/Dockerfile b/userland/brush/source/e2e/starship/Dockerfile new file mode 100644 index 0000000..65b86c6 --- /dev/null +++ b/userland/brush/source/e2e/starship/Dockerfile @@ -0,0 +1,25 @@ +# starship has no upstream shell-integration suite, so this image installs a pinned +# release and runs the small PTY-driven shell-integration suite in tests/. +FROM ubuntu:26.04 +ARG STARSHIP_VERSION=1.26.0 +# Bump these together with STARSHIP_VERSION; they are the release's own .tar.gz.sha256 entries. +ARG STARSHIP_SHA256_X86_64=b7c232b0e8249d8e55a40beb79c5c43a7d370f3f9408bd215deb0170daeaadf3 +ARG STARSHIP_SHA256_AARCH64=dc30189378d2f2e287384e8a692d3f95ad1df64cf0e8c36aa9201516028aed6b +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl python3-pexpect python3-pytest \ + && rm -rf /var/lib/apt/lists/* +RUN case "$(uname -m)" in \ + x86_64) target=x86_64-unknown-linux-musl; sha=$STARSHIP_SHA256_X86_64 ;; \ + aarch64) target=aarch64-unknown-linux-musl; sha=$STARSHIP_SHA256_AARCH64 ;; \ + *) exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/starship.tar.gz \ + "https://github.com/starship/starship/releases/download/v${STARSHIP_VERSION}/starship-${target}.tar.gz" \ + && echo "$sha /tmp/starship.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/starship.tar.gz -C /usr/local/bin starship \ + && rm /tmp/starship.tar.gz +COPY starship/entrypoint.sh starship/skip-list.txt /e2e/ +COPY starship/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/starship/entrypoint.sh b/userland/brush/source/e2e/starship/entrypoint.sh new file mode 100755 index 0000000..0b6fa51 --- /dev/null +++ b/userland/brush/source/e2e/starship/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +export STARSHIP_TEST_SHELL="${SHELL_UNDER_TEST:-bash}" +e2e_run_pytest starship "$@" diff --git a/userland/brush/source/e2e/starship/skip-list.txt b/userland/brush/source/e2e/starship/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/starship/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/starship/tests/test_starship.py b/userland/brush/source/e2e/starship/tests/test_starship.py new file mode 100644 index 0000000..fbc1045 --- /dev/null +++ b/userland/brush/source/e2e/starship/tests/test_starship.py @@ -0,0 +1,82 @@ +import os + +import pytest +from pty_shell import run, spawn +from rcfile import write_rc + + +# starship is configured (below) to render exactly this, so the prompt doubles as the assertion: +# `$status` is empty after a success and carries the exit status after a failure. +PROMPT = "STARSHIP> " +FAILED_PROMPT = "STARSHIP1> " + +CONFIG = """\ +add_newline = false +format = 'STARSHIP$status> ' +[status] +disabled = false +format = '$status' +map_symbol = false +""" + + +@pytest.fixture +def shell(tmp_path): + home = tmp_path / "home" + home.mkdir() + marker = tmp_path / "prompt-command" + marker.touch() + config = tmp_path / "starship.toml" + config.write_text(CONFIG) + # starship overwrites this prompt at its first render, so the tests wait on PROMPT instead. + rc = write_rc( + tmp_path / "rc", + "loading>", + 'PROMPT_COMMAND=\'echo ran >> "$STARSHIP_TEST_MARKER"\'', + 'eval "$(starship init bash)"', + ) + env = os.environ | { + "HOME": str(home), + # starship disables itself under TERM=dumb, so this suite needs a real terminal type; + # pty_shell answers the cursor-position queries the prompt then makes. + "TERM": "xterm-256color", + "STARSHIP_CONFIG": str(config), + "STARSHIP_TEST_MARKER": str(marker), + } + # spawn waits for the first rendered prompt, so every test starts from a shell that has + # already run its init -- and a starship that never renders fails here rather than leaving a + # later assertion to pass against the prompt that was on screen all along. + child = spawn( + os.environ["STARSHIP_TEST_SHELL"], + ["--noprofile", "--rcfile", str(rc)], + env=env, + prompt=PROMPT, + ) + yield child, marker + child.close(force=True) + + +def test_init_renders_the_prompt(shell): + child, _ = shell + assert "starship" in run(child, PROMPT, "type -t starship_precmd") + + +def test_failed_command_status_reaches_the_prompt(shell): + child, _ = shell + # Waiting for a prompt that differs from the one already on screen is what makes this a real + # assertion: it cannot be satisfied by the state the test started in. + run(child, FAILED_PROMPT, "false") + + +def test_status_clears_after_a_successful_command(shell): + child, _ = shell + run(child, FAILED_PROMPT, "false") + run(child, PROMPT, "true") + + +def test_existing_prompt_command_still_runs(shell): + child, marker = shell + before = marker.read_text().count("ran") + # run() returns only once the next prompt has rendered, so the hook has had its chance. + run(child, PROMPT, "true") + assert marker.read_text().count("ran") > before diff --git a/userland/brush/source/e2e/starship/xfail-list.txt b/userland/brush/source/e2e/starship/xfail-list.txt new file mode 100644 index 0000000..3d5ed1f --- /dev/null +++ b/userland/brush/source/e2e/starship/xfail-list.txt @@ -0,0 +1,3 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. diff --git a/userland/brush/source/e2e/zoxide/Dockerfile b/userland/brush/source/e2e/zoxide/Dockerfile new file mode 100644 index 0000000..5986c0d --- /dev/null +++ b/userland/brush/source/e2e/zoxide/Dockerfile @@ -0,0 +1,26 @@ +# zoxide's upstream tests only validate generated scripts, so this image installs a +# pinned release and runs the small PTY-driven shell-integration suite in tests/. +FROM ubuntu:26.04 +ARG ZOXIDE_VERSION=0.10.0 +# Bump these together with ZOXIDE_VERSION; upstream publishes no checksum file, so they +# are the sha256 of the release tarballs themselves. +ARG ZOXIDE_SHA256_X86_64=2d93385b99f3e82cf2701609a1bffcad863fbeb75aa3fe7eb6be4d29be68b1ae +ARG ZOXIDE_SHA256_AARCH64=f1f16c5d6298d63dee467eedea1cdcd8490e43e493bea43acd416dc9033ef641 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl python3-pexpect python3-pytest \ + && rm -rf /var/lib/apt/lists/* +RUN case "$(uname -m)" in \ + x86_64) target=x86_64-unknown-linux-musl; sha=$ZOXIDE_SHA256_X86_64 ;; \ + aarch64) target=aarch64-unknown-linux-musl; sha=$ZOXIDE_SHA256_AARCH64 ;; \ + *) exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/zoxide.tar.gz \ + "https://github.com/ajeetdsouza/zoxide/releases/download/v${ZOXIDE_VERSION}/zoxide-${ZOXIDE_VERSION}-${target}.tar.gz" \ + && echo "$sha /tmp/zoxide.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/zoxide.tar.gz -C /usr/local/bin zoxide \ + && rm /tmp/zoxide.tar.gz +COPY zoxide/entrypoint.sh zoxide/skip-list.txt /e2e/ +COPY zoxide/tests /e2e/tests +COPY lib /e2e/lib +ENV LANG=C.UTF-8 PYTHONPATH=/e2e/lib +ENTRYPOINT ["/e2e/entrypoint.sh"] diff --git a/userland/brush/source/e2e/zoxide/entrypoint.sh b/userland/brush/source/e2e/zoxide/entrypoint.sh new file mode 100755 index 0000000..5a08bf0 --- /dev/null +++ b/userland/brush/source/e2e/zoxide/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Contract: $SHELL_UNDER_TEST names the shell binary; results go to /results +# (junit XML + full log); exit status = test status. +set -uo pipefail +source /e2e/lib/run_pytest.sh +export ZOXIDE_TEST_SHELL="${SHELL_UNDER_TEST:-bash}" +e2e_run_pytest zoxide "$@" diff --git a/userland/brush/source/e2e/zoxide/skip-list.txt b/userland/brush/source/e2e/zoxide/skip-list.txt new file mode 100644 index 0000000..acbc6bb --- /dev/null +++ b/userland/brush/source/e2e/zoxide/skip-list.txt @@ -0,0 +1,3 @@ +# Tests that must not run at all, one pytest test name per line, each with a comment saying why. +# Reserve this for tests that hang or are irrelevant to the shell under test; anything that merely +# fails belongs in xfail-list.txt, so it keeps running and a fix is reported. diff --git a/userland/brush/source/e2e/zoxide/tests/test_zoxide.py b/userland/brush/source/e2e/zoxide/tests/test_zoxide.py new file mode 100644 index 0000000..947b34a --- /dev/null +++ b/userland/brush/source/e2e/zoxide/tests/test_zoxide.py @@ -0,0 +1,67 @@ +import os +import shlex + +import pytest +from pty_shell import run, spawn +from rcfile import write_rc + + +PROMPT = "zoxide-test> " + + +@pytest.fixture +def shell(tmp_path): + home = tmp_path / "home" + data = tmp_path / "data" + marker = tmp_path / "prompt-command" + home.mkdir() + data.mkdir() + marker.touch() + rc = write_rc( + tmp_path / "rc", + PROMPT.rstrip(), + 'PROMPT_COMMAND=\'echo ran >> "$ZOXIDE_TEST_MARKER"\'', + 'eval "$(zoxide init bash)"', + ) + env = os.environ | { + "HOME": str(home), + "TERM": "dumb", + "ZOXIDE_TEST_MARKER": str(marker), + "_ZO_DATA_DIR": str(data), + } + child = spawn( + os.environ["ZOXIDE_TEST_SHELL"], + ["--noprofile", "--rcfile", str(rc)], + env=env, + prompt=PROMPT, + ) + yield child, home, marker + child.close(force=True) + + +def test_init_defines_z(shell): + child, _, _ = shell + assert "function\r\n" in run(child, PROMPT, "type -t z") + + +def test_prompt_hook_preserves_status(shell): + child, _, _ = shell + run(child, PROMPT, "false") + assert "1\r\n" in run(child, PROMPT, "echo $?") + + +def test_prompt_hook_preserves_existing_command(shell): + child, _, marker = shell + before = marker.read_text().count("ran") + run(child, PROMPT, "true") + assert marker.read_text().count("ran") > before + + +def test_visited_directory_can_be_recalled(shell): + child, home, _ = shell + target = home / "projects" / "zoxide-target" + target.mkdir(parents=True) + run(child, PROMPT, f"cd {shlex.quote(str(target))}") + run(child, PROMPT, f"cd {shlex.quote(str(home))}") + run(child, PROMPT, "z zoxide-target") + assert f"{target}\r\n" in run(child, PROMPT, "pwd") diff --git a/userland/brush/source/e2e/zoxide/xfail-list.txt b/userland/brush/source/e2e/zoxide/xfail-list.txt new file mode 100644 index 0000000..3d5ed1f --- /dev/null +++ b/userland/brush/source/e2e/zoxide/xfail-list.txt @@ -0,0 +1,3 @@ +# Tests that run but are expected to fail, one test name per line. Each entry needs a comment +# saying why; the runner reports an entry that starts passing, so a fix cannot land unnoticed. +# Lines starting with # are comments. diff --git a/userland/brush/source/fuzz/.gitignore b/userland/brush/source/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/userland/brush/source/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/userland/brush/source/fuzz/Cargo.toml b/userland/brush/source/fuzz/Cargo.toml new file mode 100644 index 0000000..9069c7c --- /dev/null +++ b/userland/brush/source/fuzz/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "brush-fuzz" +description = "Fuzz tests for brush" +publish = false +version = "0.2.2" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +# Application tier; see docs/reference/msrv-policy.md. +rust-version = "1.95.0" + +[package.metadata] +cargo-fuzz = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.102" +assert_cmd = "2.2.0" +libfuzzer-sys = "0.4" +tokio = { version = "1.52.3", features = ["rt"] } + +[dependencies.brush-core] +path = "../brush-core" + +[dependencies.brush-parser] +path = "../brush-parser" +features = ["arbitrary"] + +[dependencies.brush-interactive] +path = "../brush-interactive" +features = ["highlighting"] + +[[bin]] +name = "fuzz_parse" +path = "fuzz_targets/fuzz_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_arithmetic" +path = "fuzz_targets/fuzz_arithmetic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_highlight" +path = "fuzz_targets/fuzz_highlight.rs" +test = false +doc = false +bench = false diff --git a/userland/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs b/userland/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs new file mode 100644 index 0000000..5db56a9 --- /dev/null +++ b/userland/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs @@ -0,0 +1,97 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +use std::sync::LazyLock; + +use anyhow::Result; +use brush_parser::ast; +use libfuzzer_sys::fuzz_target; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +fn eval_arithmetic(mut shell: brush_core::Shell, input: &ast::ArithmeticExpr) -> Result<()> { + const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + + // + // Turn it back into a string so we can pass it in on the command-line. + // + let input_str = input.to_string(); + + // + // Instantiate a brush shell with defaults, then try to evaluate the expression. + // + let parsed_expr = brush_parser::arithmetic::parse(input_str.as_str()).ok(); + let our_eval_result = if let Some(parsed_expr) = parsed_expr { + shell.eval_arithmetic(&parsed_expr).ok() + } else { + None + }; + + // + // Now run it under 'bash' + // + let mut oracle_cmd = std::process::Command::new("bash"); + oracle_cmd + .arg("--noprofile") + .arg("--norc") + .arg("-O") + .arg("extglob") + .arg("-t"); + + let mut oracle_cmd = assert_cmd::Command::from_std(oracle_cmd); + + oracle_cmd.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_IN_SECONDS)); + + let input = std::format!("echo \"$(( {input_str} ))\"\n"); + oracle_cmd.write_stdin(input.as_bytes()); + + let oracle_result = oracle_cmd.output()?; + let oracle_eval_result = if oracle_result.status.success() { + let oracle_output = std::str::from_utf8(&oracle_result.stdout)?; + oracle_output.trim().parse::().ok() + } else { + None + }; + + // + // Compare results. + // + if our_eval_result != oracle_eval_result { + Err(anyhow::anyhow!( + "Mismatched eval results: {oracle_eval_result:?} from oracle vs. {our_eval_result:?} from our test (expr: '{input_str}', oracle result: {oracle_result:?})" + )) + } else { + Ok(()) + } +} + +fuzz_target!(|input: ast::ArithmeticExpr| { + let s = input.to_string(); + let s = s.trim(); + + // For now, intentionally ignore known problematic cases without actually running them. + if s.contains("+ 0") + || s.is_empty() + || s.contains(|c: char| c.is_ascii_control() || !c.is_ascii()) + || s.contains("$[") + // old deprecated form of arithmetic expansion + { + return; + } + + let shell = SHELL_TEMPLATE.clone(); + eval_arithmetic(shell, &input).unwrap(); +}); diff --git a/userland/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs b/userland/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs new file mode 100644 index 0000000..c5c46c8 --- /dev/null +++ b/userland/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs @@ -0,0 +1,92 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] + +use libfuzzer_sys::fuzz_target; +use std::sync::LazyLock; + +use brush_interactive::highlighting::{Highlighted, highlight_command}; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +/// Clamps `raw` into `[0, line.len()]` and down to a UTF-8 char boundary. +const fn snap_cursor(line: &str, raw: u32) -> usize { + let upper = line.len(); + if upper == 0 { + return 0; + } + let mut pos = (raw as usize) % (upper + 1); + while pos > 0 && !line.is_char_boundary(pos) { + pos -= 1; + } + pos +} + +fn assert_invariants(highlighted: &Highlighted<'_>) { + let line = highlighted.line(); + + // 1. Every span lands on UTF-8 char boundaries and the range is valid. + for span in highlighted.spans() { + assert!( + span.range.start <= span.range.end, + "span has start > end: {span:?} (line={line:?})", + ); + assert!( + span.range.end <= line.len(), + "span end exceeds line length: {span:?} (line.len()={}, line={line:?})", + line.len(), + ); + assert!( + line.is_char_boundary(span.range.start), + "span start not on char boundary: {span:?} (line={line:?})", + ); + assert!( + line.is_char_boundary(span.range.end), + "span end not on char boundary: {span:?} (line={line:?})", + ); + } + + // 2. Spans are ordered and contiguous, covering the entire input. + let mut next_expected_start = 0usize; + for span in highlighted.spans() { + assert_eq!( + span.range.start, next_expected_start, + "spans are not contiguous: gap or overlap before {span:?} \ + (expected start={next_expected_start}, line={line:?})", + ); + next_expected_start = span.range.end; + } + assert_eq!( + next_expected_start, + line.len(), + "spans do not cover entire input (covered {next_expected_start} of {}, line={line:?})", + line.len(), + ); + + // 3. Resolving the text of each span must not panic. + for (_, _) in highlighted.iter() {} +} + +// `raw_cursor` is clamped into the line and snapped to a char boundary below. +fuzz_target!(|input: (String, u32)| { + let (line, raw_cursor) = input; + let cursor = snap_cursor(&line, raw_cursor); + + let shell = SHELL_TEMPLATE.clone(); + let highlighted = highlight_command(&shell, &line, cursor); + + assert_invariants(&highlighted); +}); diff --git a/userland/brush/source/fuzz/fuzz_targets/fuzz_parse.rs b/userland/brush/source/fuzz/fuzz_targets/fuzz_parse.rs new file mode 100644 index 0000000..b59048b --- /dev/null +++ b/userland/brush/source/fuzz/fuzz_targets/fuzz_parse.rs @@ -0,0 +1,82 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +use anyhow::Result; +use libfuzzer_sys::fuzz_target; +use std::sync::LazyLock; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +#[expect(clippy::unused_async)] +async fn parse_async(shell: brush_core::Shell, input: String) -> Result<()> { + const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + + // + // Instantiate a brush shell with defaults, then try to parse the input. + // + let our_parse_result = shell.parse_string(input.clone()); + + // + // Now run it under 'bash -n -t' as a crude way to see if it's at least valid syntax. + // + let mut oracle_cmd = std::process::Command::new("bash"); + oracle_cmd + .arg("--noprofile") + .arg("--norc") + .arg("-O") + .arg("extglob") + .arg("-n") + .arg("-t"); + + let mut oracle_cmd = assert_cmd::Command::from_std(oracle_cmd); + + oracle_cmd.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_IN_SECONDS)); + + let mut input = input; + input.push('\n'); + oracle_cmd.write_stdin(input.as_bytes()); + + let oracle_result = oracle_cmd.output()?; + + // + // Compare results. + // + if our_parse_result.is_ok() != oracle_result.status.success() { + Err(anyhow::anyhow!( + "Mismatched parse results: {oracle_result:?} vs {our_parse_result:?}" + )) + } else { + Ok(()) + } +} + +fuzz_target!(|input: String| { + // Ignore known problematic cases without actually running them. + if input.is_empty() + || input.contains(|c: char| c.is_ascii_control() || !c.is_ascii()) // non-ascii chars (or control sequences) + || input.contains('!') // history expansions + || (input.contains('[') && !input.contains(']')) // ??? + || input.contains("<<") // weirdness with here docs + || input.ends_with('\\') // unterminated trailing escape char? + || input.contains("|&") + // unimplemented bash-ism + { + return; + } + + let shell = SHELL_TEMPLATE.clone(); + TOKIO_RT.block_on(parse_async(shell, input)).unwrap(); +}); diff --git a/userland/brush/source/lychee.toml b/userland/brush/source/lychee.toml new file mode 100644 index 0000000..e1c894c --- /dev/null +++ b/userland/brush/source/lychee.toml @@ -0,0 +1,8 @@ +# Check link fragments too +include_fragments = "full" + +# Include hidden files +hidden = true + +# Include links in verbatim blocks +include_verbatim = true diff --git a/userland/brush/source/release-plz.toml b/userland/brush/source/release-plz.toml new file mode 100644 index 0000000..2e19b44 --- /dev/null +++ b/userland/brush/source/release-plz.toml @@ -0,0 +1,35 @@ +[workspace] +# disable the changelog for all packages +# ref: https://release-plz.ieni.dev/docs/extra/single-changelog +changelog_config = "cliff.toml" +changelog_update = false +git_release_enable = false +publish = true + +[[package]] +name = "brush" +version_group = "brush-shell-group" # Version-locked to brush-shell + +[[package]] +name = "brush-shell" +version_group = "brush-shell-group" +changelog_update = true +changelog_path = "./CHANGELOG.md" +changelog_include = [ + "brush-core", + "brush-interactive-shell", + "brush-parser", +] +git_release_latest = true +git_release_draft = true +git_release_enable = true +git_release_name = "brush v{{ version }}" +git_release_body = """ +{{ changelog }} +{% if remote.contributors %} +### Contributors +{% for contributor in remote.contributors %} +* @{{ contributor.username }} +{% endfor %} +{% endif %} +""" diff --git a/userland/brush/source/rust-toolchain.toml b/userland/brush/source/rust-toolchain.toml new file mode 100644 index 0000000..9967056 --- /dev/null +++ b/userland/brush/source/rust-toolchain.toml @@ -0,0 +1,30 @@ +# The Rust toolchain officially used by this project for development and CI. +# +# We pin to an exact version rather than `stable`, so that we get consistent +# results across local development and CI runs, particularly for clippy +# and other checks that change from release to release. Dependabot's +# `rust-toolchain` ecosystem bumps it as new releases become available +# (see .github/dependabot.yml). +# +# Note that this is not the MSRV; that's separately specified; see +# docs/reference/msrv-policy.md. +# +# For CI authors: install this version via `rustup toolchain install` +# (no toolchain name, which causes rustup to honor the version in this +# file), passing `--profile minimal`. Note that in this nameless form rustup +# silently ignores `--target` and `--component`; add extra targets and +# components afterwards with `rustup target add` / `rustup component add`. +# The components listed below are added on top of whichever profile is +# chosen, so the only thing the default profile would add is rust-docs: +# roughly 900 MB across some 66k files that nothing in CI reads, and slow +# to unpack on Windows runners. +# This file deliberately doesn't set `profile = "minimal"` itself, so a +# local install still gets the docs; named installs (MSRV, nightly) need +# the flag anyway. A job that deliberately needs a different toolchain installs +# it by name and then runs `rustup override set `; that override +# takes precedence over this file. Don't use dtolnay/rust-toolchain: +# it selects its toolchain with `rustup default`, which this file +# silently overrides. +[toolchain] +channel = "1.99.1" +components = ["rustfmt", "clippy"] diff --git a/userland/brush/source/rustfmt.toml b/userland/brush/source/rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/userland/brush/source/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/userland/brush/source/samples/config.toml b/userland/brush/source/samples/config.toml new file mode 100644 index 0000000..eef8621 --- /dev/null +++ b/userland/brush/source/samples/config.toml @@ -0,0 +1,22 @@ +# Sample brush configuration file +# Copy this file to ~/.config/brush/config.toml on Linux/macOS +# or %APPDATA%\brush\config.toml on Windows +# +# For schema-based validation in VS Code (with Even Better TOML extension): +#:schema https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json + +# User interface settings +[ui] +# Enable syntax highlighting in the input line. +# Default: false (true when built with the 'experimental' feature) +syntax-highlighting = true + +# Experimental features (may change or be removed in future versions) +[experimental] +# Enable zsh-style preexec/precmd hooks. +# Default: false +zsh-hooks = false + +# Enable terminal shell integration (for terminals that support it). +# Default: false +terminal-shell-integration = false diff --git a/userland/brush/source/schemas/config.schema.json b/userland/brush/source/schemas/config.schema.json new file mode 100644 index 0000000..d00cd91 --- /dev/null +++ b/userland/brush/source/schemas/config.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Config", + "description": "Root configuration structure for the brush shell.\n\nAll fields are optional to support forward compatibility and partial configuration.\nUnknown fields in the TOML file are silently ignored.", + "type": "object", + "properties": { + "experimental": { + "description": "Experimental features configuration.", + "$ref": "#/$defs/ExperimentalConfig" + }, + "ui": { + "description": "User interface configuration options.", + "$ref": "#/$defs/UiConfig" + } + }, + "$defs": { + "ExperimentalConfig": { + "description": "Experimental features configuration.\n\nThese options control unstable features that may change or be removed in future versions.", + "type": "object", + "properties": { + "terminal-shell-integration": { + "description": "Enable terminal shell integration.", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "zsh-hooks": { + "description": "Enable zsh-style preexec/precmd hooks.", + "type": [ + "boolean", + "null" + ], + "default": null + } + } + }, + "UiConfig": { + "description": "User interface configuration options.", + "type": "object", + "properties": { + "syntax-highlighting": { + "description": "Enable syntax highlighting in the input line.", + "type": [ + "boolean", + "null" + ], + "default": null + } + } + } + } +} diff --git a/userland/brush/source/scripts/compare-benchmark-results.py b/userland/brush/source/scripts/compare-benchmark-results.py new file mode 100755 index 0000000..9d47cbb --- /dev/null +++ b/userland/brush/source/scripts/compare-benchmark-results.py @@ -0,0 +1,102 @@ +#!/usr/bin/python3 +import argparse +import re +import sys +from dataclasses import dataclass +from typing import Dict + +parser = argparse.ArgumentParser() +parser.add_argument("-b", "--base-results", dest="base_results_file_path", type=str, help="Path to base results output file", required=True) +parser.add_argument("-t", "--test-results", dest="test_results_file_path", type=str, help="Path to test results output file", required=True) + +args = parser.parse_args() + +@dataclass +class Benchmark: + test_name: str + duration_in_ns: int + plus_or_minus_in_ns: int + +def parse_benchmarks_results(file_path: str) -> Dict[str, Benchmark]: + benchmarks = {} + + with open(file_path, "r") as file: + for line in file.readlines(): + match = re.match(r"test (.*) \.\.\. bench: +([\d,]+) ns/iter \(\+/- ([\d,]+)\)", line.strip()) + if match: + benchmark = Benchmark( + test_name=match.group(1), + duration_in_ns=int(match.group(2).replace(',', '')), + plus_or_minus_in_ns=int(match.group(3).replace(',', '')) + ) + + benchmarks[benchmark.test_name] = benchmark + + return benchmarks + +base_results = parse_benchmarks_results(args.base_results_file_path) +test_results = parse_benchmarks_results(args.test_results_file_path) + +# An empty report looks like "no change", so refuse to produce one. +for path, results in ((args.base_results_file_path, base_results), (args.test_results_file_path, test_results)): + if not results: + print(f"error: no benchmark results found in {path}", file=sys.stderr) + sys.exit(1) + +base_test_names = set(base_results.keys()) +test_test_names = set(test_results.keys()) + +removed_from_base = base_test_names - test_test_names +added_by_test = test_test_names - base_test_names +common = base_test_names & test_test_names + +print("# Performance Benchmark Report") + +if common: + print(f"| {'Benchmark name':38} | {'Baseline (μs)':>13} | {'Test/PR (μs)':>13} | {'Delta (μs)':>13} | {'Delta %':15} |") + print(f"| {'-' * 38} | {'-' * 13} | {'-' * 13} | {'-' * 13} | {'-' * 15} |") + for name in sorted(common): + # Retrieve base data + base_duration = base_results[name].duration_in_ns / 1000.0 + base_plus_or_minus = base_results[name].plus_or_minus_in_ns / 1000.0 + base_plus_or_minus_percentage = (100.0 * base_plus_or_minus) / base_duration + + # Retrieve test data + test_duration = test_results[name].duration_in_ns / 1000.0 + test_plus_or_minus = test_results[name].plus_or_minus_in_ns / 1000.0 + test_plus_or_minus_percentage = (100.0 * test_plus_or_minus) / test_duration + + # Compute delta + delta_duration = test_duration - base_duration + delta_percentage = (100.0 * delta_duration) / base_duration + abs_delta_percentage = abs(delta_percentage) + max_plus_or_minus_percentage = max(base_plus_or_minus_percentage, test_plus_or_minus_percentage) + + # Format + delta_str = f"{delta_duration:8.2f}" + + if abs_delta_percentage > max_plus_or_minus_percentage: + if delta_percentage < 0: + delta_prefix = "🟢 " + elif delta_percentage > 0: + delta_prefix = "🟠 +" + else: + delta_prefix = "⚪ " + + delta_percentage_str = f"{delta_prefix}{delta_percentage:.2f}%" + else: + delta_percentage_str = "⚪ Unchanged" + + print(f"| `{name:36}` | `{base_duration:8.2f} μs` | `{test_duration:8.2f} μs` | `{delta_str:>8} μs` | `{delta_percentage_str:12}` |") + +if removed_from_base: + print() + print("Benchmarks removed:") + for name in removed_from_base: + print(f" - {name}") + +if added_by_test: + print() + print("Benchmarks added:") + for name in added_by_test: + print(f" - {name}") diff --git a/userland/brush/source/scripts/enum-lib-crates.sh b/userland/brush/source/scripts/enum-lib-crates.sh new file mode 100755 index 0000000..76feefb --- /dev/null +++ b/userland/brush/source/scripts/enum-lib-crates.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +cargo metadata --no-deps --format-version 1 | jq -r ' + .workspace_members as $members + | .packages[] + | select(.id as $id | $members | index($id)) + | select(any(.targets[]?; any(.kind[]?; . == "lib"))) + | .name +' diff --git a/userland/brush/source/scripts/format-api-diff-report.py b/userland/brush/source/scripts/format-api-diff-report.py new file mode 100755 index 0000000..17d8288 --- /dev/null +++ b/userland/brush/source/scripts/format-api-diff-report.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 +import argparse +import sys + +parser = argparse.ArgumentParser(description='Format API diff report') +parser.add_argument("-p", dest="crate_name", type=str, help="Name of the crate", required=True) +parser.add_argument("diff_path", type=str, help="Path to API diff file from cargo-public-api") + +args = parser.parse_args() + +with open(args.diff_path, "r") as diff_file: + diff_lines = diff_file.readlines() + +removed_lines = [] +added_lines = [] +changed_lines = [] + +current_section = None + +for line in diff_lines: + line = line.strip() + + if "Removed items" in line: + current_section = removed_lines + elif "Added items" in line: + current_section = added_lines + elif "Changed items" in line: + current_section = changed_lines + elif "============" in line: + continue + elif line == "(none)": + continue + elif not line: + continue + elif current_section is not None: + current_section.append(line) + +if not removed_lines and not added_lines and not changed_lines: + sys.stderr.write("note: no API changes detected\n") + sys.exit(0) + +print(f"# Public API changes for crate: {args.crate_name}") + +if removed_lines: + print() + print("## Removed items") + + print("```") + for line in removed_lines: + print(line) + print("```") + +if added_lines: + print() + print("## Added items") + + print("```") + for line in added_lines: + print(line) + print("```") + +if changed_lines: + print() + print("## Changed items") + + print("```") + for line in changed_lines: + print(line) + print("```") diff --git a/userland/brush/source/scripts/install/install.sh b/userland/brush/source/scripts/install/install.sh new file mode 100755 index 0000000..653b635 --- /dev/null +++ b/userland/brush/source/scripts/install/install.sh @@ -0,0 +1,373 @@ +#!/bin/sh +# +# Installs brush from official GitHub releases of reubeno/brush, or (with --canary) from the +# canary builds that CI publishes for every push to its main branch. +# +# Usage: +# curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh +# +# To pass options, use `sh -s --`, e.g.: +# curl ... | sh -s -- --version 0.4.0 --require-attestation +# +# For the list of options, see usage() below, or pass --help. +# +# The downloaded archive is always checked against its published SHA-256 checksum. +# If the GitHub CLI (gh) is installed and authenticated, the archive's build +# provenance attestation is also verified, confirming it was built by the official +# repository's release workflow for that release's tag (or, for canary builds, for +# its main branch). +# +# This script sticks to POSIX sh, so it runs under dash, busybox ash, bash, and zsh. +# + +set -eu + +REPO="reubeno/brush" +RELEASE_WORKFLOW="${REPO}/.github/workflows/cd.yaml" +# Where the release workflow publishes canary builds, on ghcr.io. +CANARY_PACKAGE="reubeno/brush-canary" + +usage() { + cat <<'EOF' +Installs brush from its official GitHub releases, or from canary builds of its main branch. + +Usage: + curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh -s -- [options] + +Options: + --version Version to install (e.g. "0.4.0"); defaults to the latest release. + --canary Install the newest canary build of main instead of a release. Canary + builds are unreleased and may break. + --commit Install the canary build of this commit of main (a full 40-character + hash); builds of the last ~100 pushes are kept. + --dir

\n"); + }; + if let Some(summary) = &report.summary { + let list = format!("e2e/{app}/xfail-list.txt"); + for (open, heading, entries) in [ + // A report-only suite's failures are its expected state, and can run to hundreds. + ( + !report.report_only, + format!("❌ {app} · {} failed", summary.failures.len()), + &summary.failures, + ), + ( + true, + format!( + "🎉 {app} · {} passed unexpectedly: remove from {list}", + summary.unexpected_passes.len() + ), + &summary.unexpected_passes, + ), + ( + true, + format!( + "🧹 {app} · {} stale in {list}: did not run, so remove or fix the name", + summary.stale_expectations.len() + ), + &summary.stale_expectations, + ), + ( + false, + format!("⏩ {app} · {} did not run", summary.skips.len()), + &summary.skips, + ), + ] { + if !entries.is_empty() { + names(md, open, &heading, entries); + } + } + } + // The run error explains a failure only when no test failure does, as in the terminal + // report; with no results at all it is the whole story. + if report.failed && report.summary.as_ref().is_none_or(|s| !s.is_failure()) { + let error = report + .error + .as_deref() + .unwrap_or("failed with no reported test failures"); + let _ = writeln!( + md, + "\n
💥 {app} · run error\n\n```\n{}\n```\n
", + error.trim() + ); + } +} + +/// The adapters to run, rejecting a selection this runner cannot honor. +fn select_e2e_apps(e2e_dir: &Path, args: &E2eArgs) -> Result> { + let available = discover_e2e_apps(e2e_dir, true)?; + for app in args.apps.iter().chain(&args.with) { + if !available.contains(app) { + anyhow::bail!( + "unknown e2e adapter '{app}'; available adapters: {}", + available.join(", ") + ); + } + } + let apps = if args.apps.is_empty() { + let mut apps = discover_e2e_apps(e2e_dir, false)?; + apps.extend(args.with.iter().cloned()); + apps.sort(); + apps.dedup(); + apps + } else { + args.apps.clone() + }; + + if !args.adapter_args.is_empty() && apps.len() != 1 { + anyhow::bail!("adapter arguments require exactly one selected application"); + } + Ok(apps) +} + +/// The shell binary to bind-mount into each container, or `None` for the container's own bash. +fn resolve_shell(binary_args: &BinaryArgs, args: &E2eArgs) -> Result> { + if (args.baseline || args.shell.is_some()) && binary_args.brush_path.is_some() { + anyhow::bail!("--brush-path cannot be combined with --baseline or --shell"); + } + if args.baseline { + return Ok(None); + } + let Some(path) = &args.shell else { + return Ok(Some(binary_args.find_brush_binary()?)); + }; + // Resolved, since a symlink or a relative path would not survive the bind mount. + let path = path + .canonicalize() + .with_context(|| format!("shell not found at: {}", path.display()))?; + anyhow::ensure!(path.is_file(), "shell is not a file: {}", path.display()); + Ok(Some(path)) +} + +/// Retain a fresh directory for each invocation; never clear caller-provided directories. +fn create_e2e_results_root(root: &Path) -> Result { + fs::create_dir_all(root) + .with_context(|| format!("failed to create results root: {}", root.display()))?; + Ok(tempfile::Builder::new() + .prefix("run-") + .tempdir_in(root) + .context("failed to create e2e run directory")? + .keep()) +} + +fn e2e_style(color: bool, foreground: AnsiColor) -> Style { + if color { + Style::new().bold().fg_color(Some(foreground.into())) + } else { + Style::new() + } +} + +/// Prints the closing summary, then the failing test names and where to read the rest. +fn report_e2e_results(reports: &[AdapterReport], elapsed: Duration, color: bool) -> Result<()> { + eprintln!("{}", "-".repeat(12)); + let failed: Vec<&AdapterReport> = reports.iter().filter(|report| report.failed).collect(); + let style = e2e_style( + color, + if failed.is_empty() { + AnsiColor::Green + } else { + AnsiColor::Red + }, + ); + eprintln!( + " {style}Summary{style:#} [{:>8}] {} adapter(s): {} passed, {} failed", + format_duration(elapsed), + reports.len(), + reports.len() - failed.len(), + failed.len() + ); + + let noted: Vec<(&AdapterReport, Vec)> = reports + .iter() + .map(|report| (report, report.detail())) + .filter(|(_, detail)| !detail.is_empty()) + .collect(); + if !noted.is_empty() { + eprintln!(); + } + for (report, detail) in ¬ed { + for (index, line) in detail.iter().enumerate() { + let app = if index == 0 { report.app.as_str() } else { "" }; + eprintln!(" {app:<9} {line}"); + } + eprintln!( + " {:<9} -> {}", + "", + report.results.join("log.txt").display() + ); + } + if failed.is_empty() { + return Ok(()); + } + anyhow::bail!( + "{} e2e adapter(s) failed: {}", + failed.len(), + failed + .iter() + .map(|report| report.app.as_str()) + .collect::>() + .join(", ") + ) +} + +/// What one adapter did, for the run report. +struct AdapterReport { + app: String, + failed: bool, + /// Test failures are reported but excused (see [`REPORT_ONLY`]). + report_only: bool, + /// The adapter never ran to completion: its image did not build, docker would not start, or + /// it timed out. Nothing excuses that. + fatal: bool, + /// Why the run itself went wrong: the adapter could not be run (image build failed, docker + /// missing), its container exited non-zero, or its report could not be read. + error: Option, + elapsed: Duration, + summary: Option, + results: PathBuf, + /// Adapter arguments that select one test, with `{test}` for its name; for the reproduce + /// hint in the dashboard. Read from `select-args.txt`, defaulting to pytest's `-k`. + select_args: String, +} + +impl AdapterReport { + fn new( + app: String, + results: PathBuf, + elapsed: Duration, + outcome: Result>, + summary: Result, + ) -> Self { + // A build that failed, or a docker that would not start, means the adapter never ran, so + // nothing in its JUnit can excuse it. Only a container that did run gets that benefit, + // since every adapter exits non-zero when a test expected to fail does. + let (mut error, fatal) = match outcome { + Ok(exit_error) => (exit_error, false), + Err(error) => (Some(format!("{error:#}")), true), + }; + let summary = match summary { + Ok(summary) => Some(summary), + Err(report_error) => { + error = Some(match error { + // When the adapter never ran, its missing report is a consequence, not news. + Some(error) if fatal => error, + Some(error) => format!("{error}; {report_error:#}"), + None => format!("{report_error:#}"), + }); + None + } + }; + // A missing or invalid report cannot establish a passing run either, even when the + // container exits successfully. + let failed = match &summary { + Some(summary) => { + fatal || summary.is_failure() || error.is_some() && !summary.has_expectations() + } + None => true, + }; + Self { + app, + failed, + report_only: false, + fatal, + error, + elapsed, + summary, + results, + select_args: PYTEST_SELECT_ARGS.to_owned(), + } + } + + /// Applies the [`REPORT_ONLY`] policy: failing tests, and the non-zero exit they cause, no + /// longer fail the adapter. A run that left no results still does. + const fn make_report_only(&mut self) { + self.report_only = true; + self.failed = self.fatal || self.summary.is_none(); + } + + /// Whether this report-only adapter had test failures that were excused. + fn has_excused_failures(&self) -> bool { + self.report_only && self.summary.as_ref().is_some_and(JunitSummary::is_failure) + } + + /// The command that runs one of this adapter's tests on its own. + fn reproduce(&self, test: &str) -> String { + // A name kept qualified is ambiguous alone; pytest's `-k` takes the class alongside it. + let test = match test.rsplit_once("::") { + Some((class, name)) => { + let class = class.rsplit_once('.').map_or(class, |(_, class)| class); + format!("{class} and {name}") + } + None => test.to_owned(), + }; + let args = self + .select_args + .replace(TEST_PLACEHOLDER, &shell_quote(&test)); + format!("cargo xtask test e2e {} -- {args}", self.app) + } + + fn line(&self, color: bool) -> String { + let status = if self.failed { "FAIL" } else { "PASS" }; + let style = e2e_style( + color, + if self.failed { + AnsiColor::Red + } else if self.has_excused_failures() + || self + .summary + .as_ref() + .is_some_and(JunitSummary::has_expectations) + { + AnsiColor::Yellow + } else { + AnsiColor::Green + }, + ); + let counts = self + .summary + .as_ref() + .map_or_else(|| "no results".to_owned(), JunitSummary::counts); + format!( + " {style}{status}{style:#} [{:>8}] {:<9} {counts}", + format_duration(self.elapsed), + self.app + ) + } + + /// What is worth reading about this adapter: the offending test names grouped by what went + /// wrong, the tests that did not run, or the run error when there are no results at all. + /// Empty when a clean pass leaves nothing to say. + fn detail(&self) -> Vec { + let Some(summary) = &self.summary else { + return vec![ + self.error + .clone() + .unwrap_or_else(|| "failed with no results".to_owned()), + ]; + }; + let mut lines = Vec::new(); + for (names, label) in [ + (&summary.failures, "failed"), + // Both of these mean the xfail list no longer matches reality, so say what to do. + ( + &summary.unexpected_passes, + "passed unexpectedly; remove from xfail-list.txt", + ), + ( + &summary.stale_expectations, + "in xfail-list.txt but did not run; remove or fix the name", + ), + ] { + if !names.is_empty() { + lines.push(format!("{label}: {}", summarize_names(names))); + } + } + if !summary.skips.is_empty() { + lines.push(format!("did not run: {}", summarize_names(&summary.skips))); + } + if self.failed && lines.is_empty() { + lines.push( + self.error + .clone() + .unwrap_or_else(|| "failed with no reported test failures".to_owned()), + ); + } + lines + } +} + +/// A few names, with a count for the rest. +fn summarize_names(names: &[String]) -> String { + use std::fmt::Write; + let shown = names.len().min(3); + let mut text = names[..shown].join(", "); + if names.len() > shown { + let _ = write!(text, " (+{} more)", names.len() - shown); + } + text +} + +/// Seconds for short runs, minutes and seconds once a suite runs long (blesh takes ~9 minutes). +fn format_duration(elapsed: Duration) -> String { + // Round to tenths first, in integers: rounding the seconds and the remainder separately + // would print 119.97s as `1m60.0s`, and integers need no lossy cast. + let tenths = (elapsed.as_millis() + 50) / 100; + let (secs, tenth) = (tenths / 10, tenths % 10); + if secs >= 60 { + format!("{}m{:02}.{tenth}s", secs / 60, secs % 60) + } else { + format!("{secs}.{tenth}s") + } +} + +/// Totals read back from the `JUnit` an adapter's runner wrote. +/// +/// Every adapter emits `JUnit` -- bats, pytest, minitest-ci and the two hand-rolled reporters all +/// do -- so this is the one shape that summarizes all of them. +#[derive(Default)] +struct JunitSummary { + tests: usize, + /// Names of the cases the runner did not run. Most come from `skip-list.txt`, but a suite can + /// also skip a test itself, which nothing else would report. + skips: Vec, + /// Known gaps that still run, so an unexpected fix is reported. pytest writes these as + /// ``; counting them apart from plain skips keeps a suite that + /// is entirely expected-failure (blesh, today) from reading as if it passed outright. + xfailed: usize, + /// The subset of `xfailed` that `xfail-list.txt` predicted. Only these make the suite exit + /// non-zero: a runner that decides an expected failure itself still reports the run as green. + listed_failures: usize, + /// Names of the failing cases that were not expected to fail. + failures: Vec, + /// What the runner said about each of those, by name: the `message` attribute, or the body + /// when there is none. Kept apart from the names so the terminal report can stay one line. + failure_messages: BTreeMap, + /// Cases listed in `xfail-list.txt` that passed anyway: the gap they track is closed, and the + /// entry has to go. Reported as a failure so a fix cannot land unnoticed. + unexpected_passes: Vec, + /// How many cases carry each bare name. Suites that reuse names across classes + /// (bash-completion's `test_1` in every file) need the class to tell failures apart. + name_counts: BTreeMap, + /// Entries in `xfail-list.txt` matching no test that actually ran, so the list has gone + /// stale: the test was renamed, removed, or moved to `skip-list.txt`. + stale_expectations: Vec, +} + +impl JunitSummary { + /// A count breakdown listing only the buckets with entries. + fn counts(&self) -> String { + let mut parts = vec![format!("{} tests", self.tests)]; + for (n, label) in [ + (self.failures.len(), "failed"), + (self.unexpected_passes.len(), "unexpectedly passed"), + ( + self.stale_expectations.len(), + if self.stale_expectations.len() == 1 { + "stale xfail entry" + } else { + "stale xfail entries" + }, + ), + (self.xfailed, "xfailed"), + (self.skips.len(), "skipped"), + ] { + if n > 0 { + parts.push(format!("{n} {label}")); + } + } + parts.join(", ") + } + + /// Folds in one test case, consulting `expected` (the adapter's `xfail-list.txt`). + /// + /// An entry matches either the bare test name or the `classname::name` form, since what a + /// runner puts in each field differs. + fn record(&mut self, case: junit_parser::TestCase, expected: &BTreeSet) { + self.tests += 1; + *self + .name_counts + .entry(case.original_name.clone()) + .or_default() += 1; + // Qualified for now; `shorten_names` drops the class where the bare name is unambiguous. + let qualified = if case.classname.as_deref().is_some_and(|c| !c.is_empty()) { + case.name.clone() + } else { + case.original_name.clone() + }; + let expected_to_fail = + expected.contains(&case.original_name) || expected.contains(&case.name); + match case.status { + junit_parser::TestStatus::Failure(_) | junit_parser::TestStatus::Error(_) => { + if expected_to_fail { + self.xfailed += 1; + self.listed_failures += 1; + } else { + let (message, text) = match &case.status { + junit_parser::TestStatus::Failure(f) => (&f.message, &f.text), + junit_parser::TestStatus::Error(e) => (&e.message, &e.text), + _ => unreachable!(), + }; + let message = if message.trim().is_empty() { + text + } else { + message + }; + self.failure_messages + .insert(qualified.clone(), message.clone()); + self.failures.push(qualified); + } + } + junit_parser::TestStatus::Success => { + if expected_to_fail { + self.unexpected_passes.push(case.original_name); + } + } + _ if is_declared_xfail(&case.status) => self.xfailed += 1, + junit_parser::TestStatus::Skipped(_) => self.skips.push(qualified), + } + } + + /// Cases that ran and passed, including any that `xfail-list.txt` said would not. + const fn passed(&self) -> usize { + self.tests - self.failures.len() - self.xfailed - self.skips.len() + } + + /// Names each failure and skip by its bare name where that is unique in the suite, keeping + /// the `classname::name` form only where it is needed to tell cases apart. + fn shorten_names(&mut self) { + for name in self.failures.iter_mut().chain(&mut self.skips) { + let Some((_, bare)) = name.rsplit_once("::") else { + continue; + }; + if self.name_counts.get(bare) == Some(&1) { + let bare = bare.to_owned(); + if let Some(message) = self.failure_messages.remove(name) { + self.failure_messages.insert(bare.clone(), message); + } + *name = bare; + } + } + } + + /// Share of the tests that ran which passed, to one decimal place; blank when none ran. + fn pass_rate(&self) -> String { + let ran = self.tests - self.skips.len(); + if ran == 0 { + return String::new(); + } + // In integers, rounded: tenths of a percent. + let rate = (self.passed() * 1000 + ran / 2) / ran; + format!("{}.{}%", rate / 10, rate % 10) + } + + /// Whether a test this adapter's `xfail-list.txt` predicted did fail, which is why the suite + /// may have exited non-zero without that being a problem. + const fn has_expectations(&self) -> bool { + self.listed_failures > 0 + } + + /// Whether this run should be reported as a failure. + const fn is_failure(&self) -> bool { + !self.failures.is_empty() + || !self.unexpected_passes.is_empty() + || !self.stale_expectations.is_empty() + } +} + +/// A `` a runner wrote to report an expected failure of its own; pytest's is +/// ``. Unlike a plain skip, the test did run. +fn is_declared_xfail(status: &junit_parser::TestStatus) -> bool { + matches!(status, junit_parser::TestStatus::Skipped(skipped) + if skipped.skipped_type.starts_with("pytest.xfail")) +} + +/// Folds one suite and any suites nested inside it into `summary`, noting in `seen` every test +/// that actually ran. +fn record_suite( + suite: junit_parser::TestSuite, + expected: &BTreeSet, + summary: &mut JunitSummary, + seen: &mut BTreeSet, +) { + for case in suite.cases { + // A plain skip did not run, so it cannot satisfy an expectation: leaving it out of + // `seen` is what reports an `xfail-list.txt` entry that has moved to `skip-list.txt`. + if !matches!(case.status, junit_parser::TestStatus::Skipped(_)) + || is_declared_xfail(&case.status) + { + seen.insert(case.original_name.clone()); + seen.insert(case.name.clone()); + } + summary.record(case, expected); + } + for nested in suite.suites { + record_suite(nested, expected, summary, seen); + } +} + +/// Reads one of the adapter's `*-list.txt` files: one entry per line, `#` starts a comment. +fn read_test_list(path: &Path) -> Result> { + match fs::read_to_string(path) { + Ok(text) => Ok(text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(ToOwned::to_owned) + .collect()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()), + Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Stands for the test name in `select-args.txt`. +const TEST_PLACEHOLDER: &str = "{test}"; + +/// Every adapter's own tests are pytest (see `e2e/README.md`), so this is the default selection. +const PYTEST_SELECT_ARGS: &str = "-k {test}"; + +/// Reads the adapter's `select-args.txt`, if it has one: adapter arguments that select a single +/// test, with `{test}` standing for the name. +fn read_select_args(adapter_dir: &Path) -> Result { + let path = adapter_dir.join("select-args.txt"); + match fs::read_to_string(&path) { + Ok(text) => { + let template = text.trim(); + anyhow::ensure!( + template.contains(TEST_PLACEHOLDER), + "{} has no {TEST_PLACEHOLDER} placeholder", + path.display() + ); + Ok(template.to_owned()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(PYTEST_SELECT_ARGS.to_owned()) + } + Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())), + } +} + +/// Single-quotes `word` unless it is safe to paste into a shell as is. +fn shell_quote(word: &str) -> String { + if !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_-./:[]=".contains(c)) + { + word.to_owned() + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// Reads and totals every `JUnit` file the adapter left under `junit/`. +/// +/// Missing, unreadable, or empty reports are errors: they cannot establish a passing run. +fn read_junit_summary( + results: &Path, + expected: &BTreeSet, + whole_suite: bool, +) -> Result { + let junit = results.join("junit"); + let mut files = Vec::new(); + for entry in fs::read_dir(&junit) + .with_context(|| format!("failed to read JUnit directory: {}", junit.display()))? + { + let path = entry + .context("failed to read JUnit directory entry")? + .path(); + if path.extension().is_some_and(|ext| ext == "xml") { + files.push(path); + } + } + anyhow::ensure!(!files.is_empty(), "no JUnit reports in {}", junit.display()); + files.sort(); + + let mut summary = JunitSummary::default(); + let mut seen = BTreeSet::new(); + for file in &files { + let reader = std::io::BufReader::new( + fs::File::open(file).with_context(|| format!("failed to open {}", file.display()))?, + ); + let report = junit_parser::from_reader(reader) + .with_context(|| format!("failed to parse {}", file.display()))?; + let before = summary.tests; + for suite in report.suites { + record_suite(suite, expected, &mut summary, &mut seen); + } + // Adapters write one report per phase, so an empty one means that phase collapsed before + // it ran anything -- which the other phases' expected failures must not paper over. A + // subset run is exempt: there, selecting nothing for a phase is the point. + anyhow::ensure!( + !whole_suite || summary.tests > before, + "JUnit report has no test cases: {}", + file.display() + ); + } + // An entry naming nothing that ran means the test was renamed, removed, or is being skipped + // elsewhere -- either way the list no longer describes reality. Only a run of the whole suite + // can conclude that, though: when the caller selected a subset, most entries name a test that + // was simply not part of this run. + if whole_suite { + summary.stale_expectations = expected.difference(&seen).cloned().collect(); + } + anyhow::ensure!(summary.tests > 0, "JUnit reports contain no test cases"); + summary.shorten_names(); + Ok(summary) +} + +/// Adapters that run only when named: ble.sh spends minutes on compatibility failures and +/// timeouts, and bash-tests stays opt-in until it has shown itself reliable. +/// bash-completion's ~2000 tests take several minutes; CI adds it with `--with`. +const OPT_IN_ADAPTERS: &[&str] = &["blesh", "bash-tests", "bash-completion"]; + +/// Marks an adapter whose test failures are reported (with a pass rate) but do not fail the run; +/// only a run that could not produce results does. For suites tracked by pass rate rather than +/// an `xfail-list.txt`. +const REPORT_ONLY: &str = "report-only"; + +fn discover_e2e_apps(e2e_dir: &Path, include_opt_in: bool) -> Result> { + let mut apps = Vec::new(); + for entry in fs::read_dir(e2e_dir).context("failed to read e2e adapter directory")? { + let entry = entry?; + if !include_opt_in && OPT_IN_ADAPTERS.iter().any(|app| entry.file_name() == *app) { + continue; + } + if entry.path().join("Dockerfile").is_file() { + apps.push( + entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("e2e adapter name is not valid UTF-8"))?, + ); + } + } + apps.sort(); + anyhow::ensure!(!apps.is_empty(), "no e2e adapters found"); + Ok(apps) +} + +/// Everything an adapter run needs that does not vary between adapters. +struct E2eRun<'a> { + docker: &'a std::ffi::OsStr, + e2e_dir: &'a Path, + results_root: &'a Path, + shell: Option<&'a Path>, + adapter_args: &'a [OsString], + timeout: Duration, + verbose: bool, +} + +fn run_e2e_adapter(run: &E2eRun<'_>, app: &str) -> Result> { + let E2eRun { + docker, + e2e_dir, + results_root, + shell, + adapter_args, + timeout, + verbose, + } = *run; + let image = format!("brush-e2e-{app}"); + let dockerfile = e2e_dir.join(app).join("Dockerfile"); + let results = results_root.join(app); + fs::create_dir(&results) + .with_context(|| format!("failed to create results directory: {}", results.display()))?; + let results = results + .canonicalize() + .with_context(|| format!("failed to resolve results directory: {}", results.display()))?; + + let mut build = ProcessCommand::new(docker); + build.arg("build"); + // A cold build takes minutes; `-q` keeps that noise out of the report, and `--verbose` + // opts back into the progress output. + if !verbose { + build.arg("-q"); + } + build + .arg("-t") + .arg(&image) + .arg("-f") + .arg(&dockerfile) + .arg(e2e_dir); + let build = run_process(&mut build, timeout, verbose).context("container build failed")?; + anyhow::ensure!( + !build.timed_out, + "container build timed out after {}", + format_duration(timeout) + ); + anyhow::ensure!(build.ok, "container build failed{}", tail(&build.output)); + + let mut results_mount = results.as_os_str().to_owned(); + results_mount.push(":/results"); + // SAFETY: geteuid takes no arguments and has no preconditions. + let uid = unsafe { libc::geteuid() }; + // SAFETY: getegid takes no arguments and has no preconditions. + let gid = unsafe { libc::getegid() }; + let container = format!("{image}-{}", std::process::id()); + let mut test = ProcessCommand::new(docker); + test.args(["run", "--rm", "--name", &container, "--user"]) + .arg(format!("{uid}:{gid}")) + .args(["-e", "HOME=/tmp", "-v"]) + .arg(results_mount); + if let Some(path) = shell { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .context("shell filename is not valid UTF-8")?; + let target = format!("/shell/{filename}"); + let mut shell_mount = path.as_os_str().to_owned(); + shell_mount.push(format!(":{target}:ro")); + test.arg("-v") + .arg(shell_mount) + .arg("-e") + .arg(format!("SHELL_UNDER_TEST={target}")); + } + test.arg(&image).args(adapter_args); + let test = run_process(&mut test, timeout, verbose).context("container test failed")?; + if test.timed_out { + // The client is already gone; stop the container it left behind. + let _ = ProcessCommand::new(docker) + .args(["kill", &container]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + anyhow::bail!("container timed out after {}", format_duration(timeout)); + } + // A suite whose only failures are expected still exits non-zero, so this is reported rather + // than fatal: the caller weighs it against the JUnit, and shows the tail only when no test + // failure explains it. + Ok(if test.ok { + None + } else { + Some(format!("container exited non-zero{}", tail(&test.output))) + }) +} + +/// The tail of captured output, indented, for the errors that leave no `JUnit` to report from. +fn tail(output: &str) -> String { + let mut lines: Vec<&str> = output.lines().collect(); + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + let shown = lines.len().saturating_sub(12); + let mut text = String::new(); + for line in &lines[shown..] { + text.push_str("\n "); + text.push_str(line); + } + text +} + +/// Outcome of a docker invocation, with its output when it was captured rather than streamed. +struct ProcessRun { + ok: bool, + timed_out: bool, + output: String, +} + +/// Streams the command's output under `--verbose`, and captures it otherwise, giving up on it +/// after `timeout`. +/// +/// Quiet is the default because every adapter already tees its full output to +/// `/log.txt`; streaming it as well buries the run report in thousands of lines. +/// +/// Output is captured to a temporary file rather than to a pipe: a pipe would have to be drained +/// while waiting, and there is nothing to read it with while this thread watches the clock. +fn run_process( + command: &mut ProcessCommand, + timeout: Duration, + verbose: bool, +) -> Result { + let program = command.get_program().to_string_lossy().into_owned(); + let start = move || format!("failed to start {program}"); + let captured = if verbose { + eprintln!("Running: {command:?}"); + None + } else { + let file = tempfile::tempfile().context("failed to open capture file")?; + command + .stdout(file.try_clone().context("failed to open capture file")?) + .stderr(file.try_clone().context("failed to open capture file")?); + Some(file) + }; + + let mut child = command.spawn().with_context(start)?; + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait().context("failed to wait for child")? { + break Some(status); + } + if Instant::now() >= deadline { + // The child here is the docker client; stopping what it started is the caller's job. + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(200)); + }; + + let output = match captured { + None => String::new(), + Some(mut file) => { + use std::io::{Read as _, Seek as _}; + file.rewind().context("failed to read captured output")?; + // Lossy: a suite's output is whatever bytes its tests happened to print. + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .context("failed to read captured output")?; + String::from_utf8_lossy(&bytes).into_owned() + } + }; + Ok(ProcessRun { + ok: status.is_some_and(|status| status.success()), + timed_out: status.is_none(), + output, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovers_adapter_directories() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::create_dir_all(temp.path().join("fzf"))?; + fs::write(temp.path().join("fzf/Dockerfile"), "FROM scratch\n")?; + fs::create_dir_all(temp.path().join("blesh"))?; + fs::write(temp.path().join("blesh/Dockerfile"), "FROM scratch\n")?; + fs::create_dir_all(temp.path().join("bash-tests"))?; + fs::write(temp.path().join("bash-tests/Dockerfile"), "FROM scratch\n")?; + fs::create_dir_all(temp.path().join("lib"))?; + + anyhow::ensure!(discover_e2e_apps(temp.path(), false)? == ["fzf"]); + anyhow::ensure!(discover_e2e_apps(temp.path(), true)? == ["bash-tests", "blesh", "fzf"]); + Ok(()) + } + + /// A report-only adapter passes despite failing tests, but not when it never ran. + #[test] + fn a_report_only_adapter_excuses_test_failures_only() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let report = |outcome| -> Result { + let mut report = AdapterReport::new( + "example".to_owned(), + temp.path().to_path_buf(), + Duration::ZERO, + outcome, + read_junit_summary(temp.path(), &BTreeSet::new(), true), + ); + anyhow::ensure!(report.failed); + report.make_report_only(); + Ok(report.failed) + }; + + anyhow::ensure!(!report(Ok(Some("container exited non-zero".to_owned())))?); + anyhow::ensure!(report(Err(anyhow::anyhow!("container timed out")))?); + Ok(()) + } + + /// A row per adapter, blank cells for zero, and a collapsible per thing to act on: failures + /// open, plain skips closed, and a run error only when no test failure explains the failure. + #[test] + fn renders_markdown_dashboard() -> Result<()> { + let report = |app: &str, secs: u64, outcome: Result>, summary| { + AdapterReport::new( + app.to_owned(), + PathBuf::from(app), + Duration::from_secs(secs), + outcome, + summary, + ) + }; + let mut reports = [ + report( + "atuin", + 7, + Ok(None), + Ok(JunitSummary { + tests: 5, + xfailed: 1, + listed_failures: 1, + ..Default::default() + }), + ), + report( + "fzf", + 95, + Ok(Some("container exited non-zero".to_owned())), + Ok(JunitSummary { + tests: 34, + xfailed: 3, + listed_failures: 3, + failures: vec!["test_ctrl_r_delete".to_owned()], + failure_messages: BTreeMap::from([( + "test_ctrl_r_delete".to_owned(), + "Expected: \"foo\"\r\n Actual: \"\"".to_owned(), + )]), + unexpected_passes: vec!["test_alt_c".to_owned()], + skips: vec!["test_ctrl_r_abort".to_owned()], + ..Default::default() + }), + ), + report( + "nvm", + 1, + Err(anyhow::anyhow!("container build failed")), + Err(anyhow::anyhow!("no JUnit reports")), + ), + report( + "bash-completion", + 300, + Ok(Some("container exited non-zero".to_owned())), + Ok(JunitSummary { + tests: 10, + xfailed: 1, + failures: vec!["test_1".to_owned()], + ..Default::default() + }), + ), + ]; + reports[1].select_args = "-n /{test}/".to_owned(); + reports[3].make_report_only(); + let expected = "\ +## 🧪 End-to-end suites + +**❌ 2/4 adapters passed** · 49 tests · 41 ✅ (85.4%) · 2 ❌ · 5 ❎ · 1 ⏩ · ⏱ 1m43.0s + +| | adapter | % pass | ✅ pass | ❌ fail | ❎ xfail | ⏩ skip | ⏱ time | +|:--:|---------|-------:|--------:|--------:|---------:|--------:|--------:| +| 🟡 | atuin | 80.0% | 4 | | 1 | | 7.0s | +| ❌ | fzf | 87.9% | 29 | 1 | 3 | 1 | 1m35.0s | +| 💥 | nvm | — | — | — | — | — | 1.0s | +| 📊 | bash-completion | 80.0% | 8 | 1 | 1 | | 5m00.0s | + +
❌ fzf · 1 failed + +- `test_ctrl_r_delete` + ``` + Expected: \"foo\" + Actual: \"\" + ``` + Reproduce: `cargo xtask test e2e fzf -- -n /test_ctrl_r_delete/` +
+ +
🎉 fzf · 1 passed unexpectedly: remove from e2e/fzf/xfail-list.txt + +- `test_alt_c` +
+ +
⏩ fzf · 1 did not run + +- `test_ctrl_r_abort` +
+ +
💥 nvm · run error + +``` +container build failed +``` +
+ +
❌ bash-completion · 1 failed + +- `test_1` +
+"; + let actual = render_markdown(&reports, Duration::from_secs(103)); + anyhow::ensure!(actual == expected, "unexpected dashboard:\n{actual}"); + Ok(()) + } + + /// One case of each shape the adapters' runners actually emit: a self-closing pass + /// (minitest-ci), a pass with an end tag, a failure, a bats skip, and a pytest xfail -- + /// which is a `` and must not be counted as an ordinary skip. + #[test] + fn summarizes_junit_from_every_runner_shape() -> Result<()> { + let temp = tempfile::tempdir()?; + let junit = temp.path().join("junit"); + fs::create_dir_all(&junit)?; + fs::write( + junit.join("a.xml"), + r#" + + + + body + + + + + "#, + )?; + // A second file must add to the totals, the way nvm writes upstream and interactive JUnit. + fs::write( + junit.join("b.xml"), + r#""#, + )?; + + let summary = read_junit_summary(temp.path(), &BTreeSet::new(), true)?; + anyhow::ensure!(summary.tests == 6, "tests: {}", summary.tests); + // Unique names are reported bare, even with a class name. + anyhow::ensure!(summary.skips == ["left out"], "{}", summary.counts()); + anyhow::ensure!(summary.xfailed == 1, "xfailed: {}", summary.xfailed); + anyhow::ensure!(summary.failures == ["broken", "also broken"]); + anyhow::ensure!(summary.failure_messages["broken"] == "m"); + anyhow::ensure!( + summary.counts() == "6 tests, 2 failed, 1 xfailed, 1 skipped", + "{}", + summary.counts() + ); + Ok(()) + } + + /// A name repeated across classes keeps its class, and reproduces through it. + #[test] + fn repeated_failure_names_stay_qualified() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::create_dir_all(temp.path().join("junit"))?; + fs::write( + temp.path().join("junit/r.xml"), + r#" + + + "#, + )?; + + let summary = read_junit_summary(temp.path(), &BTreeSet::new(), true)?; + anyhow::ensure!(summary.failures == ["t.test_ls.TestLs::test_1"]); + anyhow::ensure!(summary.failure_messages["t.test_ls.TestLs::test_1"] == "m"); + let report = AdapterReport::new( + "example".to_owned(), + temp.path().to_path_buf(), + Duration::ZERO, + Ok(None), + Ok(summary), + ); + anyhow::ensure!( + report.reproduce("t.test_ls.TestLs::test_1") + == "cargo xtask test e2e example -- -k 'TestLs and test_1'" + ); + Ok(()) + } + + /// Neither missing results nor malformed or empty XML can establish a passing run. + #[test] + fn rejects_missing_malformed_and_empty_reports() -> Result<()> { + let temp = tempfile::tempdir()?; + anyhow::ensure!(read_junit_summary(temp.path(), &BTreeSet::new(), true).is_err()); + fs::create_dir_all(temp.path().join("junit"))?; + anyhow::ensure!(read_junit_summary(temp.path(), &BTreeSet::new(), true).is_err()); + let report = temp.path().join("junit/broken.xml"); + fs::write(&report, "")?; + let error = read_junit_summary(temp.path(), &BTreeSet::new(), true) + .err() + .context("malformed XML must fail")?; + anyhow::ensure!(error.to_string().contains("failed to parse")); + let report_error = AdapterReport::new( + "example".to_owned(), + temp.path().to_path_buf(), + Duration::ZERO, + Ok(None), + Err(error), + ); + anyhow::ensure!(report_error.failed); + anyhow::ensure!(report_error.detail()[0].contains("failed to parse")); + fs::write(&report, "")?; + anyhow::ensure!(read_junit_summary(temp.path(), &BTreeSet::new(), true).is_err()); + Ok(()) + } + + /// Writes a one-suite report with a failing and a passing case, for the expectation tests. + fn write_report(dir: &Path) -> Result<()> { + fs::create_dir_all(dir.join("junit"))?; + fs::write( + dir.join("junit/r.xml"), + r#" + + + "#, + )?; + Ok(()) + } + + /// A listed failure is expected, so the run is green and the failure is not reported. + #[test] + fn expected_failures_do_not_fail_the_run() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["known gap".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, true)?; + anyhow::ensure!(summary.xfailed == 1); + anyhow::ensure!(summary.failures.is_empty()); + anyhow::ensure!(!summary.is_failure(), "{}", summary.counts()); + Ok(()) + } + + /// The point of tracking them: a gap that closes has to be noticed, not silently absorbed. + #[test] + fn an_expected_failure_that_passes_fails_the_run() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["known gap".to_owned(), "works".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, true)?; + anyhow::ensure!(summary.unexpected_passes == ["works"]); + anyhow::ensure!(summary.is_failure()); + Ok(()) + } + + /// An entry that names nothing that ran means the list has rotted. + #[test] + fn a_stale_expectation_fails_the_run() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["renamed away".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, true)?; + anyhow::ensure!(summary.stale_expectations == ["renamed away"]); + anyhow::ensure!(summary.is_failure()); + Ok(()) + } + + /// ...but not when the caller ran a subset, where an unmatched entry just names a test that + /// was not selected. + #[test] + fn a_subset_run_does_not_report_stale_expectations() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["known gap".to_owned(), "not in this subset".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, false)?; + anyhow::ensure!(summary.stale_expectations.is_empty()); + anyhow::ensure!(!summary.is_failure(), "{}", summary.counts()); + Ok(()) + } + + /// An entry may name the bare test or the `classname::name` form a runner reports. + #[test] + fn expectations_match_either_name_form() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["c::known gap".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, true)?; + anyhow::ensure!(summary.xfailed == 1, "{}", summary.counts()); + anyhow::ensure!(summary.stale_expectations.is_empty()); + Ok(()) + } + + #[test] + fn reads_a_list_ignoring_comments_and_blanks() -> Result<()> { + let temp = tempfile::tempdir()?; + let list = temp.path().join("xfail-list.txt"); + fs::write(&list, "# why\n\n a test \n# another\nsecond\n")?; + anyhow::ensure!( + read_test_list(&list)? == BTreeSet::from(["a test".to_owned(), "second".to_owned()]) + ); + // A missing list is simply no expectations. + anyhow::ensure!(read_test_list(&temp.path().join("nope.txt"))?.is_empty()); + Ok(()) + } + + #[test] + fn run_directories_preserve_existing_files_and_previous_results() -> Result<()> { + let temp = tempfile::tempdir()?; + let adapter = temp.path().join("starship"); + fs::create_dir(&adapter)?; + fs::write(adapter.join("Dockerfile"), "FROM scratch\n")?; + let first = create_e2e_results_root(temp.path())?; + write_report(&first)?; + let second = create_e2e_results_root(temp.path())?; + + anyhow::ensure!(first != second); + anyhow::ensure!(fs::read_to_string(adapter.join("Dockerfile"))? == "FROM scratch\n"); + anyhow::ensure!(read_junit_summary(&first, &BTreeSet::new(), true)?.tests == 2); + anyhow::ensure!(fs::read_dir(second)?.next().is_none()); + Ok(()) + } + + #[test] + fn report_colors_distinguish_clean_expected_and_failed_outcomes() { + let mut report = AdapterReport { + app: "example".to_owned(), + failed: false, + report_only: false, + fatal: false, + error: None, + elapsed: Duration::from_secs(1), + summary: Some(JunitSummary::default()), + results: PathBuf::new(), + select_args: PYTEST_SELECT_ARGS.to_owned(), + }; + assert!(!report.line(false).contains('\x1b')); + assert!(report.line(true).contains("\x1b[32mPASS")); + // Yellow marks a pass that a listed expectation explains. + report.summary.as_mut().unwrap().listed_failures = 1; + assert!(report.line(true).contains("\x1b[33mPASS")); + report.failed = true; + assert!(report.line(true).contains("\x1b[31mFAIL")); + assert!(!report.line(false).contains('\x1b')); + } + + #[test] + fn formats_durations_for_short_and_long_runs() { + assert_eq!(format_duration(Duration::from_millis(1200)), "1.2s"); + assert_eq!(format_duration(Duration::from_secs_f64(542.18)), "9m02.2s"); + // Rounding must carry into the minutes rather than printing 60 seconds. + assert_eq!(format_duration(Duration::from_secs_f64(119.97)), "2m00.0s"); + assert_eq!(format_duration(Duration::from_secs_f64(59.97)), "1m00.0s"); + } + + /// Cases in a nested `` count like any other; dropping them would hide failures. + #[test] + fn nested_suites_are_counted() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::create_dir_all(temp.path().join("junit"))?; + fs::write( + temp.path().join("junit/n.xml"), + r#" + + + + + "#, + )?; + + let summary = read_junit_summary(temp.path(), &BTreeSet::new(), true)?; + anyhow::ensure!(summary.tests == 2, "{}", summary.counts()); + anyhow::ensure!(summary.failures == ["nested"]); + Ok(()) + } + + /// A test that only skips never runs, so an expectation naming it has gone stale -- otherwise + /// moving a test to `skip-list.txt` would silence its `xfail-list.txt` entry forever. + #[test] + fn an_expectation_for_a_skipped_test_is_stale() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::create_dir_all(temp.path().join("junit"))?; + fs::write( + temp.path().join("junit/s.xml"), + r#" + + + + + "#, + )?; + let expected = BTreeSet::from(["not run".to_owned(), "self declared".to_owned()]); + + let summary = read_junit_summary(temp.path(), &expected, true)?; + // The runner's own xfail did run, so only the plain skip is stale. + anyhow::ensure!( + summary.stale_expectations == ["not run"], + "{}", + summary.counts() + ); + Ok(()) + } + + /// A runner that declares its own expected failure still exits zero, so such a case cannot + /// explain a non-zero exit the way a listed expectation does. + #[test] + fn a_runner_declared_xfail_does_not_excuse_a_non_zero_exit() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::create_dir_all(temp.path().join("junit"))?; + fs::write( + temp.path().join("junit/x.xml"), + r#" + + "#, + )?; + + let summary = read_junit_summary(temp.path(), &BTreeSet::new(), true)?; + anyhow::ensure!(summary.xfailed == 1 && !summary.has_expectations()); + anyhow::ensure!( + AdapterReport::new( + "example".to_owned(), + temp.path().to_path_buf(), + Duration::ZERO, + Ok(Some("container exited non-zero".to_owned())), + Ok(summary), + ) + .failed + ); + Ok(()) + } + + /// A report file with no cases in it means that phase of the suite never ran. + #[test] + fn an_empty_report_file_fails_a_whole_suite_run() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + fs::write( + temp.path().join("junit/empty.xml"), + r#""#, + )?; + + anyhow::ensure!(read_junit_summary(temp.path(), &BTreeSet::new(), true).is_err()); + // A subset run may legitimately select nothing for one phase. + anyhow::ensure!(read_junit_summary(temp.path(), &BTreeSet::new(), false)?.tests == 2); + Ok(()) + } + + /// Expected failures explain a non-zero exit, but not a failure to run the adapter at all. + #[test] + fn only_a_container_that_ran_has_its_exit_excused() -> Result<()> { + let temp = tempfile::tempdir()?; + write_report(temp.path())?; + let expected = BTreeSet::from(["known gap".to_owned()]); + let report = |outcome| -> Result { + Ok(AdapterReport::new( + "example".to_owned(), + temp.path().to_path_buf(), + Duration::ZERO, + outcome, + read_junit_summary(temp.path(), &expected, true), + ) + .failed) + }; + + anyhow::ensure!(!report(Ok(Some("container exited non-zero".to_owned())))?); + anyhow::ensure!(report(Err(anyhow::anyhow!("container build failed")))?); + Ok(()) + } +} diff --git a/userland/brush/source/xtask/src/generate.rs b/userland/brush/source/xtask/src/generate.rs new file mode 100644 index 0000000..0c78694 --- /dev/null +++ b/userland/brush/source/xtask/src/generate.rs @@ -0,0 +1,361 @@ +//! Generation commands for documentation, completions, and schemas. +//! +//! This module provides commands for generating various artifacts: +//! - **Documentation**: Man pages and markdown help text for the command line +//! - **Completions**: Shell completion scripts for bash, zsh, fish, etc. +//! - **Schemas**: JSON schemas for configuration files +//! - **Distribution archives**: Reproducible documentation bundles with checksums +//! +//! Man pages and markdown are rendered from the fragments winnow-args' derives +//! write while `brush-shell` is checked with `WINNOW_ARGS_SPEC` set: one TOML +//! file per command-line type. +//! +//! Completion scripts are winnow-args' own: they call back into `brush` for +//! their answers, so they need only its name, which comes from those +//! fragments too. +//! +//! The config schema is produced by the `gen` example in `brush-shell`, which +//! this module shells out to. Either way xtask stays free of any dependency on +//! the shell it's used to build. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::Parser; +use winnow_args::complete::Shell as CompletionShell; +use winnow_args_man::Manual; +use winnow_args_spec::Catalog; +use xshell::{Shell, cmd}; + +use crate::common::find_package_version; + +/// The crate, as the derives name its directory, and the type of the `brush` +/// command line. +const SHELL_CRATE: &str = "brush_shell"; +const SHELL_COMMAND: &str = "CommandLineArgs"; + +/// Generate various artifacts. +#[derive(Parser)] +pub enum GenCommand { + /// Generate completion scripts. + #[clap(subcommand)] + Completion(CompletionCommand), + /// Generate documentation. + #[clap(subcommand)] + Docs(DocsCommand), + /// Generate JSON schemas. + #[clap(subcommand)] + Schema(SchemaCommand), +} + +/// Documentation generation commands. +#[derive(Parser)] +pub enum DocsCommand { + /// Generate man content. + Man(GenerateManArgs), + /// Generate help content in markdown format. + Markdown(GenerateMarkdownArgs), + /// Generate a reproducible documentation distribution archive with checksums. + Dist(GenerateDistArgs), +} + +/// Completion script generation commands. +#[derive(Parser)] +pub enum CompletionCommand { + /// Generate completion script for `bash`. + Bash, + /// Generate completion script for `elvish`. + Elvish, + /// Generate completion script for `fish`. + Fish, + /// Generate completion script for `PowerShell`. + PowerShell, + /// Generate completion script for `zsh`. + Zsh, +} + +/// Arguments for man page generation. +#[derive(Parser)] +pub struct GenerateManArgs { + /// Output directory. + #[clap(long = "output-dir", short = 'o')] + output_dir: PathBuf, +} + +/// Arguments for markdown documentation generation. +#[derive(Parser)] +pub struct GenerateMarkdownArgs { + /// Output file path. + #[clap(long = "out", short = 'o')] + output_path: PathBuf, +} + +/// Arguments for documentation distribution generation. +#[derive(Parser)] +pub struct GenerateDistArgs { + /// Output file path for the distribution archive (defaults to brush-docs.tar.gz). + #[clap(long = "out", short = 'o', default_value = "brush-docs.tar.gz")] + output_path: PathBuf, + + /// Generate SHA-256 checksum file alongside the distribution archive. + #[clap(long, default_value_t = true)] + sha256: bool, + + /// Generate SHA-512 checksum file alongside the distribution archive. + #[clap(long, default_value_t = true)] + sha512: bool, +} + +/// Schema generation commands. +#[derive(Parser)] +pub enum SchemaCommand { + /// Generate JSON schema for the configuration file. + Config(GenerateSchemaArgs), +} + +/// Arguments for schema generation. +#[derive(Parser)] +pub struct GenerateSchemaArgs { + /// Output file path. + #[clap(long = "out", short = 'o')] + output_path: PathBuf, +} + +/// Run a generation command. +pub fn run(cmd: &GenCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + match cmd { + GenCommand::Docs(docs_cmd) => match docs_cmd { + DocsCommand::Man(args) => gen_man(&sh, args, verbose), + DocsCommand::Markdown(args) => gen_markdown_docs(&sh, args, verbose), + DocsCommand::Dist(args) => gen_docs_dist(&sh, args, verbose), + }, + GenCommand::Completion(completion_cmd) => { + let shell = match completion_cmd { + CompletionCommand::Bash => CompletionShell::Bash, + CompletionCommand::Elvish => CompletionShell::Elvish, + CompletionCommand::Fish => CompletionShell::Fish, + CompletionCommand::PowerShell => CompletionShell::PowerShell, + CompletionCommand::Zsh => CompletionShell::Zsh, + }; + gen_completion_script(&sh, shell, verbose) + } + GenCommand::Schema(schema_cmd) => match schema_cmd { + SchemaCommand::Config(args) => gen_config_schema(&sh, args, verbose), + }, + } +} + +fn gen_man(sh: &Shell, args: &GenerateManArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!("Generating man pages to: {}", args.output_dir.display()); + } + + let spec = write_fragments(sh)?; + write_man(spec.path(), &args.output_dir) +} + +/// Write the command line's man pages from the fragments in `spec`. +fn write_man(spec: &Path, output_dir: &Path) -> Result<()> { + let command = shell_command(spec)?; + write_pages( + output_dir, + &manual()?.render_pages(&command, &command.name)?, + ) +} + +/// The header and footer of a man page, which name brush's version. The +/// command line declares its own `--version`, so its fragment has none. +fn manual() -> Result { + let version = find_package_version("brush-shell")?; + Ok(Manual { + source: format!("brush {version}"), + // What man shows for section 1 when a page names no manual. + manual: "General Commands Manual".into(), + ..Manual::default() + }) +} + +fn gen_markdown_docs(sh: &Shell, args: &GenerateMarkdownArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!( + "Generating markdown docs to: {}", + args.output_path.display() + ); + } + + let spec = write_fragments(sh)?; + write_markdown(spec.path(), &args.output_path) +} + +/// Write the command line's markdown page from the fragments in `spec`. +fn write_markdown(spec: &Path, output_path: &Path) -> Result<()> { + let command = shell_command(spec)?; + // `brush` has no subcommands, so its one page is the whole of it. + let (_, page) = winnow_args_markdown::render_pages(&command, &command.name)? + .into_iter() + .next() + .context("the command line has no page")?; + std::fs::write(output_path, page) + .with_context(|| format!("Failed to write {}", output_path.display())) +} + +/// The command line, stitched from the fragments in `spec`. +fn shell_command(spec: &Path) -> Result { + Ok(Catalog::load(&spec.join(SHELL_CRATE))?.stitch(SHELL_COMMAND)?) +} + +/// Check `brush-shell` with `WINNOW_ARGS_SPEC` set, and return the directory +/// the derives wrote to: one subdirectory per crate. +/// +/// Cargo compiles a crate that derives again when that variable changes. A +/// new directory each time has every such crate write its fragments. +fn write_fragments(sh: &Shell) -> Result { + let spec = tempfile::tempdir().context("Failed to create temporary directory")?; + cmd!(sh, "cargo check --package brush-shell") + .env("WINNOW_ARGS_SPEC", spec.path()) + .run() + .context("Failed to check brush-shell for its command-line fragments")?; + Ok(spec) +} + +/// Write each `(file name, contents)` pair into `dir`. +fn write_pages(dir: &Path, pages: &[(String, String)]) -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?; + for (name, contents) in pages { + let path = dir.join(name); + std::fs::write(&path, contents) + .with_context(|| format!("Failed to write {}", path.display()))?; + } + Ok(()) +} + +/// Generate a shell completion script to stdout. +/// +/// The completion script is written directly to stdout so it can be piped +/// to a file or sourced directly by the shell. +fn gen_completion_script(sh: &Shell, shell: CompletionShell, verbose: bool) -> Result<()> { + if verbose { + eprintln!("Generating {} completion script...", shell.as_str()); + } + let spec = write_fragments(sh)?; + let command = shell_command(spec.path())?; + print!("{}", winnow_args::complete::script(&command.name, shell)); + Ok(()) +} + +fn gen_config_schema(sh: &Shell, args: &GenerateSchemaArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!( + "Generating config schema to: {}", + args.output_path.display() + ); + } + + // Built with brush-shell's default features plus `schema`, so the schema + // describes the config as that feature set has it. + let output_path = &args.output_path; + cmd!( + sh, + "cargo run --package brush-shell --features schema --example gen -- {output_path}" + ) + .run() + .context("Failed to run the gen example in brush-shell")?; + Ok(()) +} + +fn gen_docs_dist(sh: &Shell, args: &GenerateDistArgs, verbose: bool) -> Result<()> { + // Create a temporary directory for staging the documentation + let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + let staging_dir = temp_dir.path(); + let md_dir = staging_dir.join("md"); + let man_dir = staging_dir.join("man"); + + std::fs::create_dir_all(&md_dir)?; + std::fs::create_dir_all(&man_dir)?; + + if verbose { + eprintln!("Staging documentation in: {}", staging_dir.display()); + } + + // One check writes the fragments the markdown and man pages are rendered from. + let spec = write_fragments(sh)?; + write_markdown(spec.path(), &md_dir.join("brush.md"))?; + write_man(spec.path(), &man_dir)?; + + // Get absolute path for output + let output_path = if args.output_path.is_absolute() { + args.output_path.clone() + } else { + std::env::current_dir()?.join(&args.output_path) + }; + + if verbose { + eprintln!( + "Creating reproducible distribution archive: {}", + output_path.display() + ); + } + + // Create reproducible distribution archive using tar with options for reproducibility: + // - --sort=name: Sort files by name for consistent ordering + // - --mtime: Set modification time to epoch for reproducibility + // - --owner=0 --group=0: Remove user/group ownership info + // - --numeric-owner: Use numeric IDs + // - --pax-option: Remove atime/ctime from PAX headers + let output_path_str = output_path.display().to_string(); + + // Change to staging directory and create archive + let dir_guard = sh.push_dir(staging_dir); + + cmd!( + sh, + "tar --sort=name --mtime=1970-01-01T00:00:00Z --owner=0 --group=0 --numeric-owner --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime -czf {output_path_str} ." + ) + .run() + .context("Failed to create distribution archive")?; + + eprintln!("Created: {}", output_path.display()); + + // Generate checksums + drop(dir_guard); + + if args.sha256 { + let checksum_path = format!("{}.sha256", output_path.display()); + let checksum = cmd!(sh, "sha256sum {output_path_str}") + .read() + .context("Failed to generate SHA-256 checksum")?; + std::fs::write(&checksum_path, format!("{checksum}\n"))?; + if verbose { + eprintln!("Created: {checksum_path}"); + } + } + + if args.sha512 { + let checksum_path = format!("{}.sha512", output_path.display()); + let checksum = cmd!(sh, "sha512sum {output_path_str}") + .read() + .context("Failed to generate SHA-512 checksum")?; + std::fs::write(&checksum_path, format!("{checksum}\n"))?; + if verbose { + eprintln!("Created: {checksum_path}"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn man_pages_name_the_version_and_the_manual() { + let manual = manual().unwrap(); + assert_eq!( + manual.source, + format!("brush {}", find_package_version("brush-shell").unwrap()) + ); + assert_eq!(manual.manual, "General Commands Manual"); + } +} diff --git a/userland/brush/source/xtask/src/main.rs b/userland/brush/source/xtask/src/main.rs new file mode 100644 index 0000000..2faf876 --- /dev/null +++ b/userland/brush/source/xtask/src/main.rs @@ -0,0 +1,62 @@ +//! xtask-style command-line tool for building this project. + +mod analyze; +mod check; +mod ci; +mod common; +#[cfg(unix)] +mod e2e; +mod generate; +mod test; + +use anyhow::Result; +use clap::Parser; + +/// Global options shared across all commands. +#[derive(Parser, Debug, Clone, Copy)] +pub struct GlobalArgs { + /// Enable verbose output. + #[clap(long, short = 'v', global = true)] + pub verbose: bool, +} + +#[derive(Parser)] +#[clap(name = "xtask", about = "Build automation tasks for brush")] +struct CommandLineArgs { + #[clap(flatten)] + global: GlobalArgs, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Parser)] +enum Command { + /// Run analysis tasks (benchmarks, public API diffing). + #[clap(subcommand)] + Analyze(analyze::AnalyzeCommand), + /// Run code quality checks. + #[clap(subcommand)] + Check(check::CheckCommand), + /// Run CI workflows. + #[clap(subcommand)] + Ci(ci::CiCommand), + /// Generate documentation, completions, and schemas. + #[clap(subcommand)] + Gen(generate::GenCommand), + /// Run tests. + Test(Box), +} + +fn main() -> Result<()> { + let args = CommandLineArgs::parse(); + let verbose = args.global.verbose; + + match &args.command { + Command::Analyze(cmd) => analyze::run(cmd, verbose), + Command::Gen(cmd) => generate::run(cmd, verbose), + Command::Check(cmd) => check::run(cmd, verbose), + Command::Test(cmd) => test::run(cmd, verbose), + Command::Ci(cmd) => ci::run(cmd, verbose), + } +} diff --git a/userland/brush/source/xtask/src/test.rs b/userland/brush/source/xtask/src/test.rs new file mode 100644 index 0000000..5062351 --- /dev/null +++ b/userland/brush/source/xtask/src/test.rs @@ -0,0 +1,491 @@ +//! Test commands for running various test suites. +//! +//! This module provides commands for running different types of tests: +//! +//! - **Unit tests**: Fast tests that don't execute the brush binary (excludes integration test +//! binaries like brush-compat-tests, brush-interactive-tests, brush-completion-tests) +//! - **Integration tests**: All workspace tests including unit tests and integration tests that +//! execute the brush binary +//! - **End-to-end tests**: Real applications' own suites (including bash-completion's), run in +//! containers; see `e2e/README.md` +//! +//! Both unit and integration tests support optional coverage collection via +//! `cargo-llvm-cov`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::{Args, Parser, Subcommand}; +use xshell::{Shell, cmd}; + +use crate::common::{BuildProfile, find_target_dir}; + +/// Integration test binaries that are excluded from unit tests. +/// These tests execute the brush binary and are slower. +const INTEGRATION_TEST_BINARIES: &[&str] = &[ + "brush-compat-tests", + "brush-interactive-tests", + "brush-completion-tests", +]; + +#[cfg(windows)] +const TEST_BINARIES_DISABLED_ON_WINDOWS: &[&str] = &["brush-compat-tests"]; + +/// Shared arguments for test commands that need a brush binary. +#[derive(Args, Debug, Clone)] +pub struct BinaryArgs { + /// Path to the brush binary to test. If not specified, uses the binary + /// from the workspace's target directory based on --profile/--debug/--release. + #[clap(long, global = true)] + pub brush_path: Option, + + /// Build profile to use when auto-detecting the brush binary. + #[clap(long, short = 'p', value_enum, default_value_t = BuildProfile::Debug, global = true)] + pub profile: BuildProfile, + + /// Use debug build profile (shorthand for --profile=debug). + #[clap(long, conflicts_with_all = ["profile", "release"], global = true)] + pub debug: bool, + + /// Use release build profile (shorthand for --profile=release). + #[clap(long, conflicts_with_all = ["profile", "debug"], global = true)] + pub release: bool, +} + +impl BinaryArgs { + /// Resolve the effective build profile, considering --debug/--release shorthands. + #[must_use] + pub const fn effective_profile(&self) -> BuildProfile { + if self.debug { + BuildProfile::Debug + } else if self.release { + BuildProfile::Release + } else { + self.profile + } + } + + /// Find the brush binary using these arguments. + #[cfg(unix)] + pub fn find_brush_binary(&self) -> Result { + crate::common::find_brush_binary(self.brush_path.as_ref(), self.effective_profile()) + } +} + +/// Run tests. +#[derive(Parser)] +pub struct TestCommand { + /// Shared binary arguments. + #[clap(flatten)] + pub binary_args: BinaryArgs, + + /// Test subcommand. + #[clap(subcommand)] + pub subcommand: TestSubcommand, +} + +/// Test subcommands. +#[derive(Subcommand, Clone)] +pub enum TestSubcommand { + /// Run unit tests (fast tests that don't execute the brush binary). + /// + /// Excludes integration test binaries: brush-compat-tests, brush-interactive-tests, + /// brush-completion-tests. + Unit(UnitTestArgs), + + /// Run all workspace tests (unit + integration tests). + /// + /// This includes all tests: unit tests plus integration tests that execute + /// the brush binary (compat tests, interactive tests, completion tests). + Integration(IntegrationTestArgs), + + /// Run containerized end-to-end tests against real applications. + #[cfg(unix)] + E2e(crate::e2e::E2eArgs), +} + +/// Arguments for unit tests. +#[derive(Args, Clone, Default)] +pub struct UnitTestArgs { + /// Coverage options. + #[clap(flatten)] + pub coverage: CoverageArgs, +} + +/// Arguments for integration tests. +#[derive(Args, Clone, Default)] +pub struct IntegrationTestArgs { + /// Coverage options. + #[clap(flatten)] + pub coverage: CoverageArgs, + + /// Copy the nextest `JUnit` XML results to this path after the test run. + /// The copy is performed even if tests fail, so CI can always upload results. + #[clap(long)] + pub results_output: Option, + + /// Build and test against a wasm32-wasip3 target under a WASI runtime + /// (wasmtime by default). Builds brush for wasm32-wasip3 with its default + /// features, then runs a subset of integration tests (excluding compat, + /// interactive, and completion suites) under the WASI launcher. Needs a + /// toolchain with the wasm32-wasip3 target (Rust 1.100+, or nightly) and + /// a runtime with WASI 0.3 support (e.g., wasmtime 46+). + #[clap(long)] + pub wasi: bool, + + /// Launcher command for the WASI runtime (only used with --wasi). + /// The first token is resolved against `PATH`; subsequent tokens are + /// passed as leading arguments before the brush binary path. + /// Defaults to `wasmtime run -S inherit-env --dir=.::/ --allow-precompiled --`. + #[clap(long)] + pub wasi_launcher: Option, + + /// Skip the WASI wasm build step and assume brush.wasm is already + /// present at the expected path (only used with --wasi). + #[clap(long)] + pub skip_wasi_build: bool, +} + +/// Arguments for coverage collection. +#[derive(Args, Clone, Default)] +pub struct CoverageArgs { + /// Collect code coverage during test run. + #[clap(long)] + pub coverage: bool, + + /// Output file for coverage report (Cobertura XML format). + /// Only used when --coverage is specified. + #[clap(long, short = 'o', default_value = "codecov.xml")] + pub coverage_output: PathBuf, +} + +/// Run a test command. +pub fn run(cmd: &TestCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match &cmd.subcommand { + TestSubcommand::Unit(args) => run_unit_tests(&sh, &cmd.binary_args, args, verbose), + TestSubcommand::Integration(args) => { + run_integration_tests(&sh, &cmd.binary_args, args, verbose) + } + #[cfg(unix)] + TestSubcommand::E2e(args) => crate::e2e::run(&cmd.binary_args, args, verbose), + } +} + +/// Run unit tests (excludes integration test binaries). +/// +/// Unit tests are fast tests that don't execute the brush binary. +pub fn run_unit_tests( + sh: &Shell, + binary_args: &BinaryArgs, + args: &UnitTestArgs, + verbose: bool, +) -> Result<()> { + let profile = binary_args.effective_profile(); + eprintln!("Running unit tests ({profile:?} profile)..."); + + // Build the filter expression to exclude integration test binaries + let exclusions: Vec = INTEGRATION_TEST_BINARIES + .iter() + .map(|name| format!("not binary({name})")) + .collect(); + let filter_expr = exclusions.join(" and "); + + if args.coverage.coverage { + run_tests_with_coverage( + sh, + profile, + Some(&filter_expr), + &args.coverage.coverage_output, + verbose, + ) + } else { + run_nextest(sh, profile, Some(&filter_expr), verbose)?; + eprintln!("Unit tests passed."); + Ok(()) + } +} + +/// Run all workspace tests (unit + integration). +/// +/// This runs all tests in the workspace, including integration tests +/// that execute the brush binary. With `--wasi`, builds brush for +/// wasm32-wasip3 and runs the integration tests under a WASI runtime. +pub fn run_integration_tests( + sh: &Shell, + binary_args: &BinaryArgs, + args: &IntegrationTestArgs, + verbose: bool, +) -> Result<()> { + let profile = binary_args.effective_profile(); + + if args.wasi { + if args.coverage.coverage { + eprintln!("Warning: --coverage is not supported with --wasi and will be ignored."); + } + return run_integration_tests_wasi(sh, profile, args, verbose); + } + + eprintln!("Running integration tests ({profile:?} profile)..."); + + #[cfg(windows)] + let exclusions: Vec = TEST_BINARIES_DISABLED_ON_WINDOWS + .iter() + .map(|name| format!("not binary({name})")) + .collect(); + #[cfg(windows)] + let filter_expr = exclusions.join(" and "); + #[cfg(windows)] + let filter = Some(filter_expr.as_str()); + + #[cfg(not(windows))] + let filter = None; + + let test_result = if args.coverage.coverage { + run_tests_with_coverage(sh, profile, filter, &args.coverage.coverage_output, verbose) + } else { + run_nextest(sh, profile, filter, verbose).map(|()| { + eprintln!("Integration tests passed."); + }) + }; + + // Copy nextest results if requested (even on test failure, so CI can upload them). + if let Some(ref output) = args.results_output { + copy_nextest_results(output)?; + } + + test_result +} + +/// Run the brush integration tests against a wasm32-wasip3 build of brush, +/// executed under a WASI runtime. Builds the wasm module first unless +/// `--skip-wasi-build` is given, then runs the integration tests via nextest +/// with the appropriate environment variables populated for the test harness. +fn run_integration_tests_wasi( + sh: &Shell, + profile: BuildProfile, + args: &IntegrationTestArgs, + verbose: bool, +) -> Result<()> { + let is_release = profile == BuildProfile::Release; + + // Build the wasm module unless the caller opts out. + if !args.skip_wasi_build { + eprintln!("Building brush for wasm32-wasip3..."); + let mut build_args = vec![ + "build", + "--target", + "wasm32-wasip3", + "-p", + "brush-shell", + "--bin", + "brush", + ]; + if is_release { + build_args.push("--release"); + } + if verbose { + eprintln!("Running: cargo {}", build_args.join(" ")); + } + cmd!(sh, "cargo {build_args...}") + .run() + .context("failed to build brush for wasm32-wasip3")?; + } + + // Locate the wasm module that was (or should have been) produced. + let profile_dir = if is_release { "release" } else { "debug" }; + let wasm_path = find_target_dir()? + .join("wasm32-wasip3") + .join(profile_dir) + .join("brush.wasm"); + let wasm_path = wasm_path.canonicalize().with_context(|| { + format!( + "brush.wasm not found at {} — did the build step succeed?", + wasm_path.display() + ) + })?; + + // The `--dir=.::/` flag maps the host root filesystem into the WASI + // sandbox. This is intentionally permissive for testing — tests create + // temp dirs and need access to fixtures across the filesystem. This is + // NOT a recommended default for production use of brush under WASI. + // Pre-compile the wasm module to avoid JIT compilation overhead during + // parallel test execution. Without this, multiple concurrent wasmtime + // processes each try to JIT-compile brush.wasm simultaneously, which + // can exceed test timeouts on CI. + eprintln!("Pre-compiling brush.wasm..."); + let cwasm_path = wasm_path.with_extension("cwasm"); + { + let wasm_arg = wasm_path.display().to_string(); + let cwasm_arg = cwasm_path.display().to_string(); + cmd!(sh, "wasmtime compile {wasm_arg} -o {cwasm_arg}") + .run() + .context("failed to pre-compile brush.wasm with wasmtime")?; + } + + // The default launcher includes --allow-precompiled so wasmtime accepts + // the AOT-compiled .cwasm module without re-compilation, and + // `-S inherit-env` so the environment the harness sets for each test + // case reaches brush. + let launcher = args + .wasi_launcher + .as_deref() + .unwrap_or("wasmtime run -S inherit-env --dir=.::/ --allow-precompiled --"); + + eprintln!("Running brush integration tests under WASI..."); + eprintln!(" wasm: {}", cwasm_path.display()); + eprintln!(" launcher: {launcher}"); + + let brush_path_str = cwasm_path.display().to_string(); + let _brush_path = sh.push_env("BRUSH_PATH", &brush_path_str); + let _brush_launcher = sh.push_env("BRUSH_LAUNCHER", launcher); + let _brush_platform_tags = sh.push_env("BRUSH_PLATFORM_TAGS", "wasi wasm"); + + // Only run the brush integration tests; compat tests require a native binary. + let filter = "binary(brush-integration-tests)"; + let test_result = run_nextest(sh, profile, Some(filter), verbose); + + // Copy nextest results if requested (even on test failure, so CI can upload them). + if let Some(ref output) = args.results_output { + copy_nextest_results(output)?; + } + + test_result +} + +/// Run cargo nextest with optional filter expression. +fn run_nextest( + sh: &Shell, + profile: BuildProfile, + filter_expr: Option<&str>, + verbose: bool, +) -> Result<()> { + let mut args = vec!["nextest", "run", "--workspace", "--no-fail-fast"]; + + if profile == BuildProfile::Release { + args.push("--release"); + } + + // Add filter expression if provided + let filter_value = filter_expr.map(str::to_string); + if let Some(ref value) = filter_value { + args.push("-E"); + args.push(value); + } + + if verbose { + eprintln!("Running: cargo {}", args.join(" ")); + } + + cmd!(sh, "cargo {args...}").run().context("Tests failed")?; + Ok(()) +} + +/// Copy the nextest `JUnit` XML results to the given output path. +fn copy_nextest_results(output: &Path) -> Result<()> { + let source = find_target_dir()?.join("nextest/default/test-results.xml"); + std::fs::copy(&source, output).with_context(|| { + format!( + "Failed to copy nextest results from {} to {}", + source.display(), + output.display() + ) + })?; + eprintln!("Nextest results copied to: {}", output.display()); + Ok(()) +} + +/// Run tests with code coverage collection using `cargo-llvm-cov`. +/// +/// The coverage workflow: +/// 1. Source environment variables from `cargo llvm-cov show-env` +/// 2. Clean previous coverage data +/// 3. Run tests (continuing even if tests fail to still generate report) +/// 4. Generate Cobertura XML report for CI integration +/// +/// Requires `cargo-llvm-cov` to be installed: `cargo install cargo-llvm-cov` +fn run_tests_with_coverage( + sh: &Shell, + profile: BuildProfile, + filter_expr: Option<&str>, + output: &Path, + verbose: bool, +) -> Result<()> { + let output_path = output.display().to_string(); + + eprintln!("Running tests with coverage ({profile:?} profile)..."); + eprintln!("Coverage output: {output_path}"); + + // Set up llvm-cov environment + eprintln!("Setting up llvm-cov environment..."); + if verbose { + eprintln!("Running: cargo llvm-cov show-env --export-prefix"); + } + let env_output = cmd!(sh, "cargo llvm-cov show-env --export-prefix") + .read() + .context("Failed to get llvm-cov environment. Is cargo-llvm-cov installed?")?; + + // Parse and set environment variables from llvm-cov output + env_output + .lines() + .filter_map(|line| line.strip_prefix("export ")) + .filter_map(|rest| rest.split_once('=')) + .for_each(|(k, v)| sh.set_var(k, v.trim_matches(['"', '\'']))); + + // Clean previous coverage data + if verbose { + eprintln!("Running: cargo llvm-cov clean --workspace"); + } + cmd!(sh, "cargo llvm-cov clean --workspace") + .run() + .context("Failed to clean coverage data")?; + + // Build cargo nextest args + let mut test_args = vec!["nextest", "run", "--workspace", "--no-fail-fast"]; + if profile == BuildProfile::Release { + test_args.push("--release"); + } + + // Add filter expression if provided + let filter_value = filter_expr.map(str::to_string); + if let Some(ref value) = filter_value { + test_args.push("-E"); + test_args.push(value); + } + + if verbose { + eprintln!("Running: cargo {}", test_args.join(" ")); + } + + // Run tests - let output pass through naturally, but continue on failure to generate coverage + // report + let test_result = cmd!(sh, "cargo {test_args...}").run(); + let test_failed = test_result.is_err(); + + if test_failed { + eprintln!("Tests failed, but continuing to generate coverage report..."); + } + + // Generate coverage report (always attempt this) + eprintln!("Generating coverage report..."); + if verbose { + eprintln!("Running: cargo llvm-cov report --cobertura --output-path {output_path}"); + } + cmd!( + sh, + "cargo llvm-cov report --cobertura --output-path {output_path}" + ) + .run() + .context("Failed to generate coverage report")?; + + eprintln!("Coverage report written to: {output_path}"); + + // Now propagate test failure if tests failed + if test_failed { + anyhow::bail!("Tests failed (coverage report was still generated)"); + } + + eprintln!("Tests with coverage completed successfully."); + Ok(()) +} diff --git a/userland/brush/source/zizmor.yml b/userland/brush/source/zizmor.yml new file mode 100644 index 0000000..99d82a3 --- /dev/null +++ b/userland/brush/source/zizmor.yml @@ -0,0 +1,4 @@ +rules: + unpinned-uses: + config: + policies: diff --git a/userland/bundle.nu b/userland/bundle.nu index c427e00..dc166ca 100644 --- a/userland/bundle.nu +++ b/userland/bundle.nu @@ -10,15 +10,22 @@ def main [destination?: path] { ^nu ($here | path join cwd-init build.nu) if $env.LAST_EXIT_CODE != 0 { error make {msg: 'cwd-init build failed'} } } - let metadata = (do { cd $here; ^cargo metadata --no-deps --format-version 1 } | from json) - do { cd $here; ^cargo build --workspace --release --locked } + let manifest = ($here | path join .. Cargo.toml) + let metadata = (^cargo metadata --manifest-path $manifest --no-deps --format-version 1 | from json) + let packages = ($metadata.packages | where {|p| + $p.id in $metadata.workspace_members and + ($p.manifest_path | str starts-with $here) and + not ($p.manifest_path | str starts-with ($here | path join brush source)) + }) + let arguments = ($packages | where {|p| $p.targets | any {|t| 'bin' in $t.kind } } | each {|p| ['-p' $p.name] } | flatten) + do { cd $here; ^cargo build --manifest-path $manifest --release --locked --target wasm32-wasip2 ...$arguments } if $env.LAST_EXIT_CODE != 0 { error make {msg: 'userland build failed'} } ^nu ($here | path join brush build.nu) if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} } mkdir $bin ^nu ($here | path join preview1.nu) $bin if $env.LAST_EXIT_CODE != 0 { error make {msg: 'preview1 build failed'} } - for package in ($metadata.packages | where {|p| $p.id in $metadata.workspace_members }) { + for package in $packages { let tools = ($package.manifest_path | path dirname | path join tools.txt) for binary in ($package.targets | where {|t| 'bin' in $t.kind }) { let source = ($metadata.target_directory | path join wasm32-wasip2 release $'($binary.name).wasm') @@ -29,8 +36,7 @@ def main [destination?: path] { } } } - let brush_metadata = (^cargo metadata --manifest-path ($here | path join brush source Cargo.toml) --no-deps --format-version 1 | from json) - let brush = ($brush_metadata.target_directory | path join wasm32-wasip2 release brush.wasm) + let brush = ($metadata.target_directory | path join wasm32-wasip2 release brush.wasm) for name in [sh bash] { ^cp --remove-destination $brush ($bin | path join $'($name).wasm') if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush copy failed'} }