Add 'apply-lines' and 'map-lines' Eshell commands

* lisp/eshell/esh-worker.el (eshell-map-lines-worker)
(eshell-apply-lines-worker): New structs.
(eshell-map-lines-worker--apply): New generic function.
(eshell-output-object-to-target, eshell-close-target): New methods.
(eshell/map-lines, eshell/apply-lines): New functions.

* test/lisp/eshell/esh-worker-tests.el (eshell-test-line-number): New
defvar...
(eshell-test-number): ... use it in this new function.
(esh-worker-test/map-lines/eshell-one-line)
(esh-worker-test/map-lines/eshell-multiple-lines)
(esh-worker-test/map-lines/eshell-multiple-batches)
(esh-worker-test/map-lines/external-one-line)
(esh-worker-test/map-lines/external-multiple-lines)
(esh-worker-test/map-lines/numbers)
(esh-worker-test/map-lines/numeric-conversion)
(esh-worker-test/apply-lines/eshell-one-line)
(esh-worker-test/apply-lines/eshell-multiple-lines)
(esh-worker-test/apply-lines/external-one-line)
(esh-worker-test/apply-lines/external-multiple-lines)
(esh-worker-test/apply-lines/numbers)
(esh-worker-test/apply-lines/numeric-conversion): New tests.
This commit is contained in:
Jim Porter 2026-05-18 15:55:19 -07:00
parent 751dccb17e
commit a1cbfe27b1
2 changed files with 219 additions and 0 deletions

View file

@ -33,6 +33,25 @@
;; also express this explicitly, as in:
;;
;; echo hi | accumulate #'upcase
;;
;;;_* `map-lines' worker
;;
;; You can also map each line of output to a function, similar to how
;; `mapcar' works. For example, you could "quote" some output for
;; pasting into an email like this:
;;
;; cat some-file.txt | map-lines (lambda (i) (format "> %s" i))
;;
;; This also shows how you use lambda functions as pipe targets in
;; Eshell.
;;
;;;_* `apply-lines' worker
;;
;; Finally, you can apply each line of output as successive arguments to
;; a function. For example, to sum up a list of numbers written one per
;; line:
;;
;; cat numbers.txt | apply-lines #'+
;;; Code:
@ -187,5 +206,90 @@ 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))
;; Map-lines worker
(cl-defstruct (eshell-map-lines-worker
(:include eshell-worker)
(:constructor eshell-map-lines-worker-create))
"A worker that calls a Lisp function once for each line.
When outputting string data to this worker, it will call a Lisp
FUNCTION once per line of text. When outputting other data types to
this worker (e.g. lists), it will call FUNCTION once with the
specified value."
(function nil :read-only t)
(current-line nil))
(cl-defgeneric eshell-map-lines-worker--apply (line target)
(let ((function (eshell-map-lines-worker-function target)))
(eshell--apply-print function (list line))))
(cl-defmethod eshell-output-object-to-target
(object (target eshell-map-lines-worker))
"Send OBJECT to the map-lines 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)."
(if (stringp object)
(let (line-begin line-end line)
;; Prepend any saved text from the current line to the new text.
(setq object (concat (eshell-map-lines-worker-current-line target)
object))
;; Pass each full line of text to FUNCTION.
(while (setq line-end (string-search "\n" object line-begin))
(setq line (eshell-mark-numeric-string
(substring object line-begin line-end))
line-begin (1+ line-end))
(eshell-map-lines-worker--apply line target))
;; Save any remaining text after the last newline for next time.
(setf (eshell-map-lines-worker-current-line target)
(and (length> object (or line-begin 0))
(substring object line-begin))))
(eshell-map-lines-worker--apply object target)))
(cl-defmethod eshell-close-target ((target eshell-map-lines-worker) _status)
(when-let* ((last-line (eshell-map-lines-worker-current-line target)))
(eshell-map-lines-worker--apply (eshell-mark-numeric-string last-line)
target)))
(defun eshell/map-lines (function)
"Map each line of output of another command to FUNCTION.
When outputting string data to this worker, it will call FUNCTION once
per line of text. When outputting other data types to this
worker (e.g. lists), it will call FUNCTION once with the specified
value."
(eshell-map-lines-worker-create :function function))
;; Apply-lines worker
(cl-defstruct
(eshell-apply-lines-worker
(:include eshell-map-lines-worker)
(:constructor eshell-apply-lines-worker-create))
"A worker that calls a Lisp function with each line as an argument.
This worker calls a Lisp FUNCTION once, with each line of string data
corresponding to one argument passed to the fuction. When outputting
other data types to this worker (e.g. lists), each object is passed as a
single argument to FUNCTION."
args) ; Arguments stored in reverse order
(cl-defmethod eshell-map-lines-worker--apply
(line (target eshell-apply-lines-worker))
(push line (eshell-apply-lines-worker-args target)))
(cl-defmethod eshell-close-target ((target eshell-apply-lines-worker) _status)
(cl-call-next-method)
(let ((function (eshell-map-lines-worker-function target)))
(eshell--apply-print
function (nreverse (eshell-apply-lines-worker-args target)))))
(defun eshell/apply-lines (function)
"Call a Lisp FUNCTION with each line of output as an argument.
This worker calls a Lisp FUNCTION once, with each line of string data
corresponding to one argument passed to the fuction. When outputting
other data types to this worker (e.g. lists), each object is passed as a
single argument to FUNCTION."
(eshell-apply-lines-worker-create :function function))
(provide 'esh-worker)
;;; esh-worker.el ends here

View file

@ -26,8 +26,17 @@
(require 'eshell-tests-helpers
(ert-resource-file "eshell-tests-helpers"))
(defvar eshell-test-line-number nil)
(defun eshell-test-number (line)
"Return LINE with a line number prepended."
(format "%2d %s" (incf eshell-test-line-number) line))
;;; Tests:
;; Basic worker pipelines
(ert-deftest esh-worker-test/pipe/eshell-to-function ()
"Test that piping an Eshell command to a function works."
(with-temp-eshell
@ -81,4 +90,110 @@
"echo hi | #'upcase | (lambda (i) (concat \"> \" i))"
"\\`> HI\n\\'")))
;; `map-lines' pipelines
(ert-deftest esh-worker-test/map-lines/eshell-one-line ()
"Test that piping a single line to `map-lines' works."
(let ((eshell-test-line-number 0))
(with-temp-eshell
(eshell-match-command-output
"echo hi | map-lines #'eshell-test-number"
"\\` 1 hi\n\\'"))))
(ert-deftest esh-worker-test/map-lines/eshell-multiple-lines ()
"Test that piping multiple lines to `map-lines' works.
It should call the mapped function once per line."
(let ((eshell-test-line-number 0))
(with-temp-eshell
(eshell-match-command-output
"echo 'hi\nbye' | map-lines #'eshell-test-number"
"\\` 1 hi\n 2 bye\n\\'"))))
(ert-deftest esh-worker-test/map-lines/eshell-multiple-batches ()
"Test that piping multiple batches of lines to `map-lines' works.
It should call the mapped function once per line, reassembling lines as
needed."
(let ((eshell-test-line-number 0))
(with-temp-eshell
(eshell-match-command-output
"{echo 'hi\nhel'; echo 'lo\nhey'} | map-lines #'eshell-test-number"
"\\` 1 hi\n 2 hello\n 3 hey\n\\'"))))
(ert-deftest esh-worker-test/map-lines/external-one-line ()
"Test that piping a single external line to `map-lines' works."
(let ((eshell-test-line-number 0))
(with-temp-eshell
(eshell-match-command-output
"*echo hi | map-lines #'eshell-test-number"
"\\` 1 hi\n\\'"))))
(ert-deftest esh-worker-test/map-lines/external-multiple-lines ()
"Test that piping multiple external lines to `map-lines' works.
It should call the mapped function once per line."
(let ((eshell-test-line-number 0))
(with-temp-eshell
(eshell-match-command-output
"*echo 'hi\nbye' | map-lines #'eshell-test-number"
"\\` 1 hi\n 2 bye\n\\'"))))
(ert-deftest esh-worker-test/map-lines/numbers ()
"Test that piping numbers to `map-lines' passes them to the mapped function."
(with-temp-eshell
(eshell-match-command-output
"{echo 10; echo 20} | map-lines #'1+"
"\\`11\n21\n\\'")))
(ert-deftest esh-worker-test/map-lines/numeric-conversion ()
"Test that `map-lines' converts numeric strings when possible."
(with-temp-eshell
(eshell-match-command-output
"{echo '10\n1'; echo '5\n20'} | map-lines #'1+"
"\\`11\n16\n21\n\\'")))
;; `apply-lines' pipelines
(ert-deftest esh-worker-test/apply-lines/eshell-one-line ()
"Test that piping a single line to `apply-lines' works."
(with-temp-eshell
(eshell-match-command-output "echo hi | apply-lines #'upcase"
"\\`HI\n\\'")))
(ert-deftest esh-worker-test/apply-lines/eshell-multiple-lines ()
"Test that piping multiple lines to `apply-lines' works.
It should pass each line as an argument to the applied function."
(with-temp-eshell
(eshell-match-command-output
"echo 'o\ni\nfoobar' | apply-lines #'string-replace"
"\\`fiibar\n\\'")))
(ert-deftest esh-worker-test/apply-lines/external-one-line ()
"Test that piping a single external line to `apply-lines' works."
(with-temp-eshell
(eshell-match-command-output "*echo hi | apply-lines #'upcase"
"\\`HI\n\\'")))
(ert-deftest esh-worker-test/apply-lines/external-multiple-lines ()
"Test that piping multiple external lines to `apply-lines' works.
It should pass each line as an argument to the applied function."
(with-temp-eshell
(eshell-match-command-output
"*echo 'o\ni\nfoobar' | apply-lines #'string-replace"
"\\`fiibar\n\\'")))
(ert-deftest esh-worker-test/apply-lines/numbers ()
"Test that piping numbers to `apply-lines' passes them to the function."
(with-temp-eshell
(eshell-match-command-output
"{echo 5; echo 8; echo 13} | apply-lines #'+"
"\\`26\n\\'")))
(ert-deftest esh-worker-test/apply-lines/numeric-conversion ()
"Test that `apply-lines' converts numeric strings when possible."
(with-temp-eshell
(eshell-match-command-output
"{echo '10\n1'; echo '5\n20'} | apply-lines #'+"
"\\`45\n\\'")))
;;; esh-io-tests.el ends here