From a1cbfe27b1b10d76de90a5c49ec783eb594453fa Mon Sep 17 00:00:00 2001 From: Jim Porter Date: Mon, 18 May 2026 15:55:19 -0700 Subject: [PATCH] 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. --- lisp/eshell/esh-worker.el | 104 ++++++++++++++++++++++++ test/lisp/eshell/esh-worker-tests.el | 115 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/lisp/eshell/esh-worker.el b/lisp/eshell/esh-worker.el index 2a45d8e4d96..79a329d2b61 100644 --- a/lisp/eshell/esh-worker.el +++ b/lisp/eshell/esh-worker.el @@ -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 diff --git a/test/lisp/eshell/esh-worker-tests.el b/test/lisp/eshell/esh-worker-tests.el index 831a3269480..7dfca9859cd 100644 --- a/test/lisp/eshell/esh-worker-tests.el +++ b/test/lisp/eshell/esh-worker-tests.el @@ -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