From c82514df115da7ae559f3b232bcd22ab1690a874 Mon Sep 17 00:00:00 2001 From: takeokunn Date: Thu, 20 Aug 2026 02:23:52 +0900 Subject: [PATCH] refactor: split printf/macros out of builtin-commands and relocate highlight module Extracts printf parsing/formatting and shared builtin macros out of builtin-commands.lisp into dedicated files, moves highlight support from src/presentation/ to a top-level src/highlight.lisp + package-highlight.lisp, and threads shell-context through the process-substitution and background-job process adapters (spawn-async, spawn-pipeline-async, process-pid, process-substitution-resource-fd/path, release/wait/close) for consistency with the existing %process-fn adapter pattern. Fixes two accidental regressions found while restoring test coverage: the %process-fn/%optional-process-fn adapters had been dropped from builtin-runtime.lisp, and %seq-parse-args leaked a raw condition object as a fourth return value. Declares the process-substitution-resource accessor wrappers notinline, since SBCL inlines defstruct accessors by default and an inlined call bypasses WITH-TEMPORARY-FUNCTION's mock in tests. Updates test fixtures and call sites for the new context-threaded signatures. --- .gitignore | 1 + README.md | 2 +- data/{presentation => }/highlight-data.lisp | 7 +- docs/notes/coverage-analysis.md | 14 +- docs/src/getting-started.md | 2 +- docs/src/guide/recipes.md | 23 +- flake.nix | 25 +- nshell.asd | 120 ++++--- run-tests.lisp | 56 ++- scripts/asdf-runtime.lisp | 110 ++++++ scripts/benchmark-completion.lisp | 86 +++-- scripts/coverage.lisp | 85 +++-- scripts/weave.lisp | 35 +- src/application/builtin-commands.lisp | 334 +----------------- src/application/builtin-jobs.lisp | 25 +- src/application/builtin-macros.lisp | 8 + src/application/builtin-printf-format.lisp | 208 +++++++++++ src/application/builtin-printf-parser.lisp | 113 ++++++ src/application/builtin-printf.lisp | 25 ++ src/application/builtin-runtime.lisp | 5 +- src/application/builtin-type-helpers.lisp | 15 +- .../execute-pipeline-expansion.lisp | 15 +- .../execute-pipeline-stage-background.lisp | 33 +- .../execute-pipeline-stage-external.lisp | 272 ++++++++------ src/application/execute-pipeline-stage.lisp | 124 ++++--- src/application/manage-job.lisp | 290 ++++++++------- src/domain/expansion/fields.lisp | 18 +- src/domain/parsing/tokenizer-data.lisp | 7 +- src/{presentation => }/highlight.lisp | 21 +- .../acl/process-capabilities.lisp | 104 ++++++ src/infrastructure/acl/syscall-foreign.lisp | 5 - src/infrastructure/acl/syscall-process.lisp | 81 +++-- .../acl/syscall-redirection.lisp | 94 +++-- src/package-domain.lisp | 4 +- src/package-highlight.lisp | 12 + src/package-infrastructure.lisp | 6 +- src/package-presentation.lisp | 11 +- src/presentation/prompt-display.lisp | 17 +- src/presentation/repl-execution-context.lisp | 7 +- .../repl-output-completion-help.lisp | 7 +- src/presentation/repl-rendering.lisp | 18 +- t/integration/test-file-config.lisp | 101 +++--- t/integration/test-file-history.lisp | 105 +++--- t/integration/test-terminal-presentation.lisp | 50 ++- t/support/builtins.lisp | 44 +-- t/support/pbt-shell.lisp | 30 +- t/unit/test-builtins-core-io.lisp | 107 ++++++ t/unit/test-builtins-source.lisp | 6 +- t/unit/test-execute-pipeline.lisp | 79 +++-- t/unit/test-expansion.lisp | 60 ++-- t/unit/test-manage-job.lisp | 64 ++-- 51 files changed, 1842 insertions(+), 1249 deletions(-) rename data/{presentation => }/highlight-data.lisp (76%) create mode 100644 src/application/builtin-macros.lisp create mode 100644 src/application/builtin-printf-format.lisp create mode 100644 src/application/builtin-printf-parser.lisp create mode 100644 src/application/builtin-printf.lisp rename src/{presentation => }/highlight.lisp (80%) create mode 100644 src/infrastructure/acl/process-capabilities.lisp create mode 100644 src/package-highlight.lisp diff --git a/.gitignore b/.gitignore index c5afb05..385e7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ coverage.dat # --- Editor and OS noise --- .DS_Store +.vscode/ *~ \#*\# .\#* diff --git a/README.md b/README.md index 22ae049..e8ead87 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ outside the tested support boundary. ## Development ```sh -nix develop # SBCL with CL_SOURCE_REGISTRY already set +nix develop # SBCL + cl-weave + paredit; registry already set nix run .#test # run the test suite nix flake check # full hermetic gate on x86_64-linux CI nix fmt # format Nix sources (treefmt) diff --git a/data/presentation/highlight-data.lisp b/data/highlight-data.lisp similarity index 76% rename from data/presentation/highlight-data.lisp rename to data/highlight-data.lisp index 6649e3d..b760b58 100644 --- a/data/presentation/highlight-data.lisp +++ b/data/highlight-data.lisp @@ -1,8 +1,7 @@ -;;; Highlight data tables: builtin names, operator token types, and the -;;; fallback (theme-less) ANSI role palette used by highlight.lisp. -(in-package #:nshell.presentation) +;;; Literal syntax-highlighting data kept separate from the classifier. +(in-package #:nshell.highlight) -(defvar *builtin-commands* +(defparameter +highlight-builtin-names+ '("echo" "printf" "pwd" "ls" "cd" "exit" "fg" "bg" "jobs" "disown" "set" "export" "unset" "alias" "abbr" "function" "source" "exec" "true" "false" "contains" "test" "type" "which" "history" "help") diff --git a/docs/notes/coverage-analysis.md b/docs/notes/coverage-analysis.md index e772da2..9e946ed 100644 --- a/docs/notes/coverage-analysis.md +++ b/docs/notes/coverage-analysis.md @@ -16,14 +16,20 @@ silently removed from the report. ## Reproduce ```sh -NSHELL_COVERAGE_DIR=/tmp/nshell-coverage \ +NSHELL_COVERAGE_DIR="$PWD/coverage" \ nix develop 'path:.' --command sbcl --script scripts/coverage.lisp ``` +The command writes `cover-index.html` and `coverage-summary.json` below the +selected directory. In a Nix check, where `$out` is provided, the same files +are retained below `$out/coverage/`. Without an explicit directory, the script +uses `$TMPDIR/nshell-coverage/` when `TMPDIR` is available; the dev-shell +`coverage` alias selects the repository's `coverage/` directory explicitly. + The command is release evidence only when the selected test count is non-zero, -the test and error counts are zero, the generated report exists, and both the -minimum and target fields have been inspected. A passing minimum with -`target-reached=false` is a warning, not a 100% coverage claim. +the test and error counts are zero, the generated HTML and JSON artifacts +exist, and both the minimum and target fields have been inspected. A passing +minimum with `target-reached=false` is a warning, not a 100% coverage claim. ## What the suite verifies diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 022342f..ba03207 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -80,7 +80,7 @@ git clone https://github.com/nerima-lisp/nshell cd nshell nix build # produces ./result/bin/nshell nix flake check # full hermetic gate on x86_64-linux CI -nix develop # dev shell with SBCL + cl-weave +nix develop # dev shell with SBCL + cl-weave + paredit ``` `flake.nix` declares `x86_64-linux` and `aarch64-darwin`. The full hermetic diff --git a/docs/src/guide/recipes.md b/docs/src/guide/recipes.md index c4f973d..1403788 100644 --- a/docs/src/guide/recipes.md +++ b/docs/src/guide/recipes.md @@ -53,6 +53,21 @@ Just the focused completion suite (`nshell/weave`): sbcl --script scripts/weave.lisp ``` +All repository entry points use `scripts/asdf-runtime.lisp` for source +discovery and compile policy. The Nix development shell supplies a complete +`CL_SOURCE_REGISTRY`; outside that shell, set `NSHELL_SOURCE_TREE` to an +explicit directory containing sibling Common Lisp systems, or set +`CL_SOURCE_REGISTRY` yourself. When no registry is supplied, the repository +itself is registered and the runtime does not scan a worktree's parent +directory. The shared runtime deliberately ignores inherited ASDF output +translations and writes compiled files below `NSHELL_ASDF_OUTPUT_DIR`, or a +temporary `nshell-asdf/` directory when that variable is unset: + +```sh +NSHELL_SOURCE_TREE=/path/to/common-lisp-systems \ + sbcl --script run-tests.lisp +``` + ### The non-sandboxed integration run Some cases need a real PTY, `stty`, and external binaries, which the Nix @@ -70,11 +85,13 @@ job-control lifecycle checks. ## Generate a coverage report ```sh -nix develop -c sbcl --script scripts/coverage.lisp +NSHELL_COVERAGE_DIR="$PWD/coverage" \ + nix develop -c sbcl --script scripts/coverage.lisp ``` -The report is written to `coverage/cover-index.html`. Set `NSHELL_COVERAGE_DIR` -to redirect the output. +The report is written to `coverage/cover-index.html`, with the gate result in +`coverage/coverage-summary.json`. Set `NSHELL_COVERAGE_DIR` to redirect both +artifacts. The default Nix check stores them under its `$out/coverage/` output. ## Performance evidence diff --git a/flake.nix b/flake.nix index c2a828d..ddc18a9 100644 --- a/flake.nix +++ b/flake.nix @@ -410,10 +410,14 @@ # whole docs tree. treefmt.evalModule = treefmt-nix.lib.evalModule; - # The cl-weave CLI, which the suites' reporters are documented against. - # Interactive only: the registry the shell exports already carries every - # system, check dependencies included. - devShellPackages = ctx: [ cl-weave.packages.${ctx.system}.default ]; + # The cl-weave CLI, which the suites' reporters are documented against, + # and the Paredit CLI used for structural Common Lisp analysis. Both + # come from the same pinned cl-weave input so the development shell does + # not grow a second, independently versioned tooling stack. + devShellPackages = ctx: [ + cl-weave.packages.${ctx.system}.default + cl-weave.inputs.paredit-cli.packages.${ctx.system}.default + ]; overrideOutputs = ctx: @@ -434,14 +438,17 @@ devShells.default = ctx.generated.devShells.default.overrideAttrs (previous: { shellHook = previous.shellHook + '' export NSHELL_ROOT=$PWD - alias test='cd "$NSHELL_ROOT" && sbcl --script "$NSHELL_ROOT/run-tests.lisp"' - alias coverage='cd "$NSHELL_ROOT" && NSHELL_COVERAGE_DIR="$NSHELL_ROOT/coverage" sbcl --script "$NSHELL_ROOT/scripts/coverage.lisp"' - alias weave='cd "$NSHELL_ROOT" && sbcl --script "$NSHELL_ROOT/scripts/weave.lisp"' + export NSHELL_TIMEOUT_SECONDS=1800 + alias test='cd "$NSHELL_ROOT" && timeout --signal=TERM --kill-after=5s "$NSHELL_TIMEOUT_SECONDS" sbcl --script "$NSHELL_ROOT/run-tests.lisp"' + alias coverage='cd "$NSHELL_ROOT" && NSHELL_COVERAGE_DIR="$NSHELL_ROOT/coverage" timeout --signal=TERM --kill-after=5s "$NSHELL_TIMEOUT_SECONDS" sbcl --script "$NSHELL_ROOT/scripts/coverage.lisp"' + alias weave='cd "$NSHELL_ROOT" && timeout --signal=TERM --kill-after=5s "$NSHELL_TIMEOUT_SECONDS" sbcl --script "$NSHELL_ROOT/scripts/weave.lisp"' + alias paredit='paredit' echo "" echo "nshell development environment" echo " test - Run the full nshell suite (cl-weave, nshell/test)" echo " weave - Run the focused completion suite (nshell/weave)" echo " coverage - Run the test suite and write HTML coverage to coverage/" + echo " paredit - Run Paredit-aware Common Lisp analysis" echo " sbcl - Interactive Common Lisp (with cl-weave)" echo "" ''; @@ -480,8 +487,8 @@ # Generate the sb-cover report and enforce the configured source # expression minimum. The report also records the distance from - # the aspirational 100% target without making an unsupported - # claim that structural sb-cover forms are executable. + # the explicit 100% target without claiming that it has been + # reached when executable expressions remain uncovered. coverage = ctx.cl.mkScriptCheck { drv = ctx.package; entryPoint = "scripts/coverage.lisp"; diff --git a/nshell.asd b/nshell.asd index ab38822..165888f 100644 --- a/nshell.asd +++ b/nshell.asd @@ -1,11 +1,3 @@ -;;; This form comes FIRST, before any defsystem. ASDF binds *package* to -;;; ASDF-USER only for a file it loads itself; read any other way — a REPL -;;; `load`, an editor evaluating the buffer, flake.nix parsing :version — the -;;; file is read in whatever package happens to be current, and an unqualified -;;; `defsystem` then fails to read at all. Saying it makes the file -;;; self-contained. See PACKAGE_STANDARD.md "asd の書き方". -(in-package #:asdf-user) - ;;; Metadata keys follow the org's canonical order: ;;; :description :long-description :author :maintainer :license :version ;;; :homepage :bug-tracker :source-control :depends-on :pathname :serial @@ -45,6 +37,7 @@ (:file "package-domain") (:file "package-application") (:file "package-infrastructure") + (:file "package-highlight") (:file "package-presentation") (:module "feature-command-line" :pathname "../packages/feature/command-line/src" @@ -62,11 +55,11 @@ (:file "domain/execution/command") (:file "domain/execution/pipeline") (:file "domain/execution/job") - (:file "domain/parsing/ast") - (:file "domain/parsing/ast-redirect-split") - (:file "domain/parsing/tokenizer-data") - (:file "domain/parsing/tokenizer-readers") - (:file "domain/parsing/tokenizer-handlers") + (:file "domain/parsing/ast") + (:file "domain/parsing/ast-redirect-split") + (:file "domain/parsing/tokenizer-data") + (:file "domain/parsing/tokenizer-readers") + (:file "domain/parsing/tokenizer-handlers") (:file "domain/parsing/parse-result") (:file "domain/parsing/control-flow-data") (:file "domain/parsing/control-flow") @@ -81,18 +74,18 @@ (:file "domain/parsing/parser-here-doc") (:file "domain/parsing/parser-reduction") (:file "domain/parsing/parser") - (:file "domain/environment/env") - (:file "domain/expansion/expand") - (:file "domain/expansion/brace") - (:file "domain/expansion/parameter-data") - (:file "domain/expansion/parameter-selection") - (:file "domain/expansion/parameter-braced") - (:file "domain/expansion/parameter") - (:file "domain/expansion/arithmetic") - (:file "domain/expansion/arithmetic-operator-data" - :pathname "../data/domain/expansion/arithmetic-operator-data") - (:file "domain/expansion/fields") - (:file "domain/abbreviation/expansion") + (:file "domain/environment/env") + (:file "domain/expansion/expand") + (:file "domain/expansion/brace") + (:file "domain/expansion/parameter-data") + (:file "domain/expansion/parameter-selection") + (:file "domain/expansion/parameter-braced") + (:file "domain/expansion/parameter") + (:file "domain/expansion/arithmetic") + (:file "domain/expansion/arithmetic-operator-data" + :pathname "../data/domain/expansion/arithmetic-operator-data") + (:file "domain/expansion/fields") + (:file "domain/abbreviation/expansion") (:file "domain/completion/candidate") (:file "domain/completion/catalog-build") (:file "domain/completion/catalog-command-data" @@ -118,23 +111,27 @@ (:file "domain/prompting/prompt") (:file "application/event-dispatcher") (:file "application/shell-context") - (:file "application/execute-pipeline-expansion") - (:file "application/execute-pipeline-redirect") - (:file "application/execute-pipeline-stage-external") - (:file "application/execute-pipeline-stage-background") - (:file "application/execute-pipeline-stage") - (:file "application/execute-pipeline-control") + (:file "application/execute-pipeline-expansion") + (:file "application/execute-pipeline-redirect") + (:file "application/execute-pipeline-stage-external") + (:file "application/execute-pipeline-stage-background") + (:file "application/execute-pipeline-stage") + (:file "application/execute-pipeline-control") (:file "application/manage-job") (:file "application/pipeline-diagram") (:file "application/builtin-spec-data" :pathname "../data/application/builtin-spec-data") (:file "application/builtin-spec") (:file "application/builtin-runtime") + (:file "application/builtin-macros") (:file "application/builtin-type-helpers") (:file "application/builtin-string-support") (:file "application/builtin-string") (:file "application/builtin-source-reader") (:file "application/builtin-source") + (:file "application/builtin-printf-format") + (:file "application/builtin-printf-parser") + (:file "application/builtin-printf") (:file "application/builtin-commands") (:file "application/builtin-commands-history") (:file "application/builtin-jobs") @@ -159,6 +156,7 @@ (:file "infrastructure/acl/pty") (:file "infrastructure/acl/pty-spawn") (:file "infrastructure/acl/signal-acl") + (:file "infrastructure/acl/process-capabilities") (:file "infrastructure/persistence/file-history") (:file "infrastructure/persistence/file-config") (:file "infrastructure/terminal/raw-mode") @@ -166,6 +164,9 @@ (:file "infrastructure/terminal/input-core") (:file "infrastructure/terminal/input-decode") (:file "infrastructure/terminal/input-read") + (:file "highlight-data" + :pathname "../data/highlight-data") + (:file "highlight") (:file "presentation/input-state-static-data" :pathname "../data/presentation/input-state-data") (:file "presentation/input-state-core") @@ -184,23 +185,20 @@ (:file "presentation/input-state-kill-yank") (:file "presentation/input-state-history-search") (:file "presentation/input-state-dispatch") - (:file "presentation/input-state-vi-data") - (:file "presentation/input-state-vi") - (:file "presentation/input-state-vi-edit") + (:file "presentation/input-state-vi-data") + (:file "presentation/input-state-vi") + (:file "presentation/input-state-vi-edit") (:file "presentation/input-state-session") (:file "presentation/repl-boundaries") (:file "presentation/prompt-display") (:file "presentation/completion-ui") (:file "presentation/autosuggest") - (:file "presentation/highlight-data" - :pathname "../data/presentation/highlight-data") - (:file "presentation/highlight") - (:file "presentation/repl-state") - (:file "presentation/repl-input-state") - (:file "presentation/repl-completion-seed") - (:file "presentation/repl-environment") - (:file "presentation/repl-session-init") - (:file "presentation/repl-process") + (:file "presentation/repl-state") + (:file "presentation/repl-input-state") + (:file "presentation/repl-completion-seed") + (:file "presentation/repl-environment") + (:file "presentation/repl-session-init") + (:file "presentation/repl-process") (:file "presentation/repl-execution-context") (:file "presentation/repl-execution-command") (:file "presentation/repl-execution") @@ -212,10 +210,10 @@ (:file "presentation/repl-output-handlers") (:file "presentation/repl-output-event-handlers") (:file "presentation/repl-output") - (:file "presentation/repl-session") - (:file "presentation/repl-batch") - (:file "presentation/repl") - (:file "main")) + (:file "presentation/repl-session") + (:file "presentation/repl-batch") + (:file "presentation/repl") + (:file "main")) ;; The three build keys and the :perform below are exempt from the metadata ;; order above -- PACKAGE_STANDARD.md names cl-weave's identical trio and ;; cl-tty-kit's :perform as "はどこに書いても構いません" -- and they sit here, @@ -276,16 +274,16 @@ ((:file "package") (:file "support/assertions") (:file "helpers-runner") - (:file "support/pbt") - (:file "support/pbt-shell") - (:file "support/input-state") - (:file "support/input-state-assertions") + (:file "support/pbt") + (:file "support/pbt-shell") + (:file "support/input-state") + (:file "support/input-state-assertions") (:file "support/repl") (:file "support/builtins") (:file "support/completion") - (:file "support/prompt") - (:file "support/history") - (:file "support/matchers") + (:file "support/prompt") + (:file "support/history") + (:file "support/matchers") (:file "unit/test-package-by-feature") (:file "unit/test-domain-events") (:file "unit/test-signals") @@ -302,13 +300,13 @@ (:file "unit/test-expansion-abbreviation") (:file "unit/test-completion-rules") (:file "unit/test-completion-rule-prover-boundaries") - (:file "unit/test-completion-rule-prover") - (:file "unit/test-completion-builtins") - (:file "unit/test-completion-knowledge-base") - (:file "unit/test-completion-path-reducer") - (:file "unit/test-completion-knowledge-base-behavior") - (:file "unit/test-completion-properties") - (:file "unit/test-completion-context") + (:file "unit/test-completion-rule-prover") + (:file "unit/test-completion-builtins") + (:file "unit/test-completion-knowledge-base") + (:file "unit/test-completion-path-reducer") + (:file "unit/test-completion-knowledge-base-behavior") + (:file "unit/test-completion-properties") + (:file "unit/test-completion-context") (:file "unit/test-repl") (:file "unit/test-repl-completion-data") (:file "unit/test-repl-background") diff --git a/run-tests.lisp b/run-tests.lisp index f46d56c..9d392f6 100644 --- a/run-tests.lisp +++ b/run-tests.lisp @@ -7,38 +7,30 @@ ;;;; the hermetic Nix check and a plain local run execute exactly the same code ;;;; path rather than two hand-rolled `--eval` chains that drift apart. ;;;; -;;;; Dependency resolution mirrors scripts/weave.lisp: inside `nix develop` or -;;;; the Nix sandbox the systems are already on CL_SOURCE_REGISTRY, and for a -;;;; plain ghq checkout the parent directory tree is registered so sibling -;;;; checkouts (../cl-weave, ../cl-prolog, ...) are found automatically. An -;;;; explicit CL_SOURCE_REGISTRY still wins, because the existing configuration -;;;; is inherited rather than replaced. +;;;; Dependency resolution is shared with the auxiliary scripts through +;;;; scripts/asdf-runtime.lisp. An explicit CL_SOURCE_REGISTRY still wins; +;;;; otherwise NSHELL_SOURCE_TREE opts into one explicit sibling-system tree. (require :asdf) -(asdf:load-system :cl-host-kit) -(let* ((root (truename #P"./")) - (parent (host-kit:parent-directory-pathname root))) - (asdf:initialize-source-registry - (if (host-kit:getenv "CL_SOURCE_REGISTRY") - `(:source-registry - (:directory ,root) - :inherit-configuration) - `(:source-registry - (:directory ,root) - (:tree ,parent) - :inherit-configuration))) - ;; Warn rather than abort on compile-file warnings: the suite's own failures - ;; are the signal this script reports, and a style warning in a dependency - ;; should not masquerade as a test failure. - (setf asdf:*compile-file-warnings-behaviour* :warn - asdf:*compile-file-failure-behaviour* :warn) - (let ((passed-p - (handler-case - (progn - (asdf:load-system "nshell/test") - (funcall (find-symbol "RUN-TESTS" "NSHELL/TEST"))) - (error (condition) - (format *error-output* "~&nshell/test failed: ~A~%" condition) - nil)))) - (sb-ext:exit :code (if passed-p 0 1)))) +(let* ((script-path (truename (or *load-truename* + *load-pathname* + #P"./run-tests.lisp"))) + (root (uiop:pathname-directory-pathname script-path))) + (load (merge-pathnames #P"scripts/asdf-runtime.lisp" root)) + (format *error-output* "~&[nshell] configure runtime~%") + (finish-output *error-output*) + (nshell-configure-runtime root) + (format *error-output* "~&[nshell] load nshell/test~%") + (finish-output *error-output*) + (asdf:load-system "nshell/test") + (format *error-output* "~&[nshell] run tests~%") + (finish-output *error-output*) + (let ((runner (find-symbol "RUN-TESTS" "NSHELL/TEST"))) + (unless (and runner (fboundp runner)) + (error "NSHELL/TEST:RUN-TESTS is not available.")) + (unless (funcall runner) + (error "nshell/test reported failure."))) + (format *error-output* "~&[nshell] tests passed~%") + (finish-output *error-output*) + (sb-ext:exit :code 0)) diff --git a/scripts/asdf-runtime.lisp b/scripts/asdf-runtime.lisp index d70ff11..12e462e 100644 --- a/scripts/asdf-runtime.lisp +++ b/scripts/asdf-runtime.lisp @@ -16,3 +16,113 @@ (t ,(truename output-root)) :ignore-inherited-configuration)) output-root)) + +(defun %nshell-register-asd-file (registry source) + (let ((name (string-downcase (pathname-name source)))) + (unless (gethash name registry) + (setf (gethash name registry) source)))) + +(defparameter +nshell-source-registry-exclusions+ + '(".bzr" ".cdv" ".git" ".hg" ".pc" ".svn" "CVS" "RCS" "SCCS" + "_darcs" "_sgbak" "autom4te.cache" "cover_db" "_build" "debian")) + +(defun %nshell-register-asd-directory (registry directory) + (dolist (source (asdf/source-registry:directory-asd-files directory)) + (%nshell-register-asd-file registry source))) + +(defun %nshell-register-asd-tree (registry directory exclusions) + (asdf/source-registry:collect-sub*directories-asd-files + directory + :exclude exclusions + :collect (lambda (source) + (%nshell-register-asd-file registry source)))) + +(defun %nshell-process-source-registry-form (registry form) + (let ((exclusions (copy-list +nshell-source-registry-exclusions+))) + (dolist (directive + (cdr (asdf/source-registry:validate-source-registry-form form))) + (destructuring-bind (keyword &rest arguments) + (if (consp directive) directive (list directive)) + (case keyword + (:directory + (%nshell-register-asd-directory registry + (uiop:ensure-directory-pathname + (pathname (first arguments))))) + (:tree + (%nshell-register-asd-tree registry + (uiop:ensure-directory-pathname + (pathname (first arguments))) + exclusions)) + (:exclude + (setf exclusions (copy-list arguments))) + (:also-exclude + (setf exclusions (append exclusions arguments))) + ((:ignore-inherited-configuration :inherit-configuration) + nil) + (:default-registry + (error "The nshell source registry cannot include the default registry.")) + (:include + (error "The nshell source registry cannot include ~S." (first arguments))) + (otherwise + (error "Unsupported nshell source-registry directive ~S." keyword))))))) + +(defun %nshell-build-source-registry (forms) + "Build an ASDF registry from FORMS without traversing implementation trees. + +ASDF's standard initializer wraps every registry with the implementation's +source tree. In the Nix shell that tree can contain recursive editor links, +so direct registration is the deterministic project boundary we need here. +Only explicit directories and trees are accepted; inherited and user-level +registries would make a build depend on the host machine." + (let ((registry (make-hash-table :test #'equal))) + (dolist (form forms) + (%nshell-process-source-registry-form registry form)) + registry)) + +(defun nshell-configure-source-registry (root) + "Register ROOT and an explicitly requested source tree with ASDF. + +An explicit CL_SOURCE_REGISTRY is appended after ROOT so project definitions +win name collisions. Outside that shell, NSHELL_SOURCE_TREE is opt-in +because a worktree parent can contain many unrelated checkouts and scanning it +makes source discovery both slow and nondeterministic." + (let* ((root (truename (uiop:ensure-directory-pathname root))) + (forms (list `(:source-registry + (:directory ,root) + :ignore-inherited-configuration))) + (source-registry (uiop:getenv "CL_SOURCE_REGISTRY"))) + (if source-registry + (setf forms + (nconc forms + (list + (asdf/source-registry:parse-source-registry-string + source-registry)))) + (let ((source-tree (uiop:getenv "NSHELL_SOURCE_TREE"))) + (when source-tree + (setf forms + (nconc forms + (list `(:source-registry + (:tree ,(truename + (uiop:ensure-directory-pathname + source-tree)))))))))) + (setf asdf/source-registry:*source-registry* + (%nshell-build-source-registry forms)) + root)) + +(defun nshell-configure-runtime (root) + "Configure ASDF source lookup and compile policy. + +Compiled systems are written beneath NSHELL_ASDF_OUTPUT_DIR when it is set, +or beneath the host temporary directory otherwise. This is required when the +source tree or its dependencies are read-only, as they are in a Nix build. + +SB-POSIX is required here, before the source registry below is replaced with +the project-only tree: cl-host-kit depends on it as a plain ASDF system name +(not a (:require ...) component), and its .asd file lives in SBCL's own +contrib tree, which the replacement registry deliberately excludes." + (require :sb-posix) + (let ((root (nshell-configure-source-registry root))) + (nshell-configure-writable-asdf-output) + (setf asdf:*compile-file-warnings-behaviour* :warn + asdf:*compile-file-failure-behaviour* :error) + root)) diff --git a/scripts/benchmark-completion.lisp b/scripts/benchmark-completion.lisp index f5f0ebe..02c4091 100644 --- a/scripts/benchmark-completion.lisp +++ b/scripts/benchmark-completion.lisp @@ -1,47 +1,43 @@ (require :asdf) -(asdf:load-system :cl-host-kit) -(let* ((root (truename #P"./")) - (parent (host-kit:parent-directory-pathname root)) - (jsonl-path (host-kit:getenv "NSHELL_BENCH_JSONL")) - (mode (string-downcase (or (host-kit:getenv "NSHELL_BENCH_MODE") "warm")))) - (asdf:initialize-source-registry - (if (host-kit:getenv "CL_SOURCE_REGISTRY") - (list :source-registry - (list :directory root) - :inherit-configuration) - (list :source-registry - (list :directory root) - (list :tree parent) - :inherit-configuration))) - (setf asdf:*compile-file-warnings-behaviour* :warn - asdf:*compile-file-failure-behaviour* :warn) - (handler-case - (progn - (unless (member mode '("warm" "process" "all") :test #'string=) - (error "NSHELL_BENCH_MODE must be warm, process, or all (got ~S)" mode)) - (asdf:load-system :nshell/benchmark :force t) - (labels ((run (stream) - (when (member mode '("warm" "all") :test #'string=) - (funcall (find-symbol "RUN-COMPLETION-BENCHMARK" "NSHELL/BENCHMARK") - :jsonl-stream stream)) - (when (member mode '("process" "all") :test #'string=) - (funcall (find-symbol "RUN-PROCESS-BENCHMARK" "NSHELL/BENCHMARK") - :jsonl-stream (or stream *standard-output*))))) - (if (and jsonl-path (plusp (length jsonl-path))) - (progn - (format t "~&JSONL output: ~A~%" jsonl-path) - (let ((failure nil)) - (with-open-file (stream jsonl-path :direction :output :if-exists :supersede - :if-does-not-exist :create :external-format :utf-8) - (handler-case - (run stream) - (error (condition) - (setf failure condition)))) - (when failure - (error failure)))) - (run nil))) - (sb-ext:exit :code 0)) - (error (condition) - (format *error-output* "~&nshell benchmark failed: ~A~%" condition) - (sb-ext:exit :code 1)))) +(let* ((script-path (truename (or *load-truename* + *load-pathname* + #P"./scripts/benchmark-completion.lisp"))) + (script-directory (uiop:pathname-directory-pathname script-path)) + (root (uiop:pathname-parent-directory-pathname script-directory))) + (load (merge-pathnames #P"asdf-runtime.lisp" script-directory)) + (nshell-configure-runtime root) + (let* ((jsonl-path (uiop:getenv "NSHELL_BENCH_JSONL")) + (mode (string-downcase (or (uiop:getenv "NSHELL_BENCH_MODE") "warm")))) + (handler-case + (progn + (unless (member mode '("warm" "process" "all") :test #'string=) + (error "NSHELL_BENCH_MODE must be warm, process, or all (got ~S)" mode)) + (asdf:load-system :nshell/benchmark :force t) + (labels ((run (stream) + (when (member mode '("warm" "all") :test #'string=) + (funcall (find-symbol "RUN-COMPLETION-BENCHMARK" "NSHELL/BENCHMARK") + :jsonl-stream stream)) + (when (member mode '("process" "all") :test #'string=) + (funcall (find-symbol "RUN-PROCESS-BENCHMARK" "NSHELL/BENCHMARK") + :jsonl-stream (or stream *standard-output*))))) + (if (and jsonl-path (plusp (length jsonl-path))) + (progn + (format t "~&JSONL output: ~A~%" jsonl-path) + (let ((failure nil)) + (with-open-file (stream jsonl-path + :direction :output + :if-exists :supersede + :if-does-not-exist :create + :external-format :utf-8) + (handler-case + (run stream) + (error (condition) + (setf failure condition)))) + (when failure + (error failure)))) + (run nil))) + (sb-ext:exit :code 0)) + (error (condition) + (format *error-output* "~&nshell benchmark failed: ~A~%" condition) + (sb-ext:exit :code 1))))) diff --git a/scripts/coverage.lisp b/scripts/coverage.lisp index 6646ae1..e877d31 100644 --- a/scripts/coverage.lisp +++ b/scripts/coverage.lisp @@ -4,11 +4,9 @@ ;;;; ;;;; nshell/test depends on sibling nerima-lisp toolkit checkouts (cl-prolog, ;;;; cl-parser-kit, ...). Inside `nix develop` those systems are already on the -;;;; ASDF source registry. For a plain local checkout we also register the -;;;; parent directory tree, so sibling ghq checkouts are discovered -;;;; automatically. When CL_SOURCE_REGISTRY is present (as it is in the Nix -;;;; development shell), register only this checkout and inherit that explicit -;;;; list. Without it, register the parent tree for a plain local checkout. +;;;; ASDF source registry. Outside that shell, set NSHELL_SOURCE_TREE to one +;;;; explicit directory containing those systems, or set CL_SOURCE_REGISTRY +;;;; yourself. The shared runtime never scans a worktree parent directory. ;;;; ;;;; :force :all is required, not :force t: :force t only forces recompiling ;;;; nshell/test itself, leaving the nshell (src/) dependency loaded from @@ -16,13 +14,13 @@ ;;;; reaches src/, and the report silently covers only test files. (require :asdf) +(defparameter *nshell-coverage-script-path* + (truename (or *load-truename* *load-pathname* #P"./scripts/coverage.lisp"))) + (load (merge-pathnames #P"asdf-runtime.lisp" - (uiop:pathname-directory-pathname - (or *load-truename* *load-pathname*)))) -(nshell-configure-writable-asdf-output) - + (uiop:pathname-directory-pathname *nshell-coverage-script-path*))) (require :sb-cover) (declaim (optimize sb-cover:store-coverage-data)) @@ -30,6 +28,11 @@ (progn (defun %coverage-string-prefix-p (prefix string) (and (<= (length prefix) (length string)) (string= prefix string :end2 (length prefix)))) + (defun %coverage-call (function &rest arguments) + (handler-case + (values (apply function arguments) nil) + (error (condition) + (values nil condition)))) (defun %next-coverage-row (text start end) (let ((odd (search "" text :start2 start :end2 end)) (even (search "" text :start2 start :end2 end))) @@ -53,15 +56,22 @@ (search total-marker row :start2 (+ covered-position (length covered-marker)))))) (if (or (null covered-position) (null total-position)) (values nil nil next-row) (values - (ignore-errors - (parse-integer - row - :start - (+ covered-position (length covered-marker)) - :junk-allowed - t)) - (ignore-errors - (parse-integer row :start (+ total-position (length total-marker)) :junk-allowed t)) + (nth-value 0 + (%coverage-call + #'parse-integer + row + :start + (+ covered-position (length covered-marker)) + :junk-allowed + t)) + (nth-value 0 + (%coverage-call + #'parse-integer + row + :start + (+ total-position (length total-marker)) + :junk-allowed + t)) next-row)))))) (defun %coverage-row-file-name (text start end) (let* ((row-end (search "" text :start2 start :end2 end)) @@ -130,12 +140,26 @@ (let ((raw (uiop:getenv name))) (if (null raw) default (let ((*read-eval* nil) - (value (ignore-errors (read-from-string raw)))) + (value (nth-value 0 (%coverage-call #'read-from-string raw)))) (if (and (realp value) (<= 0 value) (<= value 100)) (float value 1.0) (error "Invalid ~A value ~S; expected a number from 0 to 100." name raw)))))) (defun %json-boolean (value) (if value "true" "false")) + (defun %default-coverage-directory (root) + (let ((nix-output (uiop:getenv "out")) + (tmpdir (uiop:getenv "TMPDIR"))) + (cond + (nix-output + (merge-pathnames + #P"coverage/" + (uiop:ensure-directory-pathname nix-output))) + (tmpdir + (merge-pathnames + #P"nshell-coverage/" + (uiop:ensure-directory-pathname tmpdir))) + (t + (merge-pathnames #P"coverage/" root))))) (defun %write-coverage-summary (pathname files covered @@ -165,17 +189,14 @@ (%json-boolean tests-passed) (%json-boolean (and (plusp total) (>= percentage minimum))) (%json-boolean (and (plusp total) (>= percentage target)))))) - (let* ((root (truename #P"./")) - (parent (uiop:pathname-parent-directory-pathname root)) - (tmpdir (uiop:getenv "TMPDIR")) + (let* ((root (uiop:pathname-parent-directory-pathname + (uiop:pathname-directory-pathname + *nshell-coverage-script-path*))) (coverage-dir (uiop:ensure-directory-pathname (or (uiop:getenv "NSHELL_COVERAGE_DIR") - (if tmpdir (merge-pathnames - #P"nshell-coverage/" - (uiop:ensure-directory-pathname tmpdir)) - (merge-pathnames #P"coverage/" root))))) + (%default-coverage-directory root)))) (index-path (merge-pathnames #P"cover-index.html" coverage-dir)) (summary-path (merge-pathnames #P"coverage-summary.json" coverage-dir)) (source-root (uiop:native-namestring (truename (merge-pathnames #P"src/" root)))) @@ -184,15 +205,7 @@ (tests-passed nil) (report-passed nil) (coverage-passed nil)) - (asdf:initialize-source-registry - (if (uiop:getenv "CL_SOURCE_REGISTRY") - `(:source-registry - (:directory ,root) - :inherit-configuration) - `(:source-registry - (:directory ,root) - (:tree ,parent) - :inherit-configuration))) + (nshell-configure-runtime root) (ensure-directories-exist coverage-dir) (sb-cover:enable-coverage-logging) (unwind-protect (setf tests-passed (handler-case @@ -252,7 +265,7 @@ (unless coverage-passed (format *error-output* - "~&src executable expression coverage did not meet the configured minimum (~,2F%%).~%" + "~&src executable expression coverage did not meet the required minimum (~,2F%%).~%" minimum)))) (sb-ext:exit :code diff --git a/scripts/weave.lisp b/scripts/weave.lisp index 4b72e99..d652566 100644 --- a/scripts/weave.lisp +++ b/scripts/weave.lisp @@ -3,35 +3,18 @@ ;;;; Usage: sbcl --script scripts/weave.lisp ;;;; ;;;; Beyond cl-weave and cl-prolog, this suite also depends on cl-prolog/weave. -;;;; Inside `nix develop` those systems are already on the -;;;; ASDF source registry. For a plain local checkout we also register the -;;;; parent directory tree, so sibling ghq checkouts (../cl-weave, ../cl-prolog) -;;;; are discovered automatically. An explicit CL_SOURCE_REGISTRY still wins -;;;; because we inherit the existing configuration. +;;;; The ASDF source and output configuration is shared with run-tests.lisp and +;;;; the coverage and benchmark entry points. (require :asdf) -(asdf:load-system :cl-host-kit) -(load - (merge-pathnames - #P"asdf-runtime.lisp" - (uiop:pathname-directory-pathname - (or *load-truename* *load-pathname*)))) -(nshell-configure-writable-asdf-output) - -(let* ((root (truename #P"./")) - (parent (host-kit:parent-directory-pathname root))) - (asdf:initialize-source-registry - (if (host-kit:getenv "CL_SOURCE_REGISTRY") - `(:source-registry - (:directory ,root) - :inherit-configuration) - `(:source-registry - (:directory ,root) - (:tree ,parent) - :inherit-configuration))) - (setf asdf:*compile-file-warnings-behaviour* :warn - asdf:*compile-file-failure-behaviour* :warn) +(let* ((script-path (truename (or *load-truename* + *load-pathname* + #P"./scripts/weave.lisp"))) + (script-directory (uiop:pathname-directory-pathname script-path)) + (root (uiop:pathname-parent-directory-pathname script-directory))) + (load (merge-pathnames #P"asdf-runtime.lisp" script-directory)) + (nshell-configure-runtime root) (let ((passed-p (handler-case (progn diff --git a/src/application/builtin-commands.lisp b/src/application/builtin-commands.lisp index 3296967..94ab051 100644 --- a/src/application/builtin-commands.lisp +++ b/src/application/builtin-commands.lisp @@ -1,320 +1,8 @@ (in-package #:nshell.application) - -(eval-when (:compile-toplevel :load-toplevel :execute) - (defmacro define-builtin (name lambda-list ignore-variables &body body) - `(defun ,name ,lambda-list - ,@(when ignore-variables - `((declare (ignore ,@ignore-variables)))) - ,@body))) - (define-builtin %builtin-echo (context args) (context) (values (format nil "~{~a~^ ~}~%" args) 0)) -(defun %printf-pad (text width left-p &optional (pad-character #\Space)) - (let ((padding (max 0 (- (or width 0) (length text))))) - (if (zerop padding) - text - (let ((padding-text (make-string padding :initial-element pad-character))) - (if left-p - (concatenate 'string text padding-text) - (concatenate 'string padding-text text)))))) - -(defun %printf-flag-p (flags flag) - (find flag flags :test #'char=)) - -(defun %printf-read-escape (text index) - (if (>= index (length text)) - (values "\\" index nil) - (let ((character (char text index))) - (case character - (#\a (values (string (code-char 7)) (1+ index) nil)) - (#\b (values (string #\Backspace) (1+ index) nil)) - (#\c (values "" (1+ index) t)) - (#\e (values (string (code-char 27)) (1+ index) nil)) - (#\f (values (string #\Page) (1+ index) nil)) - (#\n (values (string #\Newline) (1+ index) nil)) - (#\r (values (string #\Return) (1+ index) nil)) - (#\t (values (string #\Tab) (1+ index) nil)) - (#\v (values (string (code-char 11)) (1+ index) nil)) - (#\\ (values (string #\\) (1+ index) nil)) - (#\0 - (let ((cursor (1+ index)) - (value 0) - (digits 0)) - (loop while (and (< cursor (length text)) - (< digits 3) - (find (char text cursor) "01234567" :test #'char=)) - do (setf value (+ (* value 8) - (- (char-code (char text cursor)) - (char-code #\0))) - cursor (1+ cursor) - digits (1+ digits))) - (values (string (or (code-char value) #\Null)) cursor nil))) - (otherwise (values (string character) (1+ index) nil)))))) - -(defun %printf-expand-escapes (text) - (let* ((stop nil) - (result - (with-output-to-string (out) - (loop with index = 0 - while (< index (length text)) - do (if (char= (char text index) #\\) - (multiple-value-bind (replacement next-index stop-p) - (%printf-read-escape text (1+ index)) - (write-string replacement out) - (setf index next-index - stop stop-p) - (when stop - (return))) - (progn - (write-char (char text index) out) - (incf index))))))) - (values result stop))) - -(defun %printf-parse-integer (argument) - (handler-case - (values (parse-integer argument :junk-allowed nil) t) - (error () (values 0 nil)))) - -(defun %printf-parse-real (argument) - (handler-case - (let ((*read-eval* nil)) - (multiple-value-bind (value position) - (read-from-string argument nil nil) - (if (and (realp value) - (= position (length argument))) - (values (coerce value 'double-float) t) - (values 0d0 nil)))) - (error () (values 0d0 nil)))) - -(defun %printf-integer-text (number conversion flags precision) - (let* ((negative (minusp number)) - (unsigned (if negative (abs number) number)) - (base (case conversion - ((#\x #\X) 16) - (#\o 8) - (otherwise 10))) - (digits (case base - (16 (format nil "~x" unsigned)) - (8 (format nil "~o" unsigned)) - (otherwise (format nil "~d" unsigned)))) - (digits (if (char= conversion #\X) - (string-upcase digits) - digits)) - (digits (if precision - (concatenate 'string - (make-string (max 0 (- precision (length digits))) - :initial-element #\0) - digits) - digits)) - (prefix (if (and (%printf-flag-p flags #\#) - (not (zerop unsigned))) - (case conversion - (#\x "0x") - (#\X "0X") - (#\o "0") - (otherwise "")) - "")) - (sign (cond - (negative "-") - ((%printf-flag-p flags #\+) "+") - ((%printf-flag-p flags #\Space) " ") - (t "")))) - (concatenate 'string sign prefix digits))) - -(defun %printf-format-value (argument conversion flags width precision) - (case conversion - (#\s - (values (%printf-pad (if precision - (subseq argument 0 (min precision (length argument))) - argument) - width - (%printf-flag-p flags #\-)) - t - nil)) - (#\b - (multiple-value-bind (text stop-p) (%printf-expand-escapes argument) - (values (%printf-pad (if precision - (subseq text 0 (min precision (length text))) - text) - width - (%printf-flag-p flags #\-)) - t - stop-p))) - (#\c - (values (%printf-pad (if (plusp (length argument)) - (string (char argument 0)) - (string #\Null)) - width - (%printf-flag-p flags #\-)) - t - nil)) - ((#\d #\i #\u #\o #\x #\X) - (multiple-value-bind (number valid-p) (%printf-parse-integer argument) - (values (%printf-pad (%printf-integer-text number conversion flags precision) - width - (%printf-flag-p flags #\-) - (if (and (%printf-flag-p flags #\0) - (not (%printf-flag-p flags #\-)) - (null precision)) - #\0 - #\Space)) - valid-p - nil))) - ((#\e #\E #\f #\g #\G) - (multiple-value-bind (number valid-p) (%printf-parse-real argument) - (let* ((digits (or precision 6)) - (directive (case conversion - ((#\e #\E) "e") - ((#\g #\G) "g") - (otherwise "f"))) - (text (format nil - (concatenate 'string "~," (princ-to-string digits) directive) - number)) - (text (if (and (member conversion '(#\E #\G)) - (position #\e text)) - (substitute #\E #\e text) - text)) - (text (if (and (not (minusp number)) - (%printf-flag-p flags #\+)) - (concatenate 'string "+" text) - (if (and (not (minusp number)) - (%printf-flag-p flags #\Space)) - (concatenate 'string " " text) - text)))) - (values (%printf-pad text width (%printf-flag-p flags #\-) - (if (and (%printf-flag-p flags #\0) - (not (%printf-flag-p flags #\-))) - #\0 - #\Space)) - valid-p - nil)))) - (otherwise (values "" nil nil)))) - -(defun %printf-format-once (format-string arguments) - (let ((format-index 0) - (argument-index 0) - (argument-conversion-p nil) - (valid-p t) - (stop-p nil)) - (values - (with-output-to-string (out) - (loop while (< format-index (length format-string)) - do (let ((character (char format-string format-index))) - (if (char/= character #\%) - (if (char= character #\\) - (multiple-value-bind (replacement next-index escape-stop-p) - (%printf-read-escape format-string (1+ format-index)) - (write-string replacement out) - (setf format-index next-index - stop-p escape-stop-p) - (when stop-p - (return))) - (progn - (write-char character out) - (incf format-index))) - (progn - (incf format-index) - (if (>= format-index (length format-string)) - (progn - (setf valid-p nil) - (return)) - (if (char= (char format-string format-index) #\%) - (progn - (write-char #\% out) - (incf format-index)) - (let ((flags "")) - (loop while (and (< format-index (length format-string)) - (find (char format-string format-index) - "-+0# " - :test #'char=)) - do (setf flags - (concatenate 'string - flags - (string (char format-string format-index))) - format-index (1+ format-index))) - (let ((width-start format-index) - (precision nil)) - (loop while (and (< format-index (length format-string)) - (digit-char-p (char format-string format-index))) - do (incf format-index)) - (let ((width (when (> format-index width-start) - (parse-integer format-string - :start width-start - :end format-index)))) - (when (and (< format-index (length format-string)) - (char= (char format-string format-index) #\.)) - (incf format-index) - (let ((precision-start format-index)) - (loop while (and (< format-index (length format-string)) - (digit-char-p (char format-string format-index))) - do (incf format-index)) - (setf precision - (if (= precision-start format-index) - 0 - (parse-integer format-string - :start precision-start - :end format-index))))) - (loop while (and (< format-index (length format-string)) - (find (char format-string format-index) - "hlLjzt" - :test #'char=)) - do (incf format-index)) - (if (>= format-index (length format-string)) - (progn - (setf valid-p nil) - (return)) - (let ((conversion (char format-string format-index))) - (incf format-index) - (setf argument-conversion-p t) - (if (find conversion "sbcdiuoxXeEfFgG" - :test #'char=) - (let ((argument - (if (< argument-index (length arguments)) - (nth argument-index arguments) - ""))) - (incf argument-index) - (multiple-value-bind (text value-valid-p value-stop-p) - (%printf-format-value argument conversion flags width precision) - (write-string text out) - (unless value-valid-p - (setf valid-p nil)) - (when value-stop-p - (setf stop-p t) - (return)))) - (progn - (setf valid-p nil) - (return))))))))))))))) - argument-index - argument-conversion-p - valid-p - stop-p))) - -(define-builtin %builtin-printf (context args) (context) - (let ((arguments (if (and args (string= (first args) "--")) - (rest args) - args))) - (if (null arguments) - (values nil 0) - (let* ((format-string (first arguments)) - (remaining (rest arguments)) - (valid-p t) - (output - (with-output-to-string (out) - (loop - (multiple-value-bind (text consumed argument-conversion-p once-valid-p stop-p) - (%printf-format-once format-string remaining) - (write-string text out) - (setf valid-p (and valid-p once-valid-p) - remaining (nthcdr consumed remaining)) - (when (or stop-p - (not once-valid-p) - (null remaining) - (not argument-conversion-p)) - (return))))))) - (values output (if valid-p 0 1)))))) - (define-builtin %builtin-pwd (context args) (args) (values (format nil "~a~%" (namestring (funcall (%filesystem-fn context :cwd)))) 0)) @@ -358,16 +46,16 @@ (namestring old-cwd) (if (nshell.domain.environment:env-defined-p environment "OLDPWD") (nshell.domain.environment:env-exported-p environment "OLDPWD") - t)) - environment + t))) + (setf environment (nshell.domain.environment:env-set environment "PWD" (namestring new-cwd) (if (nshell.domain.environment:env-defined-p environment "PWD") (nshell.domain.environment:env-exported-p environment "PWD") - t)) - (shell-context-environment context) environment)) + t))) + (setf (shell-context-environment context) environment)) (values (when (and args (string= (first args) "-")) (format nil "~a~%" (namestring new-cwd))) 0)))) @@ -377,8 +65,8 @@ (defun %parse-exit-status (argument) (handler-case (values (mod (parse-integer argument :junk-allowed nil) 256) t) - (error () - (values nil nil)))) + (error (condition) + (values nil nil condition)))) (define-builtin %builtin-exit (context args) () (cond @@ -416,7 +104,13 @@ (%execute-command-by-name-in-context context command command-args) (values output (%invert-status-code code)))))) -(defun %builtin-exec (context args) (declare (ignore context)) (if args (sb-ext:quit :unix-status (nshell.infrastructure.acl:run-external-exec (first args) (rest args))) (%builtin-usage "exec" "exec command [args...]"))) +(defun %builtin-exec (context args) + (if args + (funcall (%process-fn context :exit-process) + (funcall (%process-fn context :run-external-exec) + (first args) + (rest args))) + (%builtin-usage "exec" "exec command [args...]"))) (defun %contains-usage () (%builtin-usage @@ -463,7 +157,7 @@ (error-output (values error-output 2)) ((null operands) - (values (%contains-usage) 2)) + (values (%contains-usage) 2)) (t (let* ((needle (first operands)) (values (rest operands)) diff --git a/src/application/builtin-jobs.lisp b/src/application/builtin-jobs.lisp index 1f0958b..6c76862 100644 --- a/src/application/builtin-jobs.lisp +++ b/src/application/builtin-jobs.lisp @@ -29,7 +29,8 @@ (shell-context-dispatcher context) (shell-context-process-registry context) (shell-context-terminal-fns context) - job-monitor))) + job-monitor + (shell-context-process-fns context)))) (if job (values nil 0) (values (%missing-job-output "fg" (%job-spec-label args)) 1)))) @@ -39,7 +40,8 @@ (job-id (%resolve-job-id job-monitor args :active-only-p t)) (job (bg job-id (shell-context-dispatcher context) - job-monitor))) + job-monitor + (shell-context-process-fns context)))) (if job (values nil 0) (values (%missing-job-output "bg" (%job-spec-label args)) 1)))) @@ -97,7 +99,8 @@ (type-error () nil)))) (defun %parse-positive-integer (text) (let ((value (%parse-integer-designator text))) - (and value (plusp value) value))) + (when (and value (plusp value)) + value))) (defun %find-job-id-by-pid (job-monitor pid) (let ((found nil)) (nshell.domain.job-control:monitor-map-jobs @@ -146,7 +149,10 @@ (let ((job-id (%resolve-wait-job-id job-monitor job-spec))) (multiple-value-bind (job exit-code) (if job-id - (wait-for-job job-id process-registry job-monitor) + (wait-for-job job-id + process-registry + job-monitor + (shell-context-process-fns context)) (values nil nil)) (if job (setf last-code exit-code) @@ -253,14 +259,18 @@ (string= target "-"))) (nshell.domain.job-control:monitor-resolve-job-spec job-monitor target))) -(defun %kill-one-target (job-monitor target signal) +(defun %kill-one-target (context job-monitor target signal) (let ((job-id (%resolve-kill-job-id job-monitor target))) (cond - ((and job-id (signal-job job-id signal job-monitor)) + ((and job-id + (signal-job job-id + signal + job-monitor + (shell-context-process-fns context))) t) ((let ((pid (%parse-integer-designator target))) (when pid - (nshell.infrastructure.acl:kill-process pid signal) + (funcall (%process-fn context :kill-process) pid signal) t))) (t nil)))) (defun %kill-list-output () @@ -286,6 +296,7 @@ (dolist (target targets) (handler-case (unless (%kill-one-target + context job-monitor target signal-designator) (setf code 1) (format out "kill: no such process or job: ~a~%" diff --git a/src/application/builtin-macros.lisp b/src/application/builtin-macros.lisp new file mode 100644 index 0000000..248ba09 --- /dev/null +++ b/src/application/builtin-macros.lisp @@ -0,0 +1,8 @@ +(in-package #:nshell.application) + +(eval-when (:compile-toplevel :load-toplevel :execute) + (defmacro define-builtin (name lambda-list ignore-variables &body body) + `(defun ,name ,lambda-list + ,@(when ignore-variables + `((declare (ignore ,@ignore-variables)))) + ,@body))) diff --git a/src/application/builtin-printf-format.lisp b/src/application/builtin-printf-format.lisp new file mode 100644 index 0000000..62661cb --- /dev/null +++ b/src/application/builtin-printf-format.lisp @@ -0,0 +1,208 @@ +(in-package #:nshell.application) + +(defun %printf-pad (text width left-p &optional (pad-character #\Space)) + (let ((padding (max 0 (- (or width 0) (length text))))) + (if (zerop padding) + text + (let ((padding-text (make-string padding :initial-element pad-character))) + (if left-p + (concatenate 'string text padding-text) + (concatenate 'string padding-text text)))))) + +(defun %printf-flag-p (flags flag) + (find flag flags :test #'char=)) + +(defun %printf-read-escape (text index) + (if (>= index (length text)) + (values "\\" index nil) + (let ((character (char text index))) + (case character + (#\a (values (string (code-char 7)) (1+ index) nil)) + (#\b (values (string #\Backspace) (1+ index) nil)) + (#\c (values "" (1+ index) t)) + (#\e (values (string (code-char 27)) (1+ index) nil)) + (#\f (values (string #\Page) (1+ index) nil)) + (#\n (values (string #\Newline) (1+ index) nil)) + (#\r (values (string #\Return) (1+ index) nil)) + (#\t (values (string #\Tab) (1+ index) nil)) + (#\v (values (string (code-char 11)) (1+ index) nil)) + (#\\ (values (string #\\) (1+ index) nil)) + (#\0 + (let ((cursor (1+ index)) + (value 0) + (digits 0)) + (loop while (and (< cursor (length text)) + (< digits 3) + (find (char text cursor) "01234567" :test #'char=)) + do (setf value (+ (* value 8) + (- (char-code (char text cursor)) + (char-code #\0))) + cursor (1+ cursor) + digits (1+ digits))) + (values (string (or (code-char value) #\Null)) cursor nil))) + (otherwise (values (string character) (1+ index) nil)))))) + +(defun %printf-expand-escapes (text) + (let* ((stop nil) + (result + (with-output-to-string (out) + (loop with index = 0 + while (< index (length text)) + do (if (char= (char text index) #\\) + (multiple-value-bind (replacement next-index stop-p) + (%printf-read-escape text (1+ index)) + (write-string replacement out) + (setf index next-index + stop stop-p) + (when stop + (return))) + (progn + (write-char (char text index) out) + (incf index))))))) + (values result stop))) + +(defun %printf-parse-integer (argument) + (handler-case + (values (parse-integer argument :junk-allowed nil) t) + (error () (values 0 nil)))) + +(defun %printf-parse-real (argument) + (handler-case + (let ((*read-eval* nil)) + (multiple-value-bind (value position) + (read-from-string argument nil nil) + (if (and (realp value) + (= position (length argument))) + (values (coerce value 'double-float) t) + (values 0d0 nil)))) + (error () (values 0d0 nil)))) + +(defun %printf-integer-text (number conversion flags precision) + (let* ((negative (minusp number)) + (unsigned (if negative (abs number) number)) + (base (cond + ((or (char= conversion #\x) (char= conversion #\X)) 16) + ((char= conversion #\o) 8) + (t 10))) + (digits (case base + (16 (format nil "~x" unsigned)) + (8 (format nil "~o" unsigned)) + (otherwise (format nil "~d" unsigned)))) + (digits (cond + ((char= conversion #\X) (string-upcase digits)) + ((char= conversion #\x) (string-downcase digits)) + (t digits))) + (digits (if precision + (concatenate 'string + (make-string (max 0 (- precision (length digits))) + :initial-element #\0) + digits) + digits)) + (prefix (if (and (%printf-flag-p flags #\#) + (not (zerop unsigned))) + (cond + ((char= conversion #\x) "0x") + ((char= conversion #\X) "0X") + ((char= conversion #\o) "0") + (t "")) + "")) + (sign (cond + (negative "-") + ((%printf-flag-p flags #\+) "+") + ((%printf-flag-p flags #\Space) " ") + (t "")))) + (concatenate 'string sign prefix digits))) + +(defun %printf-truncate (text precision) + (if precision + (subseq text 0 (min precision (length text))) + text)) + +(defun %printf-format-text-value (argument flags width precision) + (%printf-pad (%printf-truncate argument precision) + width + (%printf-flag-p flags #\-))) + +(defun %printf-format-binary-value (argument flags width precision) + (multiple-value-bind (text stop-p) (%printf-expand-escapes argument) + (values (%printf-pad (%printf-truncate text precision) + width + (%printf-flag-p flags #\-)) + stop-p))) + +(defun %printf-format-character-value (argument flags width) + (%printf-pad (if (plusp (length argument)) + (string (char argument 0)) + (string #\Null)) + width + (%printf-flag-p flags #\-))) + +(defun %printf-zero-pad-p (flags precision) + (and (%printf-flag-p flags #\0) + (not (%printf-flag-p flags #\-)) + (null precision))) + +(defun %printf-format-integer-value (argument conversion flags width precision) + (multiple-value-bind (number valid-p) (%printf-parse-integer argument) + (values (%printf-pad (%printf-integer-text number conversion flags precision) + width + (%printf-flag-p flags #\-) + (if (%printf-zero-pad-p flags precision) + #\0 + #\Space)) + valid-p))) + +(defun %printf-real-text (number conversion flags precision) + (let* ((digits (or precision 6)) + (text (cond + ((or (char= conversion #\e) (char= conversion #\E)) + (format nil "~,vE" digits number)) + ((or (char= conversion #\g) (char= conversion #\G)) + (format nil "~,vG" digits number)) + (t + (format nil "~,vF" digits number)))) + (text (if (or (char= conversion #\E) (char= conversion #\G)) + (substitute #\E #\d (substitute #\E #\e text)) + text))) + (if (and (not (minusp number)) + (%printf-flag-p flags #\+)) + (concatenate 'string "+" text) + (if (and (not (minusp number)) + (%printf-flag-p flags #\Space)) + (concatenate 'string " " text) + text)))) + +(defun %printf-format-real-value (argument conversion flags width precision) + (multiple-value-bind (number valid-p) (%printf-parse-real argument) + (values (%printf-pad (%printf-real-text number conversion flags precision) + width + (%printf-flag-p flags #\-) + (if (and (%printf-flag-p flags #\0) + (not (%printf-flag-p flags #\-))) + #\0 + #\Space)) + valid-p))) + +(defun %printf-format-value (argument conversion flags width precision) + (cond + ((char= conversion #\s) + (values (%printf-format-text-value argument flags width precision) + t + nil)) + ((char= conversion #\b) + (multiple-value-bind (text stop-p) + (%printf-format-binary-value argument flags width precision) + (values text t stop-p))) + ((char= conversion #\c) + (values (%printf-format-character-value argument flags width) + t + nil)) + ((find conversion "diuoxX" :test #'char=) + (multiple-value-bind (text valid-p) + (%printf-format-integer-value argument conversion flags width precision) + (values text valid-p nil))) + ((find conversion "eEfgG" :test #'char=) + (multiple-value-bind (text valid-p) + (%printf-format-real-value argument conversion flags width precision) + (values text valid-p nil))) + (t (values "" nil nil)))) diff --git a/src/application/builtin-printf-parser.lisp b/src/application/builtin-printf-parser.lisp new file mode 100644 index 0000000..105891a --- /dev/null +++ b/src/application/builtin-printf-parser.lisp @@ -0,0 +1,113 @@ +(in-package #:nshell.application) + +(defun %printf-read-flags (text index) + (let ((start index)) + (loop while (and (< index (length text)) + (find (char text index) "-+0# " :test #'char=)) + do (incf index)) + (values (subseq text start index) index))) + +(defun %printf-read-decimal (text index) + (let ((start index)) + (loop while (and (< index (length text)) + (digit-char-p (char text index))) + do (incf index)) + (values (when (> index start) + (parse-integer text :start start :end index)) + index))) + +(defun %printf-read-precision (text index) + (if (and (< index (length text)) + (char= (char text index) #\.)) + (multiple-value-bind (precision next-index) + (%printf-read-decimal text (1+ index)) + (values (or precision 0) next-index)) + (values nil index))) + +(defun %printf-skip-length (text index) + (loop while (and (< index (length text)) + (find (char text index) "hlLjzt" :test #'char=)) + do (incf index)) + index) + +(defun %printf-read-directive (text index) + (multiple-value-bind (flags index) (%printf-read-flags text index) + (multiple-value-bind (width index) (%printf-read-decimal text index) + (multiple-value-bind (precision index) (%printf-read-precision text index) + (setf index (%printf-skip-length text index)) + (if (>= index (length text)) + (values flags width precision nil index nil) + (values flags width precision (char text index) (1+ index) t)))))) + +(defun %printf-conversion-p (conversion) + (and conversion + (find conversion "sbcdiuoxXeEfFgG" :test #'char=))) + +(defun %printf-emit-directive (out format-string index arguments argument-index) + (multiple-value-bind (flags width precision conversion next-index directive-p) + (%printf-read-directive format-string index) + (cond + ((not directive-p) + (values next-index argument-index nil nil nil)) + ((not (%printf-conversion-p conversion)) + (values next-index argument-index nil nil t)) + (t + (let ((argument (if (< argument-index (length arguments)) + (nth argument-index arguments) + ""))) + (multiple-value-bind (text value-valid-p value-stop-p) + (%printf-format-value argument conversion flags width precision) + (write-string text out) + (values next-index (1+ argument-index) + value-valid-p value-stop-p t))))))) + +(defun %printf-format-once (format-string arguments) + (let ((format-index 0) + (argument-index 0) + (argument-conversion-p nil) + (valid-p t) + (stop-p nil)) + (values + (with-output-to-string (out) + (loop while (< format-index (length format-string)) + do (let ((character (char format-string format-index))) + (cond + ((char/= character #\%) + (if (char= character #\\) + (multiple-value-bind (replacement next-index escape-stop-p) + (%printf-read-escape format-string (1+ format-index)) + (write-string replacement out) + (setf format-index next-index + stop-p escape-stop-p) + (when stop-p + (return))) + (progn + (write-char character out) + (incf format-index)))) + (t + (incf format-index) + (cond + ((>= format-index (length format-string)) + (setf valid-p nil) + (return)) + ((char= (char format-string format-index) #\%) + (write-char #\% out) + (incf format-index)) + (t + (multiple-value-bind (next-index next-argument-index + directive-valid-p directive-stop-p + directive-argument-p) + (%printf-emit-directive out format-string format-index + arguments argument-index) + (setf format-index next-index + argument-index next-argument-index + argument-conversion-p + (or argument-conversion-p directive-argument-p) + valid-p (and valid-p directive-valid-p)) + (when (or (not directive-valid-p) directive-stop-p) + (setf stop-p directive-stop-p) + (return)))))))))) + argument-index + argument-conversion-p + valid-p + stop-p))) diff --git a/src/application/builtin-printf.lisp b/src/application/builtin-printf.lisp new file mode 100644 index 0000000..2e1782f --- /dev/null +++ b/src/application/builtin-printf.lisp @@ -0,0 +1,25 @@ +(in-package #:nshell.application) + +(define-builtin %builtin-printf (context args) (context) + (let ((arguments (if (and args (string= (first args) "--")) + (rest args) + args))) + (if (null arguments) + (values nil 0) + (let* ((format-string (first arguments)) + (remaining (rest arguments)) + (valid-p t) + (output + (with-output-to-string (out) + (loop + (multiple-value-bind (text consumed argument-conversion-p once-valid-p stop-p) + (%printf-format-once format-string remaining) + (write-string text out) + (setf valid-p (and valid-p once-valid-p) + remaining (nthcdr consumed remaining)) + (when (or stop-p + (not once-valid-p) + (null remaining) + (not argument-conversion-p)) + (return))))))) + (values output (if valid-p 0 1)))))) diff --git a/src/application/builtin-runtime.lisp b/src/application/builtin-runtime.lisp index 1580d26..77f00ae 100644 --- a/src/application/builtin-runtime.lisp +++ b/src/application/builtin-runtime.lisp @@ -94,7 +94,10 @@ :empty-directory "")) (defun %stat-path (context path) - (ignore-errors (funcall (%filesystem-fn context :stat) path))) + (handler-case + (values (funcall (%filesystem-fn context :stat) path) nil) + (error (condition) + (values nil condition)))) (defun %path-file-p (context path) (let ((fn (%optional-filesystem-fn context :file-exists-p))) diff --git a/src/application/builtin-type-helpers.lisp b/src/application/builtin-type-helpers.lisp index 3f86477..8a58434 100644 --- a/src/application/builtin-type-helpers.lisp +++ b/src/application/builtin-type-helpers.lisp @@ -18,14 +18,18 @@ (defun %type-option-p (option) (%builtin-option-like-p option)) +(defun %type-option-prefix-p (prefix option) + (and (stringp option) + (<= (length prefix) (length option)) + (equal prefix (subseq option 0 (length prefix))))) + (defun %type-option-kind (option) (cond ((%builtin-option-p option '("-a" "--all")) :all) ((%builtin-option-p option '("-s" "--short")) :short) ((%builtin-option-p option '("-f" "--no-functions")) :no-functions) ((or (string= option "--color") - (and (>= (length option) 8) - (string= option "--color=" :end1 8 :end2 8))) + (%type-option-prefix-p "--color=" option)) :color) ((%builtin-option-p option '("-q" "--query" "--quiet")) :query) ((%builtin-option-p option '("-p" "--path")) :path) @@ -36,8 +40,7 @@ (defun %type-color-enabled-p (option) (or (string= option "--color") - (and (>= (length option) 8) - (string= option "--color=" :end1 8 :end2 8) + (and (%type-option-prefix-p "--color=" option) (let ((value (subseq option 8))) (or (string= value "always") (string= value "auto")))))) @@ -53,8 +56,8 @@ (with-output-to-string (out) (dolist (line (%string-lines text)) (write-string - (nshell.presentation:highlight->ansi - (nshell.presentation:highlight-line line) + (nshell.highlight:highlight->ansi + (nshell.highlight:highlight-line line) line (nshell.domain.configuration:default-theme)) out) diff --git a/src/application/execute-pipeline-expansion.lisp b/src/application/execute-pipeline-expansion.lisp index 994e91a..fe2044a 100644 --- a/src/application/execute-pipeline-expansion.lisp +++ b/src/application/execute-pipeline-expansion.lisp @@ -22,14 +22,15 @@ arithmetic $((..)) > POSIX $(..) > bare (..) > literal character." (first forms) (let ((parts (gensym "PARTS")) (pos (gensym "POS"))) `(multiple-value-bind (,parts ,pos) ,(first forms) - (if ,pos - (values ,parts ,pos) - (%try-substitution-match ,@(rest forms)))))))) + (if ,pos + (values ,parts ,pos) + (%try-substitution-match ,@(rest forms)))))))) -(defun %make-pipeline-shell-context () +(defun %make-pipeline-shell-context (process-fns) (make-shell-context :environment (nshell.domain.environment:inject-os-environment - (nshell.domain.environment:make-default-environment)))) + (nshell.domain.environment:make-default-environment)) + :process-fns process-fns)) (defun execute-command-line (line history dispatcher) (nshell.domain.parsing:with-complete-command-line (result ast line) @@ -140,7 +141,7 @@ arithmetic $((..)) > POSIX $(..) > bare (..) > literal character." (%append-expanded-fragment-fields fields fragment-fields)))))) (defun %expand-command-name-from-fragments (command-node environment) - (nshell.domain.expansion::%single-command-name-or-error + (nshell.domain.expansion:single-command-name-or-error (nshell.domain.parsing:command-node-command command-node) (%expand-command-name-fields-from-fragments command-node environment))) @@ -206,7 +207,7 @@ arithmetic $((..)) > POSIX $(..) > bare (..) > literal character." "Run the command substitution whose opening #\( is at OPEN-PAREN. Returns (replacement next-pos) on success, or NIL when parens are empty/unbalanced. %execute-command-substitution-fields is defined later in execute-pipeline-control.lisp." - (let ((end (nshell.domain.parsing::%balanced-substitution-end value open-paren))) + (let ((end (nshell.domain.parsing:balanced-substitution-end value open-paren))) (when (and end (> end (1+ open-paren))) (values (if preserve-newlines-p (%execute-command-substitution-output diff --git a/src/application/execute-pipeline-stage-background.lisp b/src/application/execute-pipeline-stage-background.lisp index 43f574d..3e95fab 100644 --- a/src/application/execute-pipeline-stage-background.lisp +++ b/src/application/execute-pipeline-stage-background.lisp @@ -2,16 +2,25 @@ ;;; Background pipeline stage helpers. -(defun %background-process-pid (process) +(defun %background-call-process-operation (function &rest arguments) + (handler-case + (values (apply function arguments) nil) + (error (condition) + (values nil condition)))) + +(defun %background-process-pid (context process) "Return the OS PID of a background PROCESS object, or NIL if unavailable." - (ignore-errors (sb-ext:process-pid process))) + (%background-call-process-operation (%process-fn context :process-pid) process)) (defun %register-background-job (context processes command-line) "Register PROCESSES as a background job in CONTEXT's monitor and process registry. PROCESSES is a single process object (command) or a list (pipeline). -Returns the job ID, or NIL when PIDs cannot be obtained." + Returns the job ID, or NIL when PIDs cannot be obtained." (let* ((proc-list (if (listp processes) processes (list processes))) - (pids (delete nil (mapcar #'%background-process-pid proc-list)))) + (pids (delete nil + (mapcar (lambda (process) + (%background-process-pid context process)) + proc-list)))) (when pids (let ((job-id (nshell.domain.job-control:monitor-add-background-job (shell-context-job-monitor context) @@ -35,10 +44,10 @@ Returns the job ID, or NIL when PIDs cannot be obtained." (nshell.domain.parsing:pipeline-node-commands command)) (cond (error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values error 127)) (resources - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values (%process-substitution-error "is not supported in background jobs") @@ -53,8 +62,8 @@ Returns the job ID, or NIL when PIDs cannot be obtained." redirect-split)) (clean-pipeline (nshell.domain.parsing:make-pipeline-node clean-commands)) - (processes (nshell.infrastructure.acl:spawn-pipeline-async - clean-commands :redirects redirects)) + (processes (funcall (%process-fn context :spawn-pipeline-async) + clean-commands :redirects redirects)) (command-line (nshell.domain.parsing:ast-node->command-line clean-pipeline))) (when processes @@ -66,10 +75,10 @@ Returns the job ID, or NIL when PIDs cannot be obtained." (%expand-command-node-in-context context command) (cond (error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values error 127)) (resources - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values (%process-substitution-error "is not supported in background jobs") @@ -86,8 +95,8 @@ Returns the job ID, or NIL when PIDs cannot be obtained." (args (nshell.domain.parsing:command-node-arg-values clean-command)) (command-line (nshell.domain.parsing:ast-node->command-line clean-command)) - (process (nshell.infrastructure.acl:spawn-async - cmd args :redirects redirects))) + (process (funcall (%process-fn context :spawn-async) + cmd args :redirects redirects))) (when process (%register-background-job context process command-line)) (values nil 0)))))) diff --git a/src/application/execute-pipeline-stage-external.lisp b/src/application/execute-pipeline-stage-external.lisp index a51db71..e9b6343 100644 --- a/src/application/execute-pipeline-stage-external.lisp +++ b/src/application/execute-pipeline-stage-external.lisp @@ -1,8 +1,10 @@ (in-package #:nshell.application) -;; Keep the timeout variable special in this compilation unit as well so -;; SBCL does not warn when referencing the shared ACL configuration. -(declaim (special nshell.infrastructure.acl:*external-command-timeout*)) +(defun %wait-external-process-for-cleanup (context process) + (handler-case + (values (funcall (%process-fn context :process-wait) process) nil) + (error (condition) + (values nil condition)))) ;;; External process execution helpers for pipeline stages. @@ -69,17 +71,71 @@ (t *standard-input*)) opened-input))) -(defun %start-external-process-copiers (process stdout-buffer stderr-buffer) +(defstruct (%external-process-stage-plan + (:constructor %make-external-process-stage-plan + (command args input-target redirect-plan preserve-fds)) + (:conc-name %external-process-stage-plan-)) + command + args + input-target + redirect-plan + preserve-fds) + +(defun %external-process-stage-plan-from + (context command-node redirects process-substitution-resources) + (let* ((command (nshell.domain.parsing:command-node-command command-node)) + (args (%line-command-args command-node)) + (wrapper-p + (and command + (nshell.domain.parsing:redirects-require-shell-wrapper-p + redirects))) + (input-target + (unless wrapper-p + (nshell.domain.parsing:redirect-input-file-target redirects))) + (source-redirect-plan (%external-process-redirect-plan-from redirects)) + (redirect-plan + (if wrapper-p + (%make-external-process-redirect-plan + nil + :supersede + nil + :supersede + :stdout + :stderr + (%external-process-redirect-plan-merge-stderr-p + source-redirect-plan)) + source-redirect-plan)) + (effective-command (if wrapper-p "sh" command)) + (effective-args + (if wrapper-p + (list* "-c" + (nshell.domain.parsing:shell-redirect-script redirects) + "nshell-fd-wrapper" + command + args) + args)) + (preserve-fds + (%process-substitution-resource-fds + context + process-substitution-resources))) + (%make-external-process-stage-plan effective-command + effective-args + input-target + redirect-plan + preserve-fds))) + +(defun %start-external-process-copiers + (context process stdout-buffer stderr-buffer) (let ((stdout-thread - (nshell.infrastructure.acl::%start-stream-copier - (sb-ext:process-output process) + (funcall (%process-fn context :start-stream-copier) + (funcall (%process-fn context :process-output) process) stdout-buffer "nshell process stdout copier")) (stderr-thread nil)) (when stderr-buffer (setf stderr-thread - (nshell.infrastructure.acl::%start-stream-copier - (sb-ext:process-error process) + (funcall (%process-fn context :start-stream-copier) + (funcall (%process-fn context :process-error) process) stderr-buffer "nshell process stderr copier"))) (remove nil (list stdout-thread stderr-thread)))) @@ -142,109 +198,115 @@ nil) (values returned-output exit-code))))) +(defun %wait-for-external-process + (context process stdout-buffer stderr-buffer redirect-plan command) + (let ((timeout-seconds + (funcall (%process-fn context :external-command-timeout)))) + (unwind-protect + (funcall + (%process-fn context :wait-process-with-copiers) + process + (%start-external-process-copiers + context + process + stdout-buffer + stderr-buffer) + timeout-seconds + (lambda () + (%finish-external-process-output + stdout-buffer + stderr-buffer + redirect-plan + (funcall (%process-fn context :process-exit-status-code) + process))) + (lambda () + (format *error-output* + "nshell: ~a: timed out after ~a seconds~%" + command + timeout-seconds) + (%finish-external-process-output + stdout-buffer + stderr-buffer + redirect-plan + 124))) + (when (and process + (funcall (%process-fn context :process-alive-p) process)) + (%wait-external-process-for-cleanup context process))))) + +(defmacro %with-process-substitution-lifecycle + ((context resources process-started) &body body) + `(unwind-protect + (progn ,@body) + (if ,process-started + (%finish-process-substitution-resources ,context ,resources) + (%abort-process-substitution-resources ,context ,resources)))) + +(defun %execute-external-process + (context stage-plan stdin command &optional process-substitution-resources) + (let ((process-started nil)) + (%with-process-substitution-lifecycle + (context process-substitution-resources process-started) + (let* ((stdout-buffer (make-string-output-stream)) + ;; Asked once and reused: 2>&1 decides both whether a separate + ;; stderr buffer exists and what :error is. + (redirect-plan + (%external-process-stage-plan-redirect-plan stage-plan)) + (merge-stderr-p + (%external-process-redirect-plan-merge-stderr-p + redirect-plan)) + (stderr-buffer + (unless merge-stderr-p + (make-string-output-stream))) + (process + (funcall (%process-fn context :run-program) + (%external-process-stage-plan-command stage-plan) + (%external-process-stage-plan-args stage-plan) + :input stdin + :output :stream + :error (if merge-stderr-p :output :stream) + :wait nil + :search t + :preserve-fds + (%external-process-stage-plan-preserve-fds stage-plan)))) + (setf process-started t) + (%release-process-substitution-resources + context + process-substitution-resources) + (%wait-for-external-process + context + process + stdout-buffer + stderr-buffer + redirect-plan + command))))) + (defun %execute-external-pipeline-stage - (command-node input redirects &optional process-substitution-resources) + (context command-node input redirects &optional process-substitution-resources) "Execute COMMAND-NODE as an external process with optional INPUT string. Applies REDIRECTS and returns (output exit-code)." (let* ((command (nshell.domain.parsing:command-node-command command-node)) - (args (%line-command-args command-node)) - (wrapper-p - (and command - (nshell.domain.parsing:redirects-require-shell-wrapper-p - redirects))) - (input-target - (unless wrapper-p - (nshell.domain.parsing:redirect-input-file-target redirects))) - (source-redirect-plan (%external-process-redirect-plan-from redirects)) - ;; The child wrapper applies file redirects itself. Keep only the - ;; stream topology needed to route data that remains on the parent's - ;; captured stdout/stderr pipes. - (redirect-plan - (if wrapper-p - (%make-external-process-redirect-plan - nil - :supersede - nil - :supersede - :stdout - :stderr - (%external-process-redirect-plan-merge-stderr-p - source-redirect-plan)) - source-redirect-plan)) - (effective-command (if wrapper-p "sh" command)) - (effective-args - (if wrapper-p - (list* "-c" - (nshell.domain.parsing:shell-redirect-script redirects) - "nshell-fd-wrapper" - command - args) - args)) - (preserve-fds - (%process-substitution-resource-fds - process-substitution-resources)) - (process-started nil)) + (stage-plan + (%external-process-stage-plan-from + context + command-node + redirects + process-substitution-resources))) (handler-case (multiple-value-bind (stdin opened-input) - (%external-process-input-stream input-target input) + (%external-process-input-stream + (%external-process-stage-plan-input-target stage-plan) + input) (unwind-protect - (unwind-protect - (let* ((stdout-buffer (make-string-output-stream)) - ;; Asked once and reused: 2>&1 decides both whether a - ;; separate stderr buffer exists and what :error is. - (merge-stderr-p - (%external-process-redirect-plan-merge-stderr-p - redirect-plan)) - (stderr-buffer - (unless merge-stderr-p - (make-string-output-stream))) - (process (sb-ext:run-program - effective-command - effective-args - :input stdin - :output :stream - :error (if merge-stderr-p - :output - :stream) - :wait nil - :search t - :preserve-fds preserve-fds))) - (setf process-started t) - (%release-process-substitution-resources - process-substitution-resources) - (unwind-protect - (nshell.infrastructure.acl::%wait-process-with-copiers - process - (%start-external-process-copiers process - stdout-buffer - stderr-buffer) - nshell.infrastructure.acl:*external-command-timeout* - (lambda () - (%finish-external-process-output - stdout-buffer - stderr-buffer - redirect-plan - (nshell.infrastructure.acl:process-exit-status-code - process))) - (lambda () - (format *error-output* - "nshell: ~a: timed out after ~a seconds~%" - command - nshell.infrastructure.acl:*external-command-timeout*) - (%finish-external-process-output - stdout-buffer - stderr-buffer - redirect-plan - 124))) - (when (and process (sb-ext:process-alive-p process)) - (ignore-errors (sb-ext:process-wait process))))) - (when opened-input - (close opened-input))) - (if process-started - (%finish-process-substitution-resources - process-substitution-resources) - (%abort-process-substitution-resources - process-substitution-resources)))) + (%execute-external-process + context + stage-plan + stdin + command + process-substitution-resources) + (when opened-input + (close opened-input)))) (error (condition) - (%abort-process-substitution-resources process-substitution-resources) + (%abort-process-substitution-resources + context + process-substitution-resources) (values (format nil "nshell: ~a: ~a~%" command condition) 127))))) diff --git a/src/application/execute-pipeline-stage.lisp b/src/application/execute-pipeline-stage.lisp index 764f5ad..2f709c5 100644 --- a/src/application/execute-pipeline-stage.lisp +++ b/src/application/execute-pipeline-stage.lisp @@ -1,9 +1,10 @@ (in-package #:nshell.application) -(declaim (notinline nshell.infrastructure.acl:process-substitution-resource-path - nshell.infrastructure.acl:process-substitution-resource-fd)) - -(declaim (special nshell.infrastructure.acl:*external-command-timeout*)) +(defun %call-process-cleanup (function &rest arguments) + (handler-case + (values (apply function arguments) nil) + (error (condition) + (values nil condition)))) ;;; Pipeline stage execution and command expansion. ;;; execute-ast-in-context (defined in execute-pipeline-control.lisp) is @@ -35,25 +36,31 @@ (t nil))) -(defun %release-process-substitution-resources (resources) +(defun %release-process-substitution-resources (context resources) (dolist (resource resources) - (ignore-errors - (nshell.infrastructure.acl:release-process-substitution-fd resource)))) + (%call-process-cleanup + (%process-fn context :release-process-substitution-fd) + resource))) -(defun %finish-process-substitution-resources (resources) +(defun %finish-process-substitution-resources (context resources) (dolist (resource resources) - (ignore-errors - (nshell.infrastructure.acl:wait-process-substitution resource)) - (ignore-errors - (nshell.infrastructure.acl:release-process-substitution-fd resource)))) + (%call-process-cleanup + (%process-fn context :wait-process-substitution) + resource) + (%call-process-cleanup + (%process-fn context :release-process-substitution-fd) + resource))) -(defun %abort-process-substitution-resources (resources) +(defun %abort-process-substitution-resources (context resources) (dolist (resource resources) - (ignore-errors - (nshell.infrastructure.acl:close-process-substitution resource)))) + (%call-process-cleanup + (%process-fn context :close-process-substitution) + resource))) -(defun %process-substitution-resource-fds (resources) - (mapcar #'nshell.infrastructure.acl:process-substitution-resource-fd +(defun %process-substitution-resource-fds (context resources) + (mapcar (lambda (resource) + (funcall (%process-fn context :process-substitution-resource-fd) + resource)) resources)) (defun %materialize-process-substitution-in-context (context value) @@ -82,7 +89,7 @@ context commands) (when nested-resources - (%abort-process-substitution-resources nested-resources) + (%abort-process-substitution-resources context nested-resources) (return-from %materialize-process-substitution-in-context (values @@ -109,13 +116,18 @@ (nshell.domain.parsing:command-list-redirect-split-result-redirects redirect-split))) (handler-case (let ((resource - (nshell.infrastructure.acl:spawn-process-substitution - direction - clean-commands - :redirects - redirects))) + (funcall (%process-fn + context + :spawn-process-substitution) + direction + clean-commands + :redirects + redirects))) (values - (nshell.infrastructure.acl:process-substitution-resource-path + (funcall + (%process-fn + context + :process-substitution-resource-path) resource) resource nil)) @@ -145,7 +157,7 @@ (multiple-value-bind (path resource error) (%materialize-process-substitution-in-context context value) (when error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (return-from %expand-command-args-in-context (values nil nil error))) (setf args @@ -163,7 +175,7 @@ (not (null (member value '("<<" "<<-") :test #'string=)))))) (values args (nreverse resources) nil)) (nshell.domain.expansion:parameter-expansion-error (condition) - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values nil nil (format nil "nshell: ~a~%" condition)))))) @@ -203,7 +215,7 @@ command name expands to zero or multiple fields (ambiguous)." (%expand-command-node-in-context context command) (if error (progn - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (return-from %expand-command-nodes-in-context (values nil error nil))) (progn @@ -243,7 +255,7 @@ command name expands to zero or multiple fields (ambiguous)." (with-input-from-string (*standard-input* input) (%execute-clean-command-node-in-context context command-node redirects)) (%execute-clean-command-node-in-context context command-node redirects)) - (%execute-external-pipeline-stage command-node input redirects))) + (%execute-external-pipeline-stage context command-node input redirects))) (defun %execute-source-pipeline-in-context (context commands redirects) (let ((input nil) @@ -290,7 +302,7 @@ command name expands to zero or multiple fields (ambiguous)." (%restore-context-redirects context))))) (defun %execute-os-pipeline-with-process-substitutions - (clean-commands redirects resources &optional (pipefail-p nil)) + (context clean-commands redirects resources &optional (pipefail-p nil)) (let ((spawned-p nil)) (unwind-protect (handler-case @@ -299,16 +311,17 @@ command name expands to zero or multiple fields (ambiguous)." (values (with-output-to-string (*standard-output*) (multiple-value-bind (status statuses) - (nshell.infrastructure.acl:spawn-pipeline + (funcall (%process-fn context :spawn-pipeline) clean-commands :redirects redirects :pipefail-p pipefail-p :preserve-fds - (%process-substitution-resource-fds resources) + (%process-substitution-resource-fds context resources) :after-spawn (lambda () (setf spawned-p t) (%release-process-substitution-resources + context resources))) (setf exit-code (or status 0) pipeline-statuses (or statuses @@ -318,14 +331,14 @@ command name expands to zero or multiple fields (ambiguous)." (error (condition) (values (format nil "nshell: ~a~%" condition) 127 (list 127)))) (if spawned-p - (%finish-process-substitution-resources resources) - (%abort-process-substitution-resources resources))))) + (%finish-process-substitution-resources context resources) + (%abort-process-substitution-resources context resources))))) (defun execute-command-node-in-context (context command-node) (multiple-value-bind (expanded error resources) (%expand-command-node-in-context context command-node) (when error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (return-from execute-command-node-in-context (values error 127))) (let* ((redirect-split (%extract-command-redirects expanded)) (clean-command @@ -337,18 +350,25 @@ command name expands to zero or multiple fields (ambiguous)." (if resources (if (%shell-internal-command-p context clean-command) (progn - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values (%process-substitution-error "requires an external command") 127)) (%execute-external-pipeline-stage - clean-command nil redirects resources)) + context + clean-command + nil + redirects + resources)) (if (and (not (%shell-internal-command-p context clean-command)) (nshell.domain.parsing:redirects-require-shell-wrapper-p redirects)) (%execute-external-pipeline-stage - clean-command nil redirects) + context + clean-command + nil + redirects) (%execute-clean-command-node-in-context context clean-command redirects)))))) @@ -359,7 +379,7 @@ command name expands to zero or multiple fields (ambiguous)." (multiple-value-bind (expanded-commands error resources) (%expand-command-nodes-in-context context commands) (when error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (return-from execute-pipeline-node-in-context (values error 127))) (let* ((redirect-split (%extract-pipeline-redirects expanded-commands)) (clean-commands @@ -373,14 +393,15 @@ command name expands to zero or multiple fields (ambiguous)." (some (lambda (cmd) (%shell-internal-command-p context cmd)) clean-commands)) (progn - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (values (%process-substitution-error "is not supported for internal or CPS pipelines") 127)) (multiple-value-bind (output exit-code statuses) - (%execute-os-pipeline-with-process-substitutions - clean-commands redirects resources + (%execute-os-pipeline-with-process-substitutions + context + clean-commands redirects resources (shell-context-pipefail-p context)) (%record-pipeline-statuses context statuses) (values output exit-code))) @@ -396,7 +417,7 @@ command name expands to zero or multiple fields (ambiguous)." (setf output (with-output-to-string (*standard-output*) (multiple-value-bind (status statuses) - (nshell.infrastructure.acl:spawn-pipeline + (funcall (%process-fn context :spawn-pipeline) clean-commands :redirects redirects :pipefail-p @@ -409,16 +430,16 @@ command name expands to zero or multiple fields (ambiguous)." ;; -- Public pipeline API (OS-level) ------------------------------------------- -(defun execute-pipeline (pipeline-ast) +(defun execute-pipeline (pipeline-ast process-fns) "Execute a pipeline AST using OS-level pipes. Returns the last process exit code." (let ((commands (if (nshell.domain.parsing:pipeline-node-p pipeline-ast) (nshell.domain.parsing:pipeline-node-commands pipeline-ast) (list pipeline-ast)))) - (let ((context (%make-pipeline-shell-context))) + (let ((context (%make-pipeline-shell-context process-fns))) (multiple-value-bind (expanded-commands error resources) (%expand-command-nodes-in-context context commands) (when error - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (write-string error *error-output*) (return-from execute-pipeline 127)) (let* ((redirect-split (%extract-pipeline-redirects expanded-commands)) @@ -432,7 +453,7 @@ command name expands to zero or multiple fields (ambiguous)." (some (lambda (cmd) (%shell-internal-command-p context cmd)) clean-commands)) (progn - (%abort-process-substitution-resources resources) + (%abort-process-substitution-resources context resources) (write-string (%process-substitution-error "is not supported for internal commands") @@ -441,19 +462,20 @@ command name expands to zero or multiple fields (ambiguous)." (if resources (nth-value 1 - (%execute-os-pipeline-with-process-substitutions - clean-commands redirects resources + (%execute-os-pipeline-with-process-substitutions + context + clean-commands redirects resources (shell-context-pipefail-p context))) - (nshell.infrastructure.acl:spawn-pipeline + (funcall (%process-fn context :spawn-pipeline) clean-commands :redirects redirects :pipefail-p (shell-context-pipefail-p context))))))))) -(defun execute-pipeline-use-case (pipeline dispatcher) +(defun execute-pipeline-use-case (pipeline dispatcher process-fns) (when dispatcher (publish-event dispatcher (nshell.domain.events:make-pipeline-started-event pipeline nil))) - (let ((exit-code (or (execute-pipeline pipeline) 0))) + (let ((exit-code (or (execute-pipeline pipeline process-fns) 0))) (when dispatcher (publish-event dispatcher (nshell.domain.events:make-pipeline-completed-event pipeline exit-code))) diff --git a/src/application/manage-job.lisp b/src/application/manage-job.lisp index ddbe45c..ea71de0 100644 --- a/src/application/manage-job.lisp +++ b/src/application/manage-job.lisp @@ -1,10 +1,8 @@ (in-package #:nshell.application) -(eval-when (:compile-toplevel :load-toplevel :execute) - (require :sb-posix)) - (defvar *job-monitor* (nshell.domain.job-control:make-job-monitor)) -(defvar *shell-pgid* (sb-posix:getpid)) +(defvar *job-process-fns* nil) +(defvar *shell-pgid* 0) (defvar *foreground-job-pgid* nil) (define-value-struct job-listing @@ -27,51 +25,95 @@ state status-code) +(defun %optional-job-process-fn (key) + (getf *job-process-fns* key)) + +(defun %job-process-fn (key) + (or (%optional-job-process-fn key) + (error "Missing job process capability ~s" key))) + +(defun %call-optional-job-process-fn (fn &rest args) + "Call optional process capability FN with ARGS. +Returns the capability result as the primary value and any failure condition +as the secondary value, so unsupported terminal operations remain observable +without aborting job-control cleanup." + (handler-case + (values (apply fn args) nil) + (error (condition) + (values nil condition)))) + (defun %set-acl-foreground-pgid (pgid) - (let ((symbol (find-symbol "*FOREGROUND-PGID*" "NSHELL.INFRASTRUCTURE.ACL"))) - (when symbol - (setf (symbol-value symbol) (or pgid 0)))) + (let ((fn (%optional-job-process-fn :set-foreground-pgid-state))) + (when fn + (funcall fn pgid))) (values)) (defun %continue-process-group (pgid) - (sb-posix:kill (- pgid) sb-unix:sigcont)) + (let ((fn (%optional-job-process-fn :continue-process-group))) + (when fn + (funcall fn pgid)))) + +(defun %get-foreground-pgroup () + (let ((fn (%optional-job-process-fn :get-foreground-pgroup))) + (when fn + (%call-optional-job-process-fn fn)))) + +(defun %set-foreground-pgroup (pgid) + (let ((fn (%optional-job-process-fn :set-foreground-pgroup))) + (when fn + (%call-optional-job-process-fn fn pgid)))) + +(defun %kill-process (pid signal) + (let ((fn (%optional-job-process-fn :kill-process))) + (when fn + (funcall fn pid signal)))) + +(defun %shell-process-group-id () + (or (and (integerp *shell-pgid*) + (plusp *shell-pgid*) + *shell-pgid*) + (let ((fn (%optional-job-process-fn :current-process-id))) + (and fn (%call-optional-job-process-fn fn))) + 0)) (defun fg (job-id &optional dispatcher process-registry terminal-fns - (job-monitor *job-monitor*)) + (job-monitor *job-monitor*) process-fns) "Move JOB-ID to the foreground, wait for it, then restore the shell PGID." (declare (ignore process-registry terminal-fns)) - (let ((job (%require-job job-id job-monitor))) - (when job - (let ((pgid (nshell.domain.execution:job-control-pgid job))) - (when pgid - (setf *foreground-job-pgid* pgid) - (unwind-protect - (progn - (%set-acl-foreground-pgid pgid) - (%continue-process-group pgid) - (nshell.domain.job-control:foreground-job job-monitor job-id) - (when dispatcher - (publish-event dispatcher - (nshell.domain.events:make-job-continued-event job-id))) - (%with-terminal-foreground-pgroup - pgid - (lambda () (%wait-job-pgid job job-id job-monitor)))) - (setf *foreground-job-pgid* nil) - (%set-acl-foreground-pgid nil))) - job)))) + (let ((*job-process-fns* (or process-fns *job-process-fns*))) + (let ((job (%require-job job-id job-monitor))) + (when job + (let ((pgid (nshell.domain.execution:job-control-pgid job))) + (when pgid + (setf *foreground-job-pgid* pgid) + (unwind-protect + (progn + (%set-acl-foreground-pgid pgid) + (%continue-process-group pgid) + (nshell.domain.job-control:foreground-job job-monitor job-id) + (when dispatcher + (publish-event dispatcher + (nshell.domain.events:make-job-continued-event job-id))) + (%with-terminal-foreground-pgroup + pgid + (lambda () (%wait-job-pgid job job-id job-monitor)))) + (setf *foreground-job-pgid* nil) + (%set-acl-foreground-pgid nil))) + job))))) -(defun bg (job-id &optional dispatcher (job-monitor *job-monitor*)) +(defun bg (job-id &optional dispatcher (job-monitor *job-monitor*) process-fns) "Continue JOB-ID in the background." - (let ((job (%require-job job-id job-monitor))) - (when job - (let ((pgid (nshell.domain.execution:job-control-pgid job))) - (when pgid - (%continue-process-group pgid)) - (nshell.domain.job-control:background-job job-monitor job-id) - (when dispatcher - (publish-event dispatcher - (nshell.domain.events:make-job-continued-event job-id))) - job)))) + (let ((*job-process-fns* (or process-fns *job-process-fns*))) + (let ((job (%require-job job-id job-monitor))) + (when job + (let ((pgid (nshell.domain.execution:job-control-pgid job))) + (when pgid + (%continue-process-group pgid)) + (nshell.domain.job-control:background-job job-monitor job-id) + (when dispatcher + (publish-event dispatcher + (nshell.domain.events:make-job-continued-event job-id))) + job))))) (defun jobs (&optional (job-monitor *job-monitor*)) "Return current job listings without writing to the terminal." @@ -98,10 +140,10 @@ (defun %foreground-signal-target-pgid () (let ((pgid (or *foreground-job-pgid* - (ignore-errors (nshell.infrastructure.acl:get-foreground-pgroup))))) + (%get-foreground-pgroup)))) (when (and pgid (nshell.domain.execution:valid-process-group-id-p pgid) - (/= pgid *shell-pgid*)) + (/= pgid (%shell-process-group-id))) pgid))) (defun %job-command-string (job) @@ -117,13 +159,12 @@ (otherwise "Unknown"))) (defun %with-terminal-foreground-pgroup (pgid thunk) - (let ((previous (ignore-errors (nshell.infrastructure.acl:get-foreground-pgroup)))) + (let ((previous (%get-foreground-pgroup))) (unwind-protect (progn - (ignore-errors (nshell.infrastructure.acl:set-foreground-pgroup pgid)) + (%set-foreground-pgroup pgid) (funcall thunk)) - (ignore-errors - (nshell.infrastructure.acl:set-foreground-pgroup (or previous *shell-pgid*)))))) + (%set-foreground-pgroup (or previous (%shell-process-group-id)))))) (defun %classify-job-wait-status (pid status &key stopped-p exited-p exit-status @@ -141,30 +182,22 @@ (defun %classify-job-wait-error (errno) (cond - ((= errno sb-posix:echild) + ((member errno '(:echild :no-child)) (%make-job-wait-event nil :no-child nil)) - ((= errno sb-posix:eintr) + ((member errno '(:eintr :interrupted)) (%make-job-wait-event nil :interrupted nil)) (t nil))) (defun %wait-job-pgid-event (pgid) - (handler-case - (multiple-value-bind (pid status) - (sb-posix:waitpid (- pgid) sb-posix:wuntraced) - (%classify-job-wait-status - pid status - :stopped-p #'sb-posix:wifstopped - :exited-p #'sb-posix:wifexited - :exit-status #'sb-posix:wexitstatus - :signaled-p #'sb-posix:wifsignaled - :term-signal #'sb-posix:wtermsig)) - (sb-posix:syscall-error (condition) - (let ((event - (%classify-job-wait-error - (sb-posix:syscall-errno condition)))) - (if event - event - (error condition)))))) + (multiple-value-bind (pid state detail) + (funcall (%job-process-fn :wait-job) (- pgid) :untraced t) + (case state + (:stopped (%make-job-wait-event pid :stopped nil)) + (:exited (%make-job-wait-event pid :exited detail)) + (:signaled (%make-job-wait-event pid :signaled (+ 128 detail))) + (:no-child (%make-job-wait-event nil :no-child nil)) + (:continued (%make-job-wait-event pid :continued nil)) + (otherwise (%make-job-wait-event pid :unknown nil))))) (defun %wait-job-pgid (job job-id job-monitor) (let* ((pgid (nshell.domain.execution:job-control-pgid job)) @@ -212,79 +245,77 @@ (defun %require-job (job-id &optional (job-monitor *job-monitor*)) (nshell.domain.job-control:monitor-find-job job-monitor job-id)) -(defun signal-job (job-id signal &optional (job-monitor *job-monitor*)) +(defun signal-job (job-id signal &optional (job-monitor *job-monitor*) process-fns) "Send SIGNAL to JOB-ID and synchronize the monitor after a successful signal." - (labels ((stop-signal-p (candidate) - (or (member candidate (list :sigstop :sigtstp)) - (and (integerp candidate) - (member candidate - (list sb-unix:sigstop sb-unix:sigtstp))))) - (continue-signal-p (candidate) - (or (eq candidate :sigcont) - (and (integerp candidate) - (= candidate sb-unix:sigcont)))) - (record-signal-state () - (let ((current-job - (nshell.domain.job-control:monitor-find-job - job-monitor job-id))) - (when (and current-job - (not (nshell.domain.execution:job-completed-p - current-job))) - (cond - ((stop-signal-p signal) - (nshell.domain.job-control:suspend-job - job-monitor job-id nil)) - ((and (continue-signal-p signal) - (nshell.domain.execution:job-stopped-p current-job)) - (nshell.domain.job-control:background-job - job-monitor job-id))))))) - (let ((job (%require-job job-id job-monitor))) - (when job - (let ((pgid (nshell.domain.execution:job-control-pgid job)) - (pids (nshell.domain.execution:job-known-pids job)) - (signaled-p nil)) - (if pgid - (progn - (nshell.infrastructure.acl:kill-process (- pgid) signal) - (setf signaled-p t)) - (dolist (pid pids) - (nshell.infrastructure.acl:kill-process pid signal) - (setf signaled-p t))) - (when signaled-p - (record-signal-state)) - job))))) + (let ((*job-process-fns* (or process-fns *job-process-fns*))) + (labels ((stop-signal-p (candidate) + (member candidate '(:sigstop :sigtstp))) + (continue-signal-p (candidate) + (eq candidate :sigcont)) + (record-signal-state () + (let ((current-job + (nshell.domain.job-control:monitor-find-job + job-monitor job-id))) + (when (and current-job + (not (nshell.domain.execution:job-completed-p + current-job))) + (cond + ((stop-signal-p signal) + (nshell.domain.job-control:suspend-job + job-monitor job-id nil)) + ((and (continue-signal-p signal) + (nshell.domain.execution:job-stopped-p current-job)) + (nshell.domain.job-control:background-job + job-monitor job-id))))))) + (let ((job (%require-job job-id job-monitor))) + (when job + (let ((pgid (nshell.domain.execution:job-control-pgid job)) + (pids (nshell.domain.execution:job-known-pids job)) + (signaled-p nil)) + (if pgid + (progn + (%kill-process (- pgid) signal) + (setf signaled-p t)) + (dolist (pid pids) + (%kill-process pid signal) + (setf signaled-p t))) + (when signaled-p + (record-signal-state)) + job)))))) (defun wait-for-job (job-id process-registry - &optional (job-monitor *job-monitor*)) + &optional (job-monitor *job-monitor*) process-fns) "Wait for JOB-ID using the registered SBCL process objects. The process registry is shared with the background reaper. Waiting through the same process objects avoids a second waitpid consumer and preserves the exit status when the reaper has already completed the domain job." - (let ((job (%require-job job-id job-monitor))) - (cond - ((null job) - (values nil nil)) - ((nshell.domain.execution:job-completed-p job) - (remhash job-id process-registry) - (values job - (or (nshell.domain.execution:job-exit-code job) - 0))) - (t - (let ((processes (%job-process-list (gethash job-id process-registry)))) - (if (null processes) - (values nil nil) - (progn - (%wait-job-processes processes) - (let ((exit-code (%job-process-exit-code job processes))) - (nshell.domain.job-control:complete-job - job-monitor job-id exit-code) - (remhash job-id process-registry) - (values job exit-code))))))))) + (let ((*job-process-fns* (or process-fns *job-process-fns*))) + (let ((job (%require-job job-id job-monitor))) + (cond + ((null job) + (values nil nil)) + ((nshell.domain.execution:job-completed-p job) + (remhash job-id process-registry) + (values job + (or (nshell.domain.execution:job-exit-code job) + 0))) + (t + (let ((processes (%job-process-list (gethash job-id process-registry)))) + (if (null processes) + (values nil nil) + (progn + (%wait-job-processes processes) + (let ((exit-code (%job-process-exit-code job processes))) + (nshell.domain.job-control:complete-job + job-monitor job-id exit-code) + (remhash job-id process-registry) + (values job exit-code)))))))))) (defun %job-process-exit-code (job processes) (let ((statuses (mapcar (lambda (process) - (nshell.infrastructure.acl:process-exit-status-code process)) + (let ((fn (%optional-job-process-fn :process-exit-status-code))) + (and fn (funcall fn process)))) (remove nil processes)))) (if (nshell.domain.execution:job-pipefail-p job) (or (find-if (lambda (status) @@ -295,9 +326,10 @@ exit status when the reaper has already completed the domain job." (nshell.domain.execution:job-exit-code job) 0)))) (defun %wait-job-processes (processes) - (dolist (process processes) - (when process - (sb-ext:process-wait process))) + (let ((fn (%optional-job-process-fn :process-wait))) + (dolist (process processes) + (when (and fn process) + (funcall fn process)))) processes) (defun %job-process-list (entry) (cond diff --git a/src/domain/expansion/fields.lisp b/src/domain/expansion/fields.lisp index 395dc21..63d65ae 100644 --- a/src/domain/expansion/fields.lisp +++ b/src/domain/expansion/fields.lisp @@ -96,11 +96,11 @@ returning the (possibly multiple) resulting fields." "Dispatch on STYLE and evaluate the matching form." (let ((style-var (gensym "STYLE"))) `(let ((,style-var ,style)) - (case ,style-var - ((nil) ,unquoted-form) - (:single ,single-form) - (:double ,double-form) - (t (error "Invalid quote style ~S" ,style-var)))))) + (case ,style-var + ((nil) ,unquoted-form) + (:single ,single-form) + (:double ,double-form) + (t (error "Invalid quote style ~S" ,style-var)))))) (defun expand-double-quoted (input env) "Expand INPUT as the contents of a double-quoted string. @@ -124,8 +124,8 @@ single string. Command substitution is applied by the caller before this." (boundaries nil :type list) (start nil :type (or null integer))) -(defun whitespace-field-separator-p (char) - (member char '(#\Space #\Tab #\Newline) :test #'char=)) +(defun whitespace-field-separator-p (character) + (member character '(#\Space #\Tab #\Newline) :test #'char=)) (defun whitespace-field-scanner-start-field (scanner index) (unless (whitespace-field-scanner-start scanner) @@ -200,13 +200,13 @@ single string. Command substitution is applied by the caller before this." (values nil (%command-name-candidate-error candidate (length fields)))))) -(defun %single-command-name-or-error (text fields) +(defun single-command-name-or-error (text fields) "Return one command name from FIELDS or the canonical ambiguity error." (%resolve-command-name-candidate (%make-command-name-candidate text fields))) (defun expand-command-name-by-quote-style (text style env) "Expand a command name and return either one field or an ambiguity error." - (%single-command-name-or-error + (single-command-name-or-error text (expand-command-name-fields-by-quote-style text style env))) diff --git a/src/domain/parsing/tokenizer-data.lisp b/src/domain/parsing/tokenizer-data.lisp index 661768d..d89c65d 100644 --- a/src/domain/parsing/tokenizer-data.lisp +++ b/src/domain/parsing/tokenizer-data.lisp @@ -184,7 +184,10 @@ fragments) (setf (tokenizer-state-pos state) (%token-extent-end extent)))) -(defun %balanced-substitution-end (input start) +(defun balanced-substitution-end (input start) + "Return the closing-parenthesis index for the substitution at START. + +Quotes and escaped characters do not affect the parenthesis depth." (let ((depth 0) (quote nil) (escaped nil)) @@ -208,4 +211,4 @@ (return index))))))) (defun %tokenizer-balanced-substitution-end (state start) - (%balanced-substitution-end (tokenizer-state-input state) start)) + (balanced-substitution-end (tokenizer-state-input state) start)) diff --git a/src/presentation/highlight.lisp b/src/highlight.lisp similarity index 80% rename from src/presentation/highlight.lisp rename to src/highlight.lisp index 5edd6fc..05f2e9d 100644 --- a/src/presentation/highlight.lisp +++ b/src/highlight.lisp @@ -1,23 +1,15 @@ -(in-package #:nshell.presentation) +(in-package #:nshell.highlight) -;; Highlight span value object; literal data tables live under data/. (define-value-struct %highlight-span ((start 0 :type integer) (end 0 :type integer) (role :normal :type keyword))) -;; -- Highlight roles (fish-inspired) ---------------------- (defun builtin-command-p (name) - (find name *builtin-commands* :test #'string=)) + (find name +highlight-builtin-names+ :test #'string=)) (defun classify-token-role (token-type token-value is-first-word) - "Map a token to its highlight role. Follows fish shell conventions: - - first word: command (blue) or builtin (bright blue) - - subsequent words: argument (normal/cyan for options) - - pipes/redirects: operator (yellow) - - strings/quoted: quote (orange) - - errors: error (red) - - comments: comment (gray)" + "Map a token to its syntax-highlight role." (case token-type (:word (cond @@ -64,14 +56,12 @@ role))) tokens))) -;; Rendering helpers for highlight spans. (defun fallback-highlight-control (role) (or (cdr (assoc role +fallback-highlight-ansi+ :test #'eq)) "~C[0m")) (defun theme-color->ansi (theme role) - "Convert a highlight ROLE to ANSI escape using THEME colors. - Falls back to known ANSI 16-color codes when theme lookup fails." + "Convert a highlight ROLE to ANSI escape using THEME colors." (let ((color (nshell.domain.configuration:theme-color theme role))) (if color (let ((code (nshell.infrastructure.terminal:ansi-color-code color))) @@ -83,16 +73,13 @@ (let ((result (make-string-output-stream)) (pos 0)) (dolist (span spans) - ;; Output unhighlighted gap (when (> (highlight-span-start span) pos) (write-string (subseq input pos (highlight-span-start span)) result)) - ;; Output highlighted span with color (format result "~a" (theme-color->ansi theme (highlight-span-role span))) (write-string (subseq input (highlight-span-start span) (highlight-span-end span)) result) (nshell.infrastructure.terminal:ansi-reset-style result) (setf pos (highlight-span-end span))) - ;; Output remaining text (when (< pos (length input)) (write-string (subseq input pos) result)) (get-output-stream-string result))) diff --git a/src/infrastructure/acl/process-capabilities.lisp b/src/infrastructure/acl/process-capabilities.lisp new file mode 100644 index 0000000..3e256c0 --- /dev/null +++ b/src/infrastructure/acl/process-capabilities.lisp @@ -0,0 +1,104 @@ +(in-package #:nshell.infrastructure.acl) + +;;; The application receives this table at the composition boundary. Keeping +;;; the SBCL and ACL details here lets execution depend on operations, not on +;;; a particular process implementation. + +(defun make-process-fns () + "Return the concrete process capability table for a live nshell session." + (list + :spawn-pipeline + (lambda (&rest args) + (apply #'spawn-pipeline args)) + :spawn-pipeline-async + (lambda (&rest args) + (apply #'spawn-pipeline-async args)) + :spawn-process-substitution + (lambda (&rest args) + (apply #'spawn-process-substitution args)) + ;; NOTINLINE: these two are defstruct accessors, which SBCL inlines by + ;; default. An inlined call bypasses the function cell entirely, so a + ;; test's WITH-TEMPORARY-FUNCTION mock on the accessor's symbol would be + ;; silently ignored without this declaration. + :process-substitution-resource-path + (lambda (resource) + (locally (declare (notinline process-substitution-resource-path)) + (process-substitution-resource-path resource))) + :process-substitution-resource-fd + (lambda (resource) + (locally (declare (notinline process-substitution-resource-fd)) + (process-substitution-resource-fd resource))) + :release-process-substitution-fd + (lambda (resource) + (release-process-substitution-fd resource)) + :wait-process-substitution + (lambda (resource) + (wait-process-substitution resource)) + :close-process-substitution + (lambda (resource) + (close-process-substitution resource)) + :spawn-async + (lambda (&rest args) + (apply #'spawn-async args)) + :run-program + (lambda (program args &rest options) + (apply #'sb-ext:run-program program args options)) + :process-output + (lambda (process) + (sb-ext:process-output process)) + :process-error + (lambda (process) + (sb-ext:process-error process)) + :process-alive-p + (lambda (process) + (sb-ext:process-alive-p process)) + :process-wait + (lambda (process) + (sb-ext:process-wait process)) + :process-pid + (lambda (process) + (sb-ext:process-pid process)) + :start-stream-copier + (lambda (&rest args) + (apply #'start-stream-copier args)) + :wait-process-with-copiers + (lambda (&rest args) + (apply #'wait-process-with-copiers args)) + :process-exit-status-code + (lambda (process) + (process-exit-status-code process)) + :external-command-timeout + (lambda () + *external-command-timeout*) + :run-external + (lambda (&rest args) + (apply #'run-external args)) + :run-external-capture + (lambda (&rest args) + (apply #'run-external-capture args)) + :run-external-exec + (lambda (&rest args) + (apply #'run-external-exec args)) + :exit-process + (lambda (status) + (sb-ext:quit :unix-status status)) + :kill-process + (lambda (&rest args) + (apply #'kill-process args)) + :continue-process-group + (lambda (pgid) + (kill-process (- pgid) :sigcont)) + :get-foreground-pgroup + (lambda () + (get-foreground-pgroup)) + :set-foreground-pgroup + (lambda (pgid) + (set-foreground-pgroup pgid)) + :set-foreground-pgid-state + (lambda (pgid) + (setf *foreground-pgid* (or pgid 0))) + :wait-job + (lambda (&rest args) + (apply #'wait-job args)) + :current-process-id + #'sb-posix:getpid)) diff --git a/src/infrastructure/acl/syscall-foreign.lisp b/src/infrastructure/acl/syscall-foreign.lisp index 13dea03..64a7e37 100644 --- a/src/infrastructure/acl/syscall-foreign.lisp +++ b/src/infrastructure/acl/syscall-foreign.lisp @@ -12,11 +12,6 @@ (request sb-alien:unsigned-long) (arg sb-sys:system-area-pointer)) -(defconstant +tiocgwinsz+ - #+darwin #x40087468 - #+linux #x5413 - #-(or darwin linux) 0) - (defconstant +tiocswinsz+ #+darwin #x80087467 #+linux #x5414 diff --git a/src/infrastructure/acl/syscall-process.lisp b/src/infrastructure/acl/syscall-process.lisp index 18da492..76aed2f 100644 --- a/src/infrastructure/acl/syscall-process.lisp +++ b/src/infrastructure/acl/syscall-process.lisp @@ -4,6 +4,12 @@ (defparameter *external-command-timeout* 30 "Maximum seconds for synchronous external commands. NIL disables the timeout.") +(defun %process-operation (function &rest arguments) + (handler-case + (values (apply function arguments) nil) + (error (condition) + (values nil condition)))) + (defun process-exit-status-code (proc) "Return shell-compatible exit status for an SBCL process." (let ((code (sb-ext:process-exit-code proc))) @@ -26,26 +32,24 @@ exit code, or 128+signal when it was terminated by a signal." while (plusp count) do (write-string buffer output :end count)) (when (streamp output) - (ignore-errors - (finish-output output))))) + (%process-operation #'finish-output output)))) -(defun %start-stream-copier (input output name) +(defun start-stream-copier (input output name) + "Copy INPUT to OUTPUT asynchronously and return the copier thread." (when input (sb-thread:make-thread (lambda () - (ignore-errors - (%copy-process-output input output))) + (%process-operation #'%copy-process-output input output)) :name name))) (defun %start-process-output-copier (proc output) - (%start-stream-copier (and proc (sb-ext:process-output proc)) - output - "nshell process output copier")) + (start-stream-copier (and proc (sb-ext:process-output proc)) + output + "nshell process output copier")) (defun %join-stream-copier (thread) (when thread - (ignore-errors - (sb-thread:join-thread thread)))) + (%process-operation #'sb-thread:join-thread thread))) (defun %join-process-output-copiers (copiers) (dolist (copier copiers) @@ -62,7 +66,7 @@ exit code, or 128+signal when it was terminated by a signal." do (sleep (min sleep-seconds (max 0 remaining-seconds))) (setf sleep-seconds (min 0.01 (* 2 sleep-seconds)))) (unless (sb-ext:process-alive-p proc) - (ignore-errors (sb-ext:process-wait proc)) + (%process-operation #'sb-ext:process-wait proc) t))) (defun %terminate-process (proc) @@ -73,21 +77,23 @@ exit code, or 128+signal when it was terminated by a signal." ;; negative PID can target nshells own group and terminate its host. (actual-pgid (and (integerp pid) (plusp pid) - (ignore-errors (sb-posix:getpgid pid)))) + (nth-value 0 (%process-operation #'sb-posix:getpgid pid)))) (owns-process-group-p (and (integerp actual-pgid) (plusp actual-pgid) (= pid actual-pgid)))) (flet ((terminate (signal) (if owns-process-group-p - (ignore-errors (%send-process-group-signal pid signal)) + (%process-operation #'%send-process-group-signal pid signal) (when (sb-ext:process-alive-p proc) - (ignore-errors (sb-ext:process-kill proc signal)))))) + (%process-operation #'sb-ext:process-kill proc signal))))) (terminate sb-unix:sigterm) (%wait-process-exit-with-timeout proc 0.5) (terminate sb-unix:sigkill) - (ignore-errors (sb-ext:process-wait proc)))))) + (%process-operation #'sb-ext:process-wait proc))))) -(defun %wait-process-with-copiers (proc copiers timeout-seconds success-fn timeout-fn) +(defun wait-process-with-copiers + (proc copiers timeout-seconds success-fn timeout-fn) + "Wait for PROC while joining COPIERS, calling one of the result callbacks." (unwind-protect (if (or (null timeout-seconds) (%wait-process-exit-with-timeout proc timeout-seconds)) @@ -103,7 +109,7 @@ exit code, or 128+signal when it was terminated by a signal." (defun %wait-process-with-output (proc output timeout-seconds timeout-fn) (let ((copier (%start-process-output-copier proc output))) - (%wait-process-with-copiers + (wait-process-with-copiers proc (list copier) timeout-seconds @@ -127,9 +133,11 @@ exit code, or 128+signal when it was terminated by a signal." return (subseq entry (length prefix))))) (defun %executable-file-p (path) - (ignore-errors - (not (zerop (logand (sb-posix:stat-mode (sb-posix:stat path)) - #o111))))) + (handler-case + (not (zerop (logand (sb-posix:stat-mode (sb-posix:stat path)) + #o111))) + (error (condition) + (values nil condition)))) (defun %resolve-external-command (command &optional (environment (%get-environment))) (nshell.domain.completion::%first-command-path-candidate @@ -148,6 +156,19 @@ exit code, or 128+signal when it was terminated by a signal." (defun %spawn-external-command (resolved-cmd args environment &key input output (error nil error-supplied-p)) (sb-ext:run-program resolved-cmd args :input input :output output :error (if error-supplied-p error (if *redirected-stderr* *error-output* :output)) :wait nil :search nil :environment environment)) +(defun %open-and-register-process-stream (pathname register &rest open-options) + (let ((stream nil) + (registered-p nil)) + (unwind-protect + (progn + (setf stream (apply #'open pathname open-options)) + (funcall register stream) + (setf registered-p t) + stream) + (unless registered-p + (when stream + (%process-operation #'close stream)))))) + (defun %resolve-input-redirect (redirects register) "Return the standard-input stream REDIRECTS ask for, calling REGISTER on any stream opened here so the caller can close it afterwards. With no input @@ -156,7 +177,8 @@ redirection the process inherits *STANDARD-INPUT*." (nshell.domain.parsing:redirect-input-spec redirects) (flet ((track (stream) (funcall register stream) stream)) (case kind - (:< (track (open target :direction :input :if-does-not-exist :error))) + (:< (%open-and-register-process-stream + target register :direction :input :if-does-not-exist :error)) (:<<< (track (%here-string-stream target))) ((:<< :<<-) (track (%here-document-stream target))) (t *standard-input*))))) @@ -167,10 +189,9 @@ process's own stdout, calling REGISTER on any stream opened here." (multiple-value-bind (target mode) (nshell.domain.parsing:redirect-output-spec redirects) (if target - (let ((stream (open target :direction :output - :if-exists mode :if-does-not-exist :create))) - (funcall register stream) - stream) + (%open-and-register-process-stream + target register + :direction :output :if-exists mode :if-does-not-exist :create) t))) (defun %spawn-in-own-process-group (resolved-cmd args environment input output &key (error nil error-supplied-p)) @@ -183,7 +204,7 @@ process's own stdout, calling REGISTER on any stream opened here." (when proc (let ((pid (sb-ext:process-pid proc))) (when (plusp pid) - (ignore-errors (set-process-group pid pid)))) + (%process-operation #'set-process-group pid pid))) proc))) (defun spawn-async (cmd args &key redirects) @@ -203,7 +224,7 @@ process's own stdout, calling REGISTER on any stream opened here." (format *error-output* "nshell: ~a: ~a~%" cmd err) nil)) (dolist (stream redirect-streams) - (ignore-errors (close stream))))))) + (%process-operation #'close stream)))))) (defun %foreground-external-command-timeout () "Return the timeout to apply to a foreground external command's wait: NIL @@ -255,7 +276,7 @@ normal foreground timeout policy." (format *error-output* "exec: ~a: ~a~%" cmd err) 1))) - (defun run-external (cmd args) +(defun run-external (cmd args) "Execute CMD with ARGS synchronously, printing output. Returns exit code." (handler-case (multiple-value-bind (resolved-cmd environment) @@ -264,8 +285,8 @@ normal foreground timeout policy." (%report-external-command-not-found cmd) (return-from run-external 127)) (let ((proc (%spawn-external-command resolved-cmd args environment - :input *standard-input* - :output :stream))) + :input *standard-input* + :output :stream))) (if proc (let* ((pid (sb-ext:process-pid proc)) (pgid (and (integerp pid) (plusp pid) pid)) diff --git a/src/infrastructure/acl/syscall-redirection.lisp b/src/infrastructure/acl/syscall-redirection.lisp index 4a4cab9..f45354f 100644 --- a/src/infrastructure/acl/syscall-redirection.lisp +++ b/src/infrastructure/acl/syscall-redirection.lisp @@ -28,7 +28,23 @@ (defun %close-owned-redirect-stream (stream) (when (and stream (streamp stream)) - (ignore-errors (close stream)))) + (handler-case + (values (close stream) nil) + (error (condition) + (values nil condition))))) + +(defun %install-redirect-stream (filename installer &rest open-options) + (let ((stream nil) + (installed-p nil)) + (unwind-protect + (progn + (setf stream (apply #'open filename open-options)) + (funcall installer stream) + (setf installed-p t) + stream) + (unless installed-p + (when stream + (%close-owned-redirect-stream stream)))))) (defun %release-owned-stdout () (let ((stream *redirected-stdout-owned*)) @@ -36,51 +52,47 @@ (%close-owned-redirect-stream stream)) (setf *redirected-stdout-owned* nil))) -(defun %release-owned-stderr () - (let ((stream *redirected-stderr-owned*)) - (when (and stream (not (eq stream *redirected-stdout-owned*))) - (%close-owned-redirect-stream stream)) - (setf *redirected-stderr-owned* nil))) - -(defun %release-owned-stdin () - (%close-owned-redirect-stream *redirected-stdin-owned*) - (setf *redirected-stdin-owned* nil)) - (defun redirect-output (filename mode) "Redirect *STANDARD-OUTPUT* to FILENAME until RESTORE-REDIRECTS runs. MODE is a CL :IF-EXISTS value -- :SUPERSEDE for shell `>`, :APPEND for `>>`." - (let ((stream (open filename - :direction :output - :if-exists mode - :if-does-not-exist :create))) - (%remember-redirected-stdout) - (setf *redirected-stdout-owned* stream - *standard-output* stream))) + (%install-redirect-stream + filename + (lambda (stream) + (%remember-redirected-stdout) + (setf *redirected-stdout-owned* stream + *standard-output* stream)) + :direction :output + :if-exists mode + :if-does-not-exist :create)) (defun redirect-error (filename mode) "Redirect *ERROR-OUTPUT* to FILENAME until RESTORE-REDIRECTS runs. MODE is a CL :IF-EXISTS value -- :SUPERSEDE for shell `2>`, :APPEND for `2>>`." - (let ((stream (open filename - :direction :output - :if-exists mode - :if-does-not-exist :create))) - (%remember-redirected-stderr) - (setf *redirected-stderr-owned* stream - *error-output* stream))) + (%install-redirect-stream + filename + (lambda (stream) + (%remember-redirected-stderr) + (setf *redirected-stderr-owned* stream + *error-output* stream)) + :direction :output + :if-exists mode + :if-does-not-exist :create)) (defun redirect-output-and-error (filename mode) "Redirect both *STANDARD-OUTPUT* and *ERROR-OUTPUT* to the same open stream on FILENAME, for shell `&>`/`&>>`. MODE is a CL :IF-EXISTS value." - (let ((stream (open filename - :direction :output - :if-exists mode - :if-does-not-exist :create))) - (%remember-redirected-stdout) - (%remember-redirected-stderr) - (setf *redirected-stdout-owned* stream - *redirected-stderr-owned* stream - *standard-output* stream - *error-output* stream))) + (%install-redirect-stream + filename + (lambda (stream) + (%remember-redirected-stdout) + (%remember-redirected-stderr) + (setf *redirected-stdout-owned* stream + *redirected-stderr-owned* stream + *standard-output* stream + *error-output* stream)) + :direction :output + :if-exists mode + :if-does-not-exist :create)) (defun redirect-output-to-error () (%remember-redirected-stdout) @@ -100,10 +112,14 @@ keeps it from double-closing a stream this function never owned." (defun redirect-input (filename) "Redirect *STANDARD-INPUT* to read from FILENAME until RESTORE-REDIRECTS runs, for shell `<`. Signals if FILENAME does not exist." - (let ((stream (open filename :direction :input :if-does-not-exist :error))) - (%remember-redirected-stdin) - (setf *redirected-stdin-owned* stream - *standard-input* stream))) + (%install-redirect-stream + filename + (lambda (stream) + (%remember-redirected-stdin) + (setf *redirected-stdin-owned* stream + *standard-input* stream)) + :direction :input + :if-does-not-exist :error)) (defun redirect-input-string (value) "Redirect *STANDARD-INPUT* to VALUE plus a trailing newline, for a shell diff --git a/src/package-domain.lisp b/src/package-domain.lisp index d1e6636..5c1dd6e 100644 --- a/src/package-domain.lisp +++ b/src/package-domain.lisp @@ -104,7 +104,8 @@ control-flow forms, reporting incomplete input so the reader can ask for a continuation line. A pure string-to-AST function with no filesystem access.") (:use #:cl) (:import-from #:nshell.util #:define-value-struct) - (:export #:tokenize #:shell-assignment-word-p #:parse-command-line + (:export #:tokenize #:balanced-substitution-end + #:shell-assignment-word-p #:parse-command-line #:shell-input-blank-p #:shell-word-separator-p #:shell-operator-separator-p #:shell-token-separator-p #:shell-command-separator-token-p @@ -214,6 +215,7 @@ that is what keeps expansion testable without a disk.") #:expand-variables #:expand-tilde #:expand-glob #:expand-all #:expand-by-quote-style #:expand-command-name-fields-by-quote-style + #:single-command-name-or-error #:expand-command-name-by-quote-style #:expand-double-quoted #:expand-arithmetic #:evaluate-arithmetic #:expand-braces #:argv-reference-fields #:*positional-args* diff --git a/src/package-highlight.lisp b/src/package-highlight.lisp new file mode 100644 index 0000000..400d5b4 --- /dev/null +++ b/src/package-highlight.lisp @@ -0,0 +1,12 @@ +;;; Shared syntax-highlighting package. + +(eval-when (:compile-toplevel :load-toplevel :execute) + (defpackage #:nshell.highlight + (:documentation + "Syntax-highlight data and rendering shared by application and presentation.") + (:use #:cl) + (:import-from #:nshell.util #:define-value-struct) + (:export #:highlight-line + #:highlight-span-start #:highlight-span-end + #:highlight-span-role + #:highlight->ansi #:theme-color->ansi))) diff --git a/src/package-infrastructure.lisp b/src/package-infrastructure.lisp index 8411c60..7cb2310 100644 --- a/src/package-infrastructure.lisp +++ b/src/package-infrastructure.lisp @@ -12,6 +12,7 @@ stores in the shell context, which is what makes the layers above it mockable.") (:use #:cl) (:import-from #:nshell.util #:define-value-struct) (:export #:*exported-environment* + #:make-process-fns #:spawn-pipeline #:spawn-pipeline-async #:wait-job #:spawn-process-substitution #:process-substitution-resource-p @@ -36,8 +37,9 @@ stores in the shell context, which is what makes the layers above it mockable.") #:child-status #:child-status-p #:child-status-pid #:child-status-status #:reap-children #:get-terminal-size #:terminal-size-unavailable #:terminal-size-unavailable-fd - #:*external-command-timeout* - #:run-external #:run-external-capture #:run-external-exec + #:*external-command-timeout* + #:start-stream-copier #:wait-process-with-copiers + #:run-external #:run-external-capture #:run-external-exec #:process-exit-status-code #:with-git-runner #:clear-git-status-cache #:get-git-status)) diff --git a/src/package-presentation.lisp b/src/package-presentation.lisp index c6c3667..6026b23 100644 --- a/src/package-presentation.lisp +++ b/src/package-presentation.lisp @@ -7,8 +7,8 @@ "Presentation: everything the user sees and types at. Holds the line editor's input state and the reducer that advances it one key event at a time -- emacs and vi bindings, kill ring, undo, incremental history search, completion cycling --- plus syntax highlighting, autosuggestion, prompt drawing, and the REPL itself -in its interactive, batch, and script forms.") +-- plus autosuggestion, prompt drawing, and the REPL itself in its interactive, +batch, and script forms.") (:use #:cl) (:import-from #:nshell.util #:define-value-struct) (:export #:input-state #:input-state-p #:make-input-state @@ -36,9 +36,6 @@ in its interactive, batch, and script forms.") #:run-repl #:run-repl-batch #:run-repl-script #:trampoline #:render-prompt #:compute-suggestion #:accept-suggestion - #:render-completions #:apply-completion - #:highlight-line - #:highlight-span-start #:highlight-span-end - #:highlight-span-role - #:highlight->ansi #:theme-color->ansi #:segment-kind->role)) + #:render-completions #:apply-completion + #:segment-kind->role)) ) diff --git a/src/presentation/prompt-display.lisp b/src/presentation/prompt-display.lisp index 3f32f5b..c2a488b 100644 --- a/src/presentation/prompt-display.lisp +++ b/src/presentation/prompt-display.lisp @@ -1,9 +1,9 @@ (in-package #:nshell.presentation) -(defun %char-visible-width (char) - "Terminal column width of CHAR, delegating to cl-tty-kit's Unicode-aware +(defun %char-visible-width (character) + "Terminal column width of CHARACTER, delegating to cl-tty-kit's Unicode-aware classifier (control/combining -> 0, wide/emoji -> 2, otherwise 1)." - (cl-tty-kit:char-width char)) + (cl-tty-kit:char-width character)) (defun %string-visible-width (text) "Sum of the terminal column widths of the characters in TEXT." @@ -53,9 +53,9 @@ it and treats a negative WIDTH as 0." segment) else append (let ((truncated (%truncate-string-to-width text remaining))) - (when (plusp (length truncated)) - (setf remaining 0) - (list (nshell.domain.prompting:make-prompt-segment + (when (plusp (length truncated)) + (setf remaining 0) + (list (nshell.domain.prompting:make-prompt-segment truncated kind))))))) @@ -65,7 +65,8 @@ it and treats a negative WIDTH as 0." (multiple-value-bind (rows cols) (nshell.infrastructure.acl:get-terminal-size) (declare (ignore rows)) (if (plusp cols) cols 80)) - (error () 80))) + (error (condition) + (values 80 condition)))) (defun segment-kind->role (kind) "Map prompt segment kind to highlight role for theme lookup." @@ -95,7 +96,7 @@ it and treats a negative WIDTH as 0." (let ((text (nshell.domain.prompting:prompt-segment-text seg)) (kind (nshell.domain.prompting:prompt-segment-kind seg))) (format stream "~a~a" - (theme-color->ansi theme (segment-kind->role kind)) + (nshell.highlight:theme-color->ansi theme (segment-kind->role kind)) text) (nshell.infrastructure.terminal:ansi-reset-style stream)))) diff --git a/src/presentation/repl-execution-context.lisp b/src/presentation/repl-execution-context.lisp index e916a5e..290fac8 100644 --- a/src/presentation/repl-execution-context.lisp +++ b/src/presentation/repl-execution-context.lisp @@ -13,12 +13,7 @@ (not (null (host-kit:directory-exists-p path)))))) (defparameter +repl-process-fns+ - (list :run-external - (lambda (command args) - (nshell.infrastructure.acl:run-external command args)) - :run-external-capture - (lambda (command args) - (nshell.infrastructure.acl:run-external-capture command args)))) + (nshell.infrastructure.acl:make-process-fns)) (defparameter +repl-redirect-fns+ (list :redirect-output #'nshell.infrastructure.acl:redirect-output diff --git a/src/presentation/repl-output-completion-help.lisp b/src/presentation/repl-output-completion-help.lisp index 5b51a36..c43d839 100644 --- a/src/presentation/repl-output-completion-help.lisp +++ b/src/presentation/repl-output-completion-help.lisp @@ -59,9 +59,6 @@ (setf (gethash (first spec) cache) t)))) "Set of catalogued external commands that should be enriched from help output.") -(defun %completion-help-catalogued-command-p (command) - (gethash command *completion-help-catalog-command-cache*)) - (defun %completion-help-text-p (output) (and (stringp output) (plusp (length output)) @@ -97,8 +94,8 @@ (multiple-value-bind (output exit-code) (funcall *completion-help-fetcher* command) (%completion-help-cache-help-text command output exit-code)) - (error () - :missing))) + (error (condition) + (values :missing condition)))) (progn (defun %warm-command-completion-help (command) diff --git a/src/presentation/repl-rendering.lisp b/src/presentation/repl-rendering.lisp index 32ce750..7625799 100644 --- a/src/presentation/repl-rendering.lisp +++ b/src/presentation/repl-rendering.lisp @@ -33,9 +33,12 @@ (format t "~C[7m~a~C[27m" #\Esc segment #\Esc) (handler-case (format t "~a" - (highlight->ansi (highlight-line segment) segment theme)) - (error () - (format t "~a" segment)))) + (nshell.highlight:highlight->ansi + (nshell.highlight:highlight-line segment) + segment theme)) + (error (condition) + (format t "~a" segment) + (values nil condition)))) (setf local-start local-end)))) (defun render-edit-buffer (text theme &key selection-start selection-end) @@ -81,7 +84,8 @@ (setf *prompt-rendered-origin-row* row *prompt-rendered-origin-column* column)) (setf *prompt-rendered-origin-known-p* t)) - (error () nil))) + (error (condition) + (values nil condition)))) (unless *prompt-rendered-origin-known-p* (setf *prompt-rendered-origin-row* 1 *prompt-rendered-origin-column* 1 @@ -114,7 +118,7 @@ (let* ((terminal-width (multiple-value-bind (rows cols) (handler-case (nshell.infrastructure.acl:get-terminal-size) - (error () (values 24 80))) + (error (condition) (values 24 80 condition))) (declare (ignore rows)) cols)) (prompt-width @@ -160,5 +164,5 @@ *prompt-rendered-cursor-row* (rendered-position-row cursor-position) *prompt-rendered-terminal-width* terminal-width *prompt-rendered-prompt-width* prompt-width)) - (finish-output) - (lambda () (read-key-cont)))) + (finish-output) + (lambda () (read-key-cont)))) diff --git a/t/integration/test-file-config.lisp b/t/integration/test-file-config.lisp index 4aee6e1..5846fd2 100644 --- a/t/integration/test-file-config.lisp +++ b/t/integration/test-file-config.lisp @@ -3,63 +3,62 @@ (describe "file-config-tests" (it "file-config-missing-file" "Loading a missing config file returns NIL." - (let* ((test-path (format nil "/tmp/nshell-test-config-missing-~d.lisp" - (random 1000000)))) - (unwind-protect - (progn - (setf nshell.infrastructure.persistence::*config-file-path-override* - (pathname test-path)) - (when (probe-file test-path) - (delete-file test-path)) - (expect (nshell.infrastructure.persistence:load-config) :to-be-null)) - (setf nshell.infrastructure.persistence::*config-file-path-override* nil) - (when (probe-file test-path) - (delete-file test-path))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "config-missing.lisp" directory))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence::*config-file-path-override* + test-path) + (when (probe-file test-path) + (delete-file test-path)) + (expect (nshell.infrastructure.persistence:load-config) :to-be-null)) + (setf nshell.infrastructure.persistence::*config-file-path-override* nil) + (when (probe-file test-path) + (delete-file test-path)))))) (it "file-config-roundtrip" "Saving and loading config preserves the stored lines." - (let* ((test-path (format nil "/tmp/nshell-test-config-~d.lisp" - (random 1000000))) - (config '("set -g prompt nshell" - "export PATH=/usr/local/bin:$PATH" - ""))) - (unwind-protect - (progn - (setf nshell.infrastructure.persistence::*config-file-path-override* - (pathname test-path)) - (when (probe-file test-path) - (delete-file test-path)) - (expect t :to-be (nshell.infrastructure.persistence:save-config config)) - (expect config :to-equal (nshell.infrastructure.persistence:load-config))) - (setf nshell.infrastructure.persistence::*config-file-path-override* nil) - (when (probe-file test-path) - (delete-file test-path))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "config.lisp" directory)) + (config '("set -g prompt nshell" + "export PATH=/usr/local/bin:$PATH" + ""))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence::*config-file-path-override* + test-path) + (when (probe-file test-path) + (delete-file test-path)) + (expect t :to-be (nshell.infrastructure.persistence:save-config config)) + (expect config :to-equal (nshell.infrastructure.persistence:load-config))) + (setf nshell.infrastructure.persistence::*config-file-path-override* nil) + (when (probe-file test-path) + (delete-file test-path)))))) (it "interactive-startup-sources-config" "Interactive initialization applies .nshellrc through the normal source boundary." - (let* ((config-path (format nil "/tmp/nshell-test-startup-config-~d.lisp" - (random 1000000))) - (history-path (format nil "/tmp/nshell-test-startup-history-~d" - (random 1000000)))) - (unwind-protect - (progn - (with-open-file (stream config-path - :direction :output - :if-exists :supersede - :if-does-not-exist :create) - (write-line "set -x NSHELL_CONFIG_LOADED yes" stream)) - (setf nshell.infrastructure.persistence::*config-file-path-override* - (pathname config-path) - nshell.infrastructure.persistence::*history-file-path-override* - (pathname history-path)) - (nshell.presentation::initialize-repl-state) - (expect "yes" :to-equal (repl-test-env "NSHELL_CONFIG_LOADED"))) - (setf nshell.infrastructure.persistence::*config-file-path-override* nil - nshell.infrastructure.persistence::*history-file-path-override* nil) - (when (probe-file config-path) - (delete-file config-path)) - (when (probe-file history-path) - (delete-file history-path))))) + (host-kit:with-temporary-directory (directory) + (let ((config-path (merge-pathnames "startup-config.lisp" directory)) + (history-path (merge-pathnames "startup-history" directory))) + (unwind-protect + (progn + (with-open-file (stream config-path + :direction :output + :if-exists :supersede + :if-does-not-exist :create) + (write-line "set -x NSHELL_CONFIG_LOADED yes" stream)) + (setf nshell.infrastructure.persistence::*config-file-path-override* + config-path + nshell.infrastructure.persistence::*history-file-path-override* + history-path) + (nshell.presentation::initialize-repl-state) + (expect "yes" :to-equal (repl-test-env "NSHELL_CONFIG_LOADED"))) + (setf nshell.infrastructure.persistence::*config-file-path-override* nil + nshell.infrastructure.persistence::*history-file-path-override* nil) + (when (probe-file config-path) + (delete-file config-path)) + (when (probe-file history-path) + (delete-file history-path)))))) (it "interactive-config-reports-nonzero-status" "A nonzero sourced config status is reported on standard error." (with-temporary-functions diff --git a/t/integration/test-file-history.lisp b/t/integration/test-file-history.lisp index 8aa4464..e2dd88d 100644 --- a/t/integration/test-file-history.lisp +++ b/t/integration/test-file-history.lisp @@ -3,64 +3,65 @@ (describe "file-history-tests" (it "file-history-append" "Appending to file history works" - (let* ((test-path (format nil "/tmp/nshell-test-history-~d.lisp" (random 1000000)))) - (unwind-protect - (progn - ;; Override history file path for test isolation - (setf nshell.infrastructure.persistence:*history-file-path-override* - (pathname test-path)) - ;; Clean up any previous test data - (when (probe-file test-path) (delete-file test-path)) - (nshell.infrastructure.persistence:append-history-entry "test command") - (let ((loaded (nshell.infrastructure.persistence:load-history-file))) - (expect (consp loaded) :to-be-truthy) - (expect "test command" :to-equal (first loaded)))) - ;; Cleanup - (setf nshell.infrastructure.persistence:*history-file-path-override* nil) - (when (probe-file test-path) (delete-file test-path))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "history.lisp" directory))) + (unwind-protect + (progn + ;; Override history file path for test isolation + (setf nshell.infrastructure.persistence:*history-file-path-override* + test-path) + ;; Clean up any previous test data + (when (probe-file test-path) (delete-file test-path)) + (nshell.infrastructure.persistence:append-history-entry "test command") + (let ((loaded (nshell.infrastructure.persistence:load-history-file))) + (expect (consp loaded) :to-be-truthy) + (expect "test command" :to-equal (first loaded)))) + ;; Cleanup + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path)))))) (it "file-history-multiline-round-trip" "Multiline command history survives persistence." - (let* ((test-path (format nil "/tmp/nshell-test-history-multiline-~d.lisp" - (random 1000000))) - (command (format nil "for item in one two~% echo $item~%done"))) - (unwind-protect - (progn - (setf nshell.infrastructure.persistence:*history-file-path-override* - (pathname test-path)) - (when (probe-file test-path) (delete-file test-path)) - (nshell.infrastructure.persistence:append-history-entry command) - (expect (list command) :to-equal - (nshell.infrastructure.persistence:load-history-file))) - (setf nshell.infrastructure.persistence:*history-file-path-override* nil) - (when (probe-file test-path) (delete-file test-path))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "history-multiline.lisp" directory)) + (command (format nil "for item in one two~% echo $item~%done"))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence:*history-file-path-override* + test-path) + (when (probe-file test-path) (delete-file test-path)) + (nshell.infrastructure.persistence:append-history-entry command) + (expect (list command) :to-equal + (nshell.infrastructure.persistence:load-history-file))) + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path)))))) (it "file-history-unframed-lines" "Unframed history records are not loaded." - (let ((test-path (format nil "/tmp/nshell-test-history-unframed-~d.lisp" - (random 1000000)))) - (unwind-protect - (progn - (setf nshell.infrastructure.persistence:*history-file-path-override* - (pathname test-path)) - (when (probe-file test-path) (delete-file test-path)) - (with-open-file (stream test-path :direction :output - :if-exists :supersede - :if-does-not-exist :create) - (format stream "unframed one~%unframed two~%")) - (expect (nshell.infrastructure.persistence:load-history-file) :to-be-null)) - (setf nshell.infrastructure.persistence:*history-file-path-override* nil) - (when (probe-file test-path) (delete-file test-path))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "history-unframed.lisp" directory))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence:*history-file-path-override* + test-path) + (when (probe-file test-path) (delete-file test-path)) + (with-open-file (stream test-path :direction :output + :if-exists :supersede + :if-does-not-exist :create) + (format stream "unframed one~%unframed two~%")) + (expect (nshell.infrastructure.persistence:load-history-file) :to-be-null)) + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path)))))) (it "file-history-missing-file" "Loading a missing history file returns NIL." - (let* ((test-path (format nil "/tmp/nshell-test-history-missing-~d.lisp" - (random 1000000)))) - (unwind-protect - (progn - (setf nshell.infrastructure.persistence:*history-file-path-override* - (pathname test-path)) - (when (probe-file test-path) (delete-file test-path)) - (expect (nshell.infrastructure.persistence:load-history-file) :to-be-null)) - (setf nshell.infrastructure.persistence:*history-file-path-override* nil) - (when (probe-file test-path) (delete-file test-path)))))) + (host-kit:with-temporary-directory (directory) + (let ((test-path (merge-pathnames "history-missing.lisp" directory))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence:*history-file-path-override* + test-path) + (when (probe-file test-path) (delete-file test-path)) + (expect (nshell.infrastructure.persistence:load-history-file) :to-be-null)) + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path))))))) diff --git a/t/integration/test-terminal-presentation.lisp b/t/integration/test-terminal-presentation.lisp index d986f4f..fa8738f 100644 --- a/t/integration/test-terminal-presentation.lisp +++ b/t/integration/test-terminal-presentation.lisp @@ -6,9 +6,9 @@ (dolist (line (list "echo \"hi" "echo \\")) (let ((state (input-state))) (dolist (event (read-key-events-from-string (format nil "~a~%" line))) - (multiple-value-bind (next-state output) + (multiple-value-bind (next-state output) (nshell.presentation:reduce-input-state state event) - (setf state + (setf state (if (eq output :execute) (with-parsed-command-line (result (nshell.presentation:input-state-buffer next-state)) @@ -24,50 +24,48 @@ (it "terminal-execute-on-structural-incomplete-input-indents-continuation" "REPL execution promotes structural incomplete input to an indented continuation." (with-repl-test-state - (dolist (case '(("echo hi |" . "echo hi |~% ") - ("echo hi &&" . "echo hi &&~% ") - ("if true" . "if true~% "))) - (destructuring-bind (line . expected-format) case + (dolist (line '("echo hi |" "echo hi &&" "if true")) + (let ((expected (concatenate 'string line (string #\Newline) " "))) (setf nshell.presentation::*input-state* (nshell.presentation::make-repl-input-state :buffer line)) (capture-process-output-event :execute) - (expect (format nil expected-format) :to-equal (nshell.presentation:input-state-buffer - nshell.presentation::*input-state*)) + (expect expected :to-equal (nshell.presentation:input-state-buffer + nshell.presentation::*input-state*)) (expect (length (nshell.presentation:input-state-buffer nshell.presentation::*input-state*)) :to-equal (nshell.presentation:input-state-cursor-pos nshell.presentation::*input-state*)))))) (it "terminal-highlight-uses-parser-diagnostics" "Presentation highlighting marks parser diagnostics as errors." - (let* ((spans (nshell.presentation:highlight-line "| echo nope")) + (let* ((spans (nshell.highlight:highlight-line "| echo nope")) (first-span (first spans))) (expect (null first-span) :to-be-falsy) - (expect :error :to-be (nshell.presentation:highlight-span-role first-span)) - (expect 0 :to-equal (nshell.presentation:highlight-span-start first-span)) - (expect 1 :to-equal (nshell.presentation:highlight-span-end first-span)))) + (expect :error :to-be (nshell.highlight:highlight-span-role first-span)) + (expect 0 :to-equal (nshell.highlight:highlight-span-start first-span)) + (expect 1 :to-equal (nshell.highlight:highlight-span-end first-span)))) (it "terminal-highlight-span-constructor-is-internal-boundary" "Highlight spans are produced by highlight-line rather than public raw construction." - (expect (fboundp 'nshell.presentation::make-highlight-span) :to-be-falsy) - (expect (fboundp 'nshell.presentation::highlight-span-p) :to-be-falsy) - (expect (fboundp 'nshell.presentation::copy-highlight-span) :to-be-falsy) - (expect (fboundp 'nshell.presentation::%make-highlight-span) :to-be-truthy)) + (expect (fboundp 'nshell.highlight::make-highlight-span) :to-be-falsy) + (expect (fboundp 'nshell.highlight::highlight-span-p) :to-be-falsy) + (expect (fboundp 'nshell.highlight::copy-highlight-span) :to-be-falsy) + (expect (fboundp 'nshell.highlight::%make-highlight-span) :to-be-truthy)) (it "terminal-highlight-span-type-is-internal-boundary" "Highlight spans are opaque presentation values with public projections only." - (let ((span (first (nshell.presentation:highlight-line "| echo nope")))) - (expect span :to-be-type-of 'nshell.presentation::%highlight-span) - (expect (find-symbol "HIGHLIGHT-SPAN" :nshell.presentation) :to-be-falsy))) + (let ((span (first (nshell.highlight:highlight-line "| echo nope")))) + (expect span :to-be-type-of 'nshell.highlight::%highlight-span) + (expect (find-symbol "HIGHLIGHT-SPAN" :nshell.highlight) :to-be-falsy))) (it "terminal-highlight-span-raw-accessors-stay-internal" "Highlight span projections stay behind explicit public accessors." - (let ((span (first (nshell.presentation:highlight-line "| echo nope")))) - (expect (fboundp 'nshell.presentation::%highlight-span-start) :to-be-truthy) - (expect (fboundp 'nshell.presentation::%highlight-span-end) :to-be-truthy) - (expect (fboundp 'nshell.presentation::%highlight-span-role) :to-be-truthy) - (expect (nshell.presentation:highlight-span-start span) :to-equal (nshell.presentation::%highlight-span-start span)) - (expect (nshell.presentation:highlight-span-end span) :to-equal (nshell.presentation::%highlight-span-end span)) - (expect (nshell.presentation:highlight-span-role span) :to-be (nshell.presentation::%highlight-span-role span)))) + (let ((span (first (nshell.highlight:highlight-line "| echo nope")))) + (expect (fboundp 'nshell.highlight::%highlight-span-start) :to-be-truthy) + (expect (fboundp 'nshell.highlight::%highlight-span-end) :to-be-truthy) + (expect (fboundp 'nshell.highlight::%highlight-span-role) :to-be-truthy) + (expect (nshell.highlight:highlight-span-start span) :to-equal (nshell.highlight::%highlight-span-start span)) + (expect (nshell.highlight:highlight-span-end span) :to-equal (nshell.highlight::%highlight-span-end span)) + (expect (nshell.highlight:highlight-span-role span) :to-be (nshell.highlight::%highlight-span-role span)))) (it "terminal-highlight-uses-public-ansi-boundary" "Presentation color rendering depends on the terminal ANSI public contract." diff --git a/t/support/builtins.lisp b/t/support/builtins.lisp index 9b62460..e2c941b 100644 --- a/t/support/builtins.lisp +++ b/t/support/builtins.lisp @@ -29,14 +29,18 @@ :cwd (lambda () #p"/tmp/") :chdir (lambda (path) (declare (ignore path)) t)) :process-fns - (let ((fns (list :run-external - (or external-runner - (lambda (command args) - (declare (ignore command args)) - 0))))) - (if external-capture-runner - (list* :run-external-capture external-capture-runner fns) - fns)) + ;; :run-external and :run-external-capture are listed ahead of + ;; MAKE-PROCESS-FNS's real implementations so GETF's first-match finds + ;; these fakes instead: an absent capture-runner must stay absent (NIL) + ;; so %RUN-EXTERNAL-COMMAND-IN-CONTEXT falls back to :run-external, + ;; rather than silently picking up the real external-process runner. + (list* :run-external + (or external-runner + (lambda (command args) + (declare (ignore command args)) + 0)) + :run-external-capture external-capture-runner + (nshell.infrastructure.acl:make-process-fns)) :redirect-fns (list :redirect-output #'nshell.infrastructure.acl:redirect-output :redirect-error #'nshell.infrastructure.acl:redirect-error @@ -94,13 +98,13 @@ output-empty contains) (append (when (not (null code)) - `((expect ,code :to-equal ,actual-code))) + `((expect ,actual-code :to-equal ,code))) (when output - `((expect ,output :to-equal ,actual-output))) + `((expect ,actual-output :to-equal ,output))) (when output-null `((expect ,actual-output :to-be-null))) (when output-empty - `((expect "" :to-equal ,actual-output))) + `((expect ,actual-output :to-equal ""))) (when contains `((expect (%builtin-output-contains-all-p ,actual-output ,contains) :to-be-truthy))) `((values ,actual-output ,actual-code)))) @@ -139,8 +143,8 @@ (defmacro with-builtins-source-ok ((output code context lines) expected-output &body extra-assertions) "Like WITH-BUILTINS-SOURCE but automatically asserts exit-code=0 and string output equality." `(with-builtins-source (,output ,code ,context ,lines) - (expect 0 :to-equal ,code) - (expect ,expected-output :to-equal ,output) + (expect ,code :to-equal 0) + (expect ,output :to-equal ,expected-output) ,@extra-assertions)) (defmacro with-stubbed-command-executor ((&rest cases) &body body) @@ -193,23 +197,23 @@ form should return (values output exit-code). Example: (multiple-value-bind (output code) (call-builtin ,context ,name ,add-args) (expect output :to-be-null) - (expect 0 :to-equal code)) - (expect ,expansion :to-equal (gethash ,key ,table-form)) - (expect 0 :to-equal (nth-value 1 (call-builtin ,context ,name (list "-q" ,key)))) - (expect 1 :to-equal (nth-value 1 (call-builtin ,context ,name (list "-q" ,missing-key)))) + (expect code :to-equal 0)) + (expect (gethash ,key ,table-form) :to-equal ,expansion) + (expect (nth-value 1 (call-builtin ,context ,name (list "-q" ,key))) :to-equal 0) + (expect (nth-value 1 (call-builtin ,context ,name (list "-q" ,missing-key))) :to-equal 1) ,@(when body-contains `((multiple-value-bind (output code) (call-builtin ,context ,name (list ,key)) - (expect 0 :to-equal code) + (expect code :to-equal 0) ,@(mapcar (lambda (needle) `(expect (search ,needle output) :to-be-truthy)) body-contains)))) (multiple-value-bind (output code) (call-builtin ,context ,name nil) - (expect 0 :to-equal code) + (expect code :to-equal 0) (expect (search ,list-fragment output) :to-be-truthy)) (assert-builtin-call (,context ,name '("-e")) :code 2 :output ,erase-error-output) - (expect 0 :to-equal (nth-value 1 (call-builtin ,context ,name ,erase-args))) + (expect (nth-value 1 (call-builtin ,context ,name ,erase-args)) :to-equal 0) (expect (gethash ,key ,table-form) :to-be-null))) diff --git a/t/support/pbt-shell.lisp b/t/support/pbt-shell.lisp index 61434d7..21d9193 100644 --- a/t/support/pbt-shell.lisp +++ b/t/support/pbt-shell.lisp @@ -20,21 +20,23 @@ t))) (defun %default-test-process-fns () - (list :spawn (lambda (&rest args) - (declare (ignore args)) - :spawned) - :wait (lambda (&rest args) - (declare (ignore args)) - :waited) - :signal (lambda (&rest args) + (append + (list :spawn (lambda (&rest args) (declare (ignore args)) - :signaled) - :run-external (lambda (command args) - (declare (ignore command args)) - 0) - :run-external-capture (lambda (command args) - (declare (ignore command args)) - (values nil 0)))) + :spawned) + :wait (lambda (&rest args) + (declare (ignore args)) + :waited) + :signal (lambda (&rest args) + (declare (ignore args)) + :signaled) + :run-external (lambda (command args) + (declare (ignore command args)) + 0) + :run-external-capture (lambda (command args) + (declare (ignore command args)) + (values nil 0))) + (nshell.infrastructure.acl:make-process-fns))) (defun %default-test-terminal-fns () (list :get-size (lambda () diff --git a/t/unit/test-builtins-core-io.lisp b/t/unit/test-builtins-core-io.lisp index a368719..f5dccd6 100644 --- a/t/unit/test-builtins-core-io.lisp +++ b/t/unit/test-builtins-core-io.lisp @@ -30,6 +30,113 @@ :code 0 :output (format nil "literal~%")))) + (it "printf-covers-option-escape-and-formatting-boundaries" + "printf handles its option terminator, escape forms, widths, precisions, and errors." + (with-builtins-context (context) + (assert-builtin-cases (context "printf") + (nil + :code 0 + :output-null t) + (("--" "%s" "x") + :code 0 + :output "x") + (("%b" "left\\cignored") + :code 0 + :output "left") + (("%b" "A\\010B") + :code 0 + :output (concatenate 'string "A" (string #\Backspace) "B")) + (("%b" "\\a\\b\\e\\f\\n\\r\\t\\v\\\\") + :code 0 + :output (concatenate 'string + (string (code-char 7)) + (string #\Backspace) + (string (code-char 27)) + (string #\Page) + (string #\Newline) + (string #\Return) + (string #\Tab) + (string (code-char 11)) + "\\")) + (("literal\\q") + :code 0 + :output "literalq") + (("trailing\\") + :code 0 + :output "trailing\\") + (("%%") + :code 0 + :output "%") + (("%c" "Z") + :code 0 + :output "Z") + (("%c" "") + :code 0 + :output (string #\Null)) + (("%+d" "7") + :code 0 + :output "+7") + (("% d" "7") + :code 0 + :output " 7") + (("%i" "-7") + :code 0 + :output "-7") + (("%u" "7") + :code 0 + :output "7") + (("%#o" "8") + :code 0 + :output "010") + (("%#x" "15") + :code 0 + :output "0xf") + (("%#X" "15") + :code 0 + :output "0XF") + (("%.3d" "7") + :code 0 + :output "007") + (("%05.3d" "7") + :code 0 + :output " 007") + (("%-5s" "x") + :code 0 + :output "x ") + (("%2lls" "x") + :code 0 + :output " x") + (("%.s" "x") + :code 0 + :output-empty t) + (("%f" "1.5") + :code 0 + :output (format nil "~,6f" 1.5d0)) + (("%.2f" "1.5") + :code 0 + :output (format nil "~,2f" 1.5d0)) + (("%+f" "1.5") + :code 0 + :output (concatenate 'string "+" (format nil "~,6f" 1.5d0))) + (("% f" "1.5") + :code 0 + :output (concatenate 'string " " (format nil "~,6f" 1.5d0))) + (("%E" "1.5") + :code 0 + :contains '("E")) + (("%f" "not-a-number") + :code 1 + :output (format nil "~,6f" 0d0)) + (("%q" "x") + :code 1 + :output-empty t) + (("%") + :code 1 + :output-empty t) + (("literal" "ignored") + :code 0 + :output "literal")))) + (it "pwd-returns-current-working-directory" "pwd returns the context cwd as a string with a trailing newline." (with-builtins-context (context) diff --git a/t/unit/test-builtins-source.lisp b/t/unit/test-builtins-source.lisp index dde857e..7e344ca 100644 --- a/t/unit/test-builtins-source.lisp +++ b/t/unit/test-builtins-source.lisp @@ -130,7 +130,8 @@ process)) (with-temporary-function ('nshell.application::%background-process-pid - (lambda (proc) + (lambda (ignored-context proc) + (declare (ignore ignored-context)) (when (eq proc process) 4321))) (with-called-source (output code context '("first arg & second")) @@ -170,7 +171,8 @@ processes)) (with-temporary-function ('nshell.application::%background-process-pid - (lambda (proc) + (lambda (ignored-context proc) + (declare (ignore ignored-context)) (case proc (:left-process 4321) (:right-process 4322)))) diff --git a/t/unit/test-execute-pipeline.lisp b/t/unit/test-execute-pipeline.lisp index 2c27b35..034c3b0 100644 --- a/t/unit/test-execute-pipeline.lisp +++ b/t/unit/test-execute-pipeline.lisp @@ -54,7 +54,9 @@ :process-exited :pipeline-completed) (nshell.domain.events:domain-event-type event) - (expect 0 :to-equal (nshell.application:execute-pipeline-use-case ast dispatcher)) + (expect 0 :to-equal + (nshell.application:execute-pipeline-use-case + ast dispatcher (%default-test-process-fns))) (expect (nshell.application:drain-events dispatcher) :to-be-null) (let ((delivered (nreverse events))) (expect (member :pipeline-started delivered) :to-be-truthy) @@ -69,7 +71,8 @@ (code nil) (output (with-output-to-string (*standard-output*) (setf code - (nshell.application:execute-pipeline-use-case ast nil))))) + (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 0 :to-equal code) (expect "pipeline-api" :to-equal output)))) @@ -82,7 +85,8 @@ (code nil)) (let ((error-output (with-output-to-string (*error-output*) - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 127 :to-equal code) (expect (format nil "nshell: $NSHELL_PIPELINE_CMD: command name expansion produced 2 fields~%") :to-equal error-output))))) @@ -94,7 +98,8 @@ (code nil)) (let ((error-output (with-output-to-string (*error-output*) - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 127 :to-equal code) (expect (format nil "nshell: NSHELL_MISSING_REQUIRED: required value~%") :to-equal error-output)))) @@ -138,16 +143,16 @@ (unwind-protect (progn (ensure-directories-exist root) - (expect 0 :to-equal (nshell.application:execute-pipeline-use-case ast nil)) + (expect 0 :to-equal + (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns))) (expect (probe-file output) :to-be-truthy) (with-open-file (stream output :direction :input) (let ((actual (make-string (file-length stream)))) (read-sequence actual stream) (expect content :to-equal actual)))) - (handler-case - (when (probe-file root) - (host-kit:delete-directory-tree root :validate t)) - (error ()))))) + (when (probe-file root) + (host-kit:delete-directory-tree root :validate t))))) (it "pipeline-stage-streams-opens-file-and-here-string-inputs" "Pipeline stage setup materializes file and here-string input redirects." @@ -245,7 +250,8 @@ (ast (nshell.domain.parsing:make-pipeline-node (list writer counter))) (code nil) (output (capture-standard-output - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 0 :to-equal code) (expect (format nil "3~%") :to-equal output))) @@ -259,7 +265,8 @@ (ast (nshell.domain.parsing:make-pipeline-node (list writer counter))) (code nil) (output (capture-standard-output - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 0 :to-equal code) (expect (format nil "6~%") :to-equal output))) @@ -274,7 +281,8 @@ (ast (nshell.domain.parsing:make-pipeline-node (list writer counter))) (code nil) (output (capture-standard-output - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 0 :to-equal code) (expect (format nil "3~%") :to-equal output) (expect "OUT" :to-equal (host-kit:read-file-string target))))) @@ -290,7 +298,8 @@ (ast (nshell.domain.parsing:make-pipeline-node (list writer counter))) (code nil) (output (capture-standard-output - (setf code (nshell.application:execute-pipeline-use-case ast nil))))) + (setf code (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns)))))) (expect 0 :to-equal code) (expect (format nil "0~%") :to-equal output) (expect "OUTERR" :to-equal (host-kit:read-file-string target))))) @@ -300,7 +309,9 @@ (let ((ast (nshell.domain.parsing:make-command-node "definitely-not-a-real-command" nil))) - (expect 127 :to-equal (nshell.application:execute-pipeline-use-case ast nil)))) + (expect 127 :to-equal + (nshell.application:execute-pipeline-use-case + ast nil (%default-test-process-fns))))) (it "execute-pipeline-node-in-context-times-out-external-stages-in-cps-mode" "The CPS execution path drains external output and times out long-running stages." @@ -433,7 +444,8 @@ (length (nshell.application::%process-substitution-inner-commands pipeline)))) (expect (nshell.application::%process-substitution-inner-commands nil) :to-be-null)) (it "process-substitution-resource-cleanup-is-best-effort" - (let ((released nil)) + (let ((context (make-test-shell-context)) + (released nil)) (with-temporary-functions (((quote nshell.infrastructure.acl:release-process-substitution-fd) (lambda (resource) @@ -441,10 +453,12 @@ (when (eq resource :bad) (error "release failure"))))) (nshell.application::%release-process-substitution-resources + context (list :first :bad :last))) (expect (list :last :bad :first) :to-equal released))) (it "process-substitution-finish-and-abort-release-all-resources" - (let ((waited nil) + (let ((context (make-test-shell-context)) + (waited nil) (released nil) (closed nil)) (with-temporary-functions @@ -459,8 +473,8 @@ ((quote nshell.infrastructure.acl:close-process-substitution) (lambda (resource) (push resource closed)))) - (nshell.application::%finish-process-substitution-resources (list :ok :bad)) - (nshell.application::%abort-process-substitution-resources (list :left :right))) + (nshell.application::%finish-process-substitution-resources context (list :ok :bad)) + (nshell.application::%abort-process-substitution-resources context (list :left :right))) (expect (list :bad :ok) :to-equal waited) (expect (list :bad :ok) :to-equal released) (expect (list :right :left) :to-equal closed))) @@ -472,6 +486,7 @@ (expect (list 3 4) :to-equal (nshell.application::%process-substitution-resource-fds + (make-test-shell-context) (list (list :fd 3) (list :fd 4)))))) (it "source-pipeline-exit-status-honors-pipefail" (expect 2 :to-equal (nshell.application::%source-pipeline-exit-status (list 0 2) nil)) @@ -559,7 +574,8 @@ (declare (ignore ignored-context)) (values commands nil (list :nested)))) ((quote nshell.application::%abort-process-substitution-resources) - (lambda (resources) + (lambda (ignored-context resources) + (declare (ignore ignored-context)) (setf aborted resources)))) (multiple-value-bind (path resource error) (nshell.application::%materialize-process-substitution-in-context @@ -600,8 +616,8 @@ (declare (ignore ignored-context value)) (values nil nil "substitution failed"))) ((quote nshell.application::%abort-process-substitution-resources) - (lambda (resources) - (declare (ignore resources)) + (lambda (ignored-context resources) + (declare (ignore ignored-context resources)) (setf abort-called t)))) (multiple-value-bind (args resources error) (nshell.application::%expand-command-args-in-context context command) @@ -628,7 +644,8 @@ (values command nil (list :resource)) (values nil "node failed" nil)))) ((quote nshell.application::%abort-process-substitution-resources) - (lambda (resources) + (lambda (ignored-context resources) + (declare (ignore ignored-context)) (setf aborted resources)))) (multiple-value-bind (commands error resources) (nshell.application::%expand-command-nodes-in-context @@ -639,7 +656,8 @@ (expect error :to-equal "node failed") (expect aborted :to-equal (list :resource)))))) (it "os-process-substitution-pipeline-releases-resources-after-spawn" - (let ((captured nil) + (let ((context (make-test-shell-context)) + (captured nil) (released nil) (waited nil)) (with-temporary-functions @@ -661,6 +679,7 @@ (push resource waited)))) (multiple-value-bind (output code) (nshell.application::%execute-os-pipeline-with-process-substitutions + context '(:command) nil (list :resource) @@ -671,7 +690,8 @@ (expect released :to-equal (list :resource :resource)) (expect waited :to-equal (list :resource)))))) (it "os-process-substitution-pipeline-aborts-on-spawn-failure" - (let ((closed nil)) + (let ((context (make-test-shell-context)) + (closed nil)) (with-temporary-functions (((quote nshell.infrastructure.acl:process-substitution-resource-fd) (lambda (resource) @@ -686,6 +706,7 @@ (push resource closed)))) (multiple-value-bind (output code) (nshell.application::%execute-os-pipeline-with-process-substitutions + context '(:command) nil (list :resource) @@ -703,7 +724,8 @@ (declare (ignore ignored-context ignored-command)) (values command nil (list :resource)))) ((quote nshell.application::%abort-process-substitution-resources) - (lambda (resources) + (lambda (ignored-context resources) + (declare (ignore ignored-context)) (setf aborted resources)))) (multiple-value-bind (output code) (nshell.application::execute-command-node-in-context context command) @@ -723,7 +745,8 @@ nil (list :resource)))) ((quote nshell.application::%abort-process-substitution-resources) - (lambda (resources) + (lambda (ignored-context resources) + (declare (ignore ignored-context)) (setf aborted resources)))) (multiple-value-bind (output code) (nshell.application::execute-pipeline-node-in-context context pipeline) @@ -742,8 +765,8 @@ nil (list :resource)))) ((quote nshell.application::%execute-os-pipeline-with-process-substitutions) - (lambda (commands redirects resources pipefail-p) - (declare (ignore commands redirects resources pipefail-p)) + (lambda (ignored-context commands redirects resources pipefail-p) + (declare (ignore ignored-context commands redirects resources pipefail-p)) (values "os output" 12)))) (multiple-value-bind (output code) (nshell.application::execute-pipeline-node-in-context context pipeline) diff --git a/t/unit/test-expansion.lisp b/t/unit/test-expansion.lisp index 1782e4c..ec70d6d 100644 --- a/t/unit/test-expansion.lisp +++ b/t/unit/test-expansion.lisp @@ -13,10 +13,10 @@ Each case is (EXPECTED INPUT &rest ARGS)." (let ((expander (gensym "EXPANDER-"))) `(let ((,expander ,builder)) - ,@(mapcar (lambda (case) - (destructuring-bind (expected &rest args) case - `(expect (,predicate ',expected (funcall ,expander ,@args)) :to-be-truthy))) - cases)))) + ,@(mapcar (lambda (case) + (destructuring-bind (expected &rest args) case + `(expect (,predicate ',expected (funcall ,expander ,@args)) :to-be-truthy))) + cases)))) (defmacro %assert-expansion-cases-with-env ((predicate expander env-form) &body cases) "Assert CASES against an EXPANDER that takes INPUT and ENV." @@ -25,40 +25,40 @@ Each case is (EXPECTED INPUT &rest ARGS)." (expander-fn (gensym "EXPANDER-"))) `(let ((,env ,env-form) (,expander-fn ,expander)) - (%assert-expansion-cases (,predicate - (lambda (,input) - (funcall ,expander-fn ,input ,env))) - ,@cases)))) + (%assert-expansion-cases (,predicate + (lambda (,input) + (funcall ,expander-fn ,input ,env))) + ,@cases)))) (defmacro %assert-multiple-value-cases ((predicate builder) &body cases) "Assert CASES against a BUILDER that returns multiple values." (let ((expander (gensym "EXPANDER-"))) `(let ((,expander ,builder)) - ,@(mapcar (lambda (case) - (destructuring-bind (expected &rest args) case - `(expect (,predicate ',expected - (multiple-value-list - (funcall ,expander ,@args))) :to-be-truthy))) - cases)))) + ,@(mapcar (lambda (case) + (destructuring-bind (expected &rest args) case + `(expect (,predicate ',expected + (multiple-value-list + (funcall ,expander ,@args))) :to-be-truthy))) + cases)))) (defmacro %assert-quote-style-dispatch-case (style expected branch) `(let ((observed-branch nil)) - (expect ,expected :to-equal (nshell.domain.expansion:expand-by-quote-style - ,style - (progn (setf observed-branch :unquoted) '("unquoted")) - (progn (setf observed-branch :single) '("single")) - (progn (setf observed-branch :double) '("double")))) - (expect ,branch :to-be observed-branch))) + (expect ,expected :to-equal (nshell.domain.expansion:expand-by-quote-style + ,style + (progn (setf observed-branch :unquoted) '("unquoted")) + (progn (setf observed-branch :single) '("single")) + (progn (setf observed-branch :double) '("double")))) + (expect ,branch :to-be observed-branch))) (defmacro %assert-command-name-case (input style expected-command expected-error-count env) `(multiple-value-bind (command error) (nshell.domain.expansion:expand-command-name-by-quote-style ,input ,style ,env) - (expect ,expected-command :to-equal command) - (if ,expected-error-count - (expect (format nil "nshell: ~a: command name expansion produced ~d fields~%" - ,input ,expected-error-count) :to-equal error) - (expect error :to-be-null)))) + (expect ,expected-command :to-equal command) + (if ,expected-error-count + (expect (format nil "nshell: ~a: command name expansion produced ~d fields~%" + ,input ,expected-error-count) :to-equal error) + (expect error :to-be-null)))) (describe "expansion-tests" (it "dollar-var-expansion" @@ -180,17 +180,17 @@ Each case is (EXPECTED INPUT &rest ARGS)." (it "single-command-name-or-error-validates-non-empty-cardinality" "Command-position resolution drops empty fields before validating cardinality." (multiple-value-bind (command error) - (nshell.domain.expansion::%single-command-name-or-error "$EMPTY" '("")) + (nshell.domain.expansion:single-command-name-or-error "$EMPTY" '("")) (expect command :to-be-null) (expect (format nil "nshell: $EMPTY: command name expansion produced 0 fields~%") :to-equal error)) (multiple-value-bind (command error) - (nshell.domain.expansion::%single-command-name-or-error "$CMD" '("" "echo" "")) + (nshell.domain.expansion:single-command-name-or-error "$CMD" '("" "echo" "")) (expect "echo" :to-equal command) (expect error :to-be-null)) (multiple-value-bind (command error) - (nshell.domain.expansion::%single-command-name-or-error "$CMD" '("echo" "printf")) - (expect command :to-be-null) - (expect (format nil "nshell: $CMD: command name expansion produced 2 fields~%") :to-equal error))) + (nshell.domain.expansion:single-command-name-or-error "$CMD" '("echo" "printf")) + (expect command :to-be-null) + (expect (format nil "nshell: $CMD: command name expansion produced 2 fields~%") :to-equal error))) (it "command-name-candidate-resolves-non-empty-cardinality" "command-name-candidate owns empty-field removal before command resolution." diff --git a/t/unit/test-manage-job.lisp b/t/unit/test-manage-job.lisp index 88f744f..4ad7dd4 100644 --- a/t/unit/test-manage-job.lisp +++ b/t/unit/test-manage-job.lisp @@ -30,7 +30,7 @@ (nth-value 1 (find-symbol "MAKE-JOB-LISTING" "NSHELL.APPLICATION"))) :to-be-falsy) (expect (fboundp 'nshell.application::copy-job-listing) :to-be-falsy) - (expect (fboundp '(setf nshell.application:job-listing-command)) :to-be-falsy) + (expect (fboundp (list 'setf 'nshell.application:job-listing-command)) :to-be-falsy) (expect "printf ok" :to-equal (nshell.application:job-listing-command listing)))) (it "bg-marks-job-as-background-and-publishes-continuation" @@ -79,9 +79,17 @@ (job (make-test-job 0 "sleep" :args '("10") :pgid 4321)) (job-id (nshell.domain.job-control:monitor-add-job monitor job)) (set-foreground-calls nil) + (foreground-pgid 0) + (process-fns + (list :get-foreground-pgroup (lambda () 1000) + :set-foreground-pgroup + (lambda (pgid) + (push pgid set-foreground-calls)) + :set-foreground-pgid-state + (lambda (pgid) + (setf foreground-pgid (or pgid 0))))) (nshell.application:*shell-pgid* 1000) - (nshell.application:*foreground-job-pgid* nil) - (nshell.infrastructure.acl::*foreground-pgid* 0)) + (nshell.application:*foreground-job-pgid* nil)) (with-temporary-functions (('nshell.application::%continue-process-group (lambda (pgid) @@ -92,19 +100,15 @@ (expect job-id :to-equal waited-job-id) (expect monitor :to-be waited-monitor) (expect 4321 :to-equal nshell.application:*foreground-job-pgid*) - (expect 4321 :to-equal nshell.infrastructure.acl::*foreground-pgid*) + (expect 4321 :to-equal foreground-pgid) (error "wait failed"))) - ('nshell.infrastructure.acl:get-foreground-pgroup - (lambda () 1000)) - ('nshell.infrastructure.acl:set-foreground-pgroup - (lambda (pgid) - (push pgid set-foreground-calls)))) + ) (handler-case - (nshell.application:fg job-id nil nil nil monitor) + (nshell.application:fg job-id nil nil nil monitor process-fns) (error (condition) (expect (search "wait failed" (princ-to-string condition)) :to-be-truthy))) (expect nshell.application:*foreground-job-pgid* :to-be-null) - (expect 0 :to-equal nshell.infrastructure.acl::*foreground-pgid*) + (expect 0 :to-equal foreground-pgid) (expect '(1000 4321) :to-equal set-foreground-calls)))) (it "fg-publishes-continuation-to-dispatcher" "FG emits a continuation event through the supplied dispatcher." @@ -113,9 +117,17 @@ (job (make-test-job 0 "sleep" :args '("10") :pgid 4321)) (job-id (nshell.domain.job-control:monitor-add-job monitor job)) (set-foreground-calls nil) + (foreground-pgid 0) + (process-fns + (list :get-foreground-pgroup (lambda () 1000) + :set-foreground-pgroup + (lambda (pgid) + (push pgid set-foreground-calls)) + :set-foreground-pgid-state + (lambda (pgid) + (setf foreground-pgid (or pgid 0))))) (nshell.application:*shell-pgid* 1000) - (nshell.application:*foreground-job-pgid* nil) - (nshell.infrastructure.acl::*foreground-pgid* 0)) + (nshell.application:*foreground-job-pgid* nil)) (with-event-capture (continued dispatcher :job-continued) (nshell.domain.events:domain-event-type event) (with-temporary-functions @@ -128,15 +140,11 @@ (expect job-id :to-equal waited-job-id) (expect monitor :to-be waited-monitor) (expect 4321 :to-equal nshell.application:*foreground-job-pgid*) - (expect 4321 :to-equal nshell.infrastructure.acl::*foreground-pgid*) + (expect 4321 :to-equal foreground-pgid) waited-job)) - ('nshell.infrastructure.acl:get-foreground-pgroup - (lambda () 1000)) - ('nshell.infrastructure.acl:set-foreground-pgroup - (lambda (pgid) - (push pgid set-foreground-calls)))) + ) (expect job :to-be - (nshell.application:fg job-id dispatcher nil nil monitor))) + (nshell.application:fg job-id dispatcher nil nil monitor process-fns))) (expect (nshell.application:drain-events dispatcher) :to-be-null) (expect '(1000 4321) :to-equal set-foreground-calls) (expect '(:job-continued) :to-equal (nreverse continued))))) @@ -240,19 +248,19 @@ (let ((nshell.application:*shell-pgid* 1000) (nshell.application:*foreground-job-pgid* nil)) (with-temporary-function - ('nshell.infrastructure.acl:get-foreground-pgroup + ('nshell.application::%get-foreground-pgroup (lambda () 1000)) (expect (nshell.application::%foreground-signal-target-pgid) :to-be-null))) (let ((nshell.application:*shell-pgid* 1000) (nshell.application:*foreground-job-pgid* 1000)) (with-temporary-function - ('nshell.infrastructure.acl:get-foreground-pgroup + ('nshell.application::%get-foreground-pgroup (lambda () 2000)) (expect (nshell.application::%foreground-signal-target-pgid) :to-be-null))) (let ((nshell.application:*shell-pgid* 1000) (nshell.application:*foreground-job-pgid* nil)) (with-temporary-function - ('nshell.infrastructure.acl:get-foreground-pgroup + ('nshell.application::%get-foreground-pgroup (lambda () 2000)) (expect 2000 :to-equal (nshell.application::%foreground-signal-target-pgid))))) @@ -305,7 +313,7 @@ (calls nil)) (nshell.domain.job-control:background-job monitor job-id) (with-temporary-function - ((quote nshell.infrastructure.acl:kill-process) + ((quote nshell.application::%kill-process) (lambda (pid signal) (push (list pid signal) calls) 0)) @@ -371,14 +379,14 @@ (it "classifies wait errors without an OS wait" "Known wait errors become application events while unknown errors remain unclassified." (let* ((no-child - (nshell.application::%classify-job-wait-error sb-posix:echild)) + (nshell.application::%classify-job-wait-error :echild)) (interrupted - (nshell.application::%classify-job-wait-error sb-posix:eintr)) + (nshell.application::%classify-job-wait-error :eintr)) (unknown-errno - (1+ (max sb-posix:echild sb-posix:eintr)))) + (nshell.application::%classify-job-wait-error :enoent))) (expect :no-child :to-be (nshell.application::job-wait-event-state no-child)) (expect :interrupted :to-be (nshell.application::job-wait-event-state interrupted)) (expect nil :to-equal - (nshell.application::%classify-job-wait-error unknown-errno))))) + unknown-errno))))