diff --git a/lisp/eshell/em-basic.el b/lisp/eshell/em-basic.el index 54394614711..8147ae31afb 100644 --- a/lisp/eshell/em-basic.el +++ b/lisp/eshell/em-basic.el @@ -137,6 +137,8 @@ or `eshell-printn' for display." "To terminate with a newline, you should use -N instead.")) (eshell-echo args output-newline)))) +(put 'eshell/echo 'eshell-literal-result t) + (defun eshell/printnl (&rest args) "Print out each of the arguments as strings, separated by newlines." (let ((elems (flatten-tree args))) diff --git a/lisp/eshell/esh-cmd.el b/lisp/eshell/esh-cmd.el index 0282c00ae63..000c9329987 100644 --- a/lisp/eshell/esh-cmd.el +++ b/lisp/eshell/esh-cmd.el @@ -105,6 +105,7 @@ (require 'esh-proc) (require 'esh-module) (require 'esh-ext) +(require 'esh-worker) (require 'eldoc) (require 'generator) @@ -1585,7 +1586,20 @@ a string naming a Lisp function." (let* ((eshell-ensure-newline-p t) (command-form-p (and (functionp object) (symbolp object))) - result) + (literal-result (when command-form-p + (get object 'eshell-literal-result))) + result worker-result + (printer + (lambda (object) + (setq worker-result + (cond + ((and (not literal-result) (eshell-worker-p object)) + object) + ((and (not literal-result) + (memq eshell-in-pipeline-p '(t last)) + (eshell-get-pipe object))) + (t + (ignore (eshell-print-maybe-n object)))))))) (if command-form-p (setq eshell-last-arguments (eshell-convert-args args object) eshell-last-command-name (format "#" @@ -1593,7 +1607,7 @@ a string naming a Lisp function." (setq eshell-last-arguments args eshell-last-command-name "#")) (setq result (eshell-exec-lisp - #'eshell-print-maybe-n #'eshell-error-maybe-n + printer #'eshell-error-maybe-n object eshell-last-arguments (not command-form-p))) (when (memq eshell-in-pipeline-p '(nil last)) (eshell-set-exit-info @@ -1606,7 +1620,7 @@ a string naming a Lisp function." (not result)) 2) result)) - nil))) + worker-result))) (define-obsolete-function-alias 'eshell-lisp-command* #'eshell-lisp-command "31.1") diff --git a/lisp/eshell/esh-worker.el b/lisp/eshell/esh-worker.el new file mode 100644 index 00000000000..2a45d8e4d96 --- /dev/null +++ b/lisp/eshell/esh-worker.el @@ -0,0 +1,191 @@ +;;; esh-worker.el --- eshell workers -*- lexical-binding:t -*- + +;; Copyright (C) 2026 Free Software Foundation, Inc. + +;; This file is part of GNU Emacs. + +;; GNU Emacs is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; GNU Emacs is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: + +;; Workers are an Emacs Lisp-based analogue to external processes, +;; allowing for piping command output into them to perform some +;; action. For example, to uppercase the output of some other command: +;; +;; echo hi | #'upcase +;; +;;;_* `accumulate' worker +;; +;; Eshell provides several ways to pipe output into a Lisp function. +;; The simplest is the "accumulate" worker, which is the default +;; behavior when piping directly to a Lisp function as above. You can +;; also express this explicitly, as in: +;; +;; echo hi | accumulate #'upcase + +;;; Code: + +(require 'esh-io) + +(defgroup eshell-worker nil + "Eshell workers provide a way to construct process-like objects in Emacs +Lisp that can serve as pipe targets, allowing you to manipulating other +command's output with ordinary Lisp." + :tag "Worker support" + :group 'eshell) + +(cl-defstruct (eshell-worker + (:constructor eshell-worker-create) + (:copier nil)) + "A structure representing an abstract process-like object. +This can be inherited from to define the behavior of a running +task in Eshell." + (status 'run) + (eshell-buffer (progn + (cl-assert (derived-mode-p 'eshell-mode)) + (current-buffer)) + :read-only t) + (output-handles (eshell-duplicate-handles eshell-current-handles)) + (ensure-newline-p t)) + +(cl-defgeneric eshell-get-pipe (_object) + "Get the pipe associated with OBJECT, if any." + nil) + +(cl-defmethod eshell-get-pipe ((object eshell-worker)) object) +(cl-defmethod eshell-get-pipe ((object buffer)) object) +(cl-defmethod eshell-get-pipe ((object marker)) object) +(cl-defmethod eshell-get-pipe ((object process)) object) + +(cl-defmethod eshell-get-pipe :extra "function" (object) + (if (functionp object) + (eshell/accumulate object) + (cl-call-next-method))) + +(cl-defmethod eshell-task-p ((_object eshell-worker)) + t) + +(cl-defmethod eshell-task-status ((task eshell-worker)) + (eshell-worker-status task)) + +(cl-defmethod eshell-task-active-p ((task eshell-worker)) + (eq (eshell-worker-status task) 'run)) + +(cl-defmethod eshell-get-target ((raw-target eshell-worker) &optional _mode) + "Convert a raw `eshell-worker' RAW-TARGET into a valid output target. +This just returns RAW-TARGET." + raw-target) + +(cl-defmethod eshell-output-object-to-target :around + (_object (target eshell-worker)) + "Output OBJECT to the Eshell worker TARGET. +This method is called around any primary method and is responsible +for let-binding the proper value for `eshell-current-handles'." + (unless (eq (eshell-worker-status target) 'run) + (signal 'eshell-pipe-broken target)) + (let ((eshell-current-handles (eshell-worker-output-handles target)) + (eshell-ensure-newline-p (eshell-worker-ensure-newline-p target))) + (with-current-buffer (eshell-worker-eshell-buffer target) + (cl-call-next-method)) + (setf (eshell-worker-ensure-newline-p target) eshell-ensure-newline-p))) + +(cl-defmethod eshell-close-target :around ((target eshell-worker) _status) + "Close the worker PROC, passing STATUS as the result. +This method is called around any primary method and is responsible +for let-binding the proper value for `eshell-current-handles', +closing the handles when done, and calling +`eshell-kill-process-function'." + (when (eq (eshell-worker-status target) 'run) + (let ((eshell-current-handles (eshell-worker-output-handles target)) + (eshell-ensure-newline-p (eshell-worker-ensure-newline-p target))) + (with-current-buffer (eshell-worker-eshell-buffer target) + (cl-call-next-method) + (setf (eshell-worker-status target) 'exit) + (eshell-close-handles) + (declare-function eshell-kill-process-function "esh-proc" (proc status)) + (eshell-kill-process-function target "finished\n"))))) + +(defun eshell--apply-print (function args) + "Call FUNCTION with Eshell-converted ARGS and print the result." + (when-let* ((result (apply function (eshell-convert-args args function)))) + (eshell-print-maybe-n result) + result)) + +;; Accumulate worker + +(cl-defstruct (eshell-accumulate-worker + (:include eshell-worker) + (:constructor eshell-accumulate-worker-create)) + "A worker that calls a Lisp function once with all output. +When outputting data to this worker, it will accumulate all the text, +calling a Lisp FUNCTION once at the end. + +When outputting non-string data types to this worker (e.g. lists), it +will first convert the data to a string before concatenating it. If +this structure only receives a single non-string value as input, it will +pass the value unaltered to FUNCTION." + (function nil :read-only t) + (buffer-or-value nil)) + +(defsubst eshell-accumulate-worker--make-buffer (worker) + "Ensure WORKER has an internal buffer to add strings to." + (setf (eshell-accumulate-worker-buffer-or-value worker) + (generate-new-buffer " *eshell-worker*" t))) + +(cl-defmethod eshell-output-object-to-target + (object (target eshell-accumulate-worker)) + "Send OBJECT to the accumulate-worker TARGET. +This calls the function associated with the worker. + +The returned value is the OBJECT in the form that it was actually +sent to TARGET (e.g. a string representing OBJECT)." + (let ((buf-or-val (eshell-accumulate-worker-buffer-or-value target))) + (cond + ((bufferp buf-or-val) + (with-current-buffer buf-or-val + (insert (eshell-stringify object t)))) + (buf-or-val + (cl-assert (listp buf-or-val)) + (with-current-buffer (eshell-accumulate-worker--make-buffer target) + (insert (eshell-concat t (car buf-or-val) object)))) + ((stringp object) + (with-current-buffer (eshell-accumulate-worker--make-buffer target) + (insert object))) + (t + (setf (eshell-accumulate-worker-buffer-or-value target) + (list object)))))) + +(cl-defmethod eshell-close-target ((target eshell-accumulate-worker) _status) + "Close the accumulate-worker TARGET, flushing its buffer." + (let* ((function (eshell-accumulate-worker-function target)) + (buf-or-val (eshell-accumulate-worker-buffer-or-value target)) + (input (if (bufferp buf-or-val) + (with-current-buffer buf-or-val + (eshell-mark-numeric-string (buffer-string))) + (car buf-or-val)))) + (eshell--apply-print function (list input)))) + +(defun eshell/accumulate (function) + "Call FUNCTION once with all the accumulated Eshell output. +When outputting data to this worker, it will accumulate all the text, +calling FUNCTION once at the end. + +When outputting non-string data types to this worker (e.g. lists), it +will first convert the data to a string before concatenating it. If +this structure only receives a single non-string value as input, it will +pass the value unaltered to FUNCTION." + (eshell-accumulate-worker-create :function function)) + +(provide 'esh-worker) +;;; esh-worker.el ends here diff --git a/test/lisp/eshell/esh-worker-tests.el b/test/lisp/eshell/esh-worker-tests.el new file mode 100644 index 00000000000..831a3269480 --- /dev/null +++ b/test/lisp/eshell/esh-worker-tests.el @@ -0,0 +1,84 @@ +;;; esh-io-tests.el --- esh-io test suite -*- lexical-binding:t -*- + +;; Copyright (C) 2026 Free Software Foundation, Inc. + +;; This file is part of GNU Emacs. + +;; GNU Emacs is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; GNU Emacs is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Code: + +(require 'ert) +(require 'ert-x) +(require 'eshell) + +(require 'eshell-tests-helpers + (ert-resource-file "eshell-tests-helpers")) + +;;; Tests: + +(ert-deftest esh-worker-test/pipe/eshell-to-function () + "Test that piping an Eshell command to a function works." + (with-temp-eshell + (eshell-match-command-output "echo hi | #'upcase" "\\`HI\n\\'"))) + +(ert-deftest esh-worker-test/pipe/eshell-to-function/numeric () + "Test that piping an Eshell command to a function works." + (with-temp-eshell + (eshell-match-command-output "echo 42 | #'1+" "\\`43\n\\'"))) + +(ert-deftest esh-worker-test/pipe/external-to-function () + "Test that piping an external command to a function works." + (with-temp-eshell + (eshell-match-command-output "*echo hi | #'upcase" "\\`HI\n\\'"))) + +(ert-deftest esh-worker-test/pipe/multiple-to-function () + "Test that piping multiple output batches to a function works." + (with-temp-eshell + (eshell-match-command-output "{*echo hi; *echo bye} | #'upcase" + "\\`HI\nBYE\n\\'"))) + +(ert-deftest esh-worker-test/pipe/multiple-to-function/numeric () + "Test that piping multiple numeric output batches to a function works." + (with-temp-eshell + (eshell-match-command-output "{echo 1; echo 2} | #'1+" + "\\`13\n\\'"))) + +(ert-deftest esh-worker-test/pipe/eshell-to-lambda () + "Test that piping an Eshell command to a lambda works." + (with-temp-eshell + (eshell-match-command-output "echo hi | (lambda (i) (concat \"> \" i))" + "\\`> hi\n\\'"))) + +(ert-deftest esh-worker-test/pipe/external-to-lambda () + "Test that piping an external command to a lambda works." + (with-temp-eshell + (eshell-match-command-output "*echo hi | (lambda (i) (concat \"> \" i))" + "\\`> hi\n\\'"))) + +(ert-deftest esh-worker-test/pipe/multiple-to-lambda () + "Test that piping multiple output batches to a lambda works." + (with-temp-eshell + (eshell-match-command-output + "{*echo hi; *echo bye} | (lambda (i) (concat \"> \" i))" + "\\`> hi\nbye\n\\'"))) + +(ert-deftest esh-worker-test/pipe/multiple-pipes () + "Test that piping an Eshell command to a function works." + (with-temp-eshell + (eshell-match-command-output + "echo hi | #'upcase | (lambda (i) (concat \"> \" i))" + "\\`> HI\n\\'"))) + +;;; esh-io-tests.el ends here