From: Jim Porter Date: Thu, 30 May 2024 02:21:09 +0000 (-0700) Subject: Make Eshell's "which" command extensible X-Git-Url: http://git.eshelyaron.com/gitweb/?a=commitdiff_plain;h=8d52bc5873e8d430a8e9f0525544f6b6de1813b4;p=emacs.git Make Eshell's "which" command extensible Since 'eshell-named-command-hook' already makes execution of commands extensible, "which" should be too. This makes sure that "which" returns the right result for quoted commands like "/:cat". * lisp/eshell/em-alias.el (eshell-aliases-file): Allow it to be nil. (eshell-read-aliases-list, eshell-write-aliases-list): Check if 'eshell-aliases-file' is nil. (eshell-maybe-replace-by-alias--which): New function... (eshell-maybe-replace-by-alias): ... use it. * lisp/eshell/esh-cmd.el (eshell-named-command-hook): Update docstring. (eshell/which): Make extensible. (eshell--find-plain-lisp-command, eshell-plain-command--which): New functions. (eshell-plain-command): Use 'eshell--find-plain-lisp-command'. * lisp/eshell/esh-ext.el (eshell-explicit-command--which): New function... (eshell-explicit-command): ... unise it. (eshell-quoted-file-command--which): New function... (eshell-quoted-file-command): ... use it. (eshell-external-command--which): New function. * test/lisp/eshell/esh-cmd-tests.el (esh-cmd-test/which/plain/eshell-builtin) (esh-cmd-test/which/plain/external-program) (esh-cmd-test/which/plain/not-found, esh-cmd-test/which/alias) (esh-cmd-test/which/explicit, esh-cmd-test/which/explicit/not-found) (esh-cmd-test/which/quoted-file) (esh-cmd-test/which/quoted-file/not-found): New tests. * test/lisp/eshell/eshell-tests-helpers.el (with-temp-eshell-settings): Don't load or save aliases. (eshell-command-result--match,eshell-command-result--match-explainer) (eshell-command-result-match): New functions. (cherry picked from commit 1df3554f0766c91a9452cd40f74f469ed612eda6) --- diff --git a/lisp/eshell/em-alias.el b/lisp/eshell/em-alias.el index d12b382d885..ff0620702cf 100644 --- a/lisp/eshell/em-alias.el +++ b/lisp/eshell/em-alias.el @@ -107,7 +107,9 @@ it will be written to this file. Thus, alias definitions (and deletions) are always permanent. This approach was chosen for the sake of simplicity, since that's pretty much the only benefit to be gained by using this module." - :type 'file + :version "30.1" + :type '(choice (const :tag "Don't save aliases" nil) + file) :group 'eshell-alias) (defcustom eshell-bad-command-tolerance 3 @@ -186,29 +188,30 @@ file named by `eshell-aliases-file'.") "Read in an aliases list from `eshell-aliases-file'. This is useful after manually editing the contents of the file." (interactive) - (let ((file eshell-aliases-file)) - (when (file-readable-p file) - (setq eshell-command-aliases-list - (with-temp-buffer - (let (eshell-command-aliases-list) - (insert-file-contents file) - (while (not (eobp)) - (if (re-search-forward - "^alias\\s-+\\(\\S-+\\)\\s-+\\(.+\\)") - (setq eshell-command-aliases-list - (cons (list (match-string 1) - (match-string 2)) - eshell-command-aliases-list))) - (forward-line 1)) - eshell-command-aliases-list)))))) + (when (and eshell-aliases-file + (file-readable-p eshell-aliases-file)) + (setq eshell-command-aliases-list + (with-temp-buffer + (let (eshell-command-aliases-list) + (insert-file-contents eshell-aliases-file) + (while (not (eobp)) + (if (re-search-forward + "^alias\\s-+\\(\\S-+\\)\\s-+\\(.+\\)") + (setq eshell-command-aliases-list + (cons (list (match-string 1) + (match-string 2)) + eshell-command-aliases-list))) + (forward-line 1)) + eshell-command-aliases-list))))) (defun eshell-write-aliases-list () "Write out the current aliases into `eshell-aliases-file'." - (if (file-writable-p (file-name-directory eshell-aliases-file)) - (let ((eshell-current-handles - (eshell-create-handles eshell-aliases-file 'overwrite))) - (eshell/alias) - (eshell-close-handles 0 'nil)))) + (when (and eshell-aliases-file + (file-writable-p (file-name-directory eshell-aliases-file))) + (let ((eshell-current-handles + (eshell-create-handles eshell-aliases-file 'overwrite))) + (eshell/alias) + (eshell-close-handles 0 'nil)))) (defsubst eshell-lookup-alias (name) "Check whether NAME is aliased. Return the alias if there is one." @@ -216,18 +219,26 @@ This is useful after manually editing the contents of the file." (defvar eshell-prevent-alias-expansion nil) +(defun eshell-maybe-replace-by-alias--which (command) + (unless (and eshell-prevent-alias-expansion + (member command eshell-prevent-alias-expansion)) + (when-let ((alias (eshell-lookup-alias command))) + (concat command " is an alias, defined as \"" (cadr alias) "\"")))) + (defun eshell-maybe-replace-by-alias (command _args) "Call COMMAND's alias definition, if it exists." (unless (and eshell-prevent-alias-expansion (member command eshell-prevent-alias-expansion)) - (let ((alias (eshell-lookup-alias command))) - (if alias - (throw 'eshell-replace-command - `(let ((eshell-command-name ',eshell-last-command-name) - (eshell-command-arguments ',eshell-last-arguments) - (eshell-prevent-alias-expansion - ',(cons command eshell-prevent-alias-expansion))) - ,(eshell-parse-command (nth 1 alias)))))))) + (when-let ((alias (eshell-lookup-alias command))) + (throw 'eshell-replace-command + `(let ((eshell-command-name ',eshell-last-command-name) + (eshell-command-arguments ',eshell-last-arguments) + (eshell-prevent-alias-expansion + ',(cons command eshell-prevent-alias-expansion))) + ,(eshell-parse-command (nth 1 alias))))))) + +(put 'eshell-maybe-replace-by-alias 'eshell-which-function + #'eshell-maybe-replace-by-alias--which) (defun eshell-alias-completions (name) "Find all possible completions for NAME. diff --git a/lisp/eshell/esh-cmd.el b/lisp/eshell/esh-cmd.el index a093b7face9..ee9143db765 100644 --- a/lisp/eshell/esh-cmd.el +++ b/lisp/eshell/esh-cmd.el @@ -154,7 +154,8 @@ To prevent a command from executing at all, set :type 'hook) (defcustom eshell-named-command-hook nil - "A set of functions called before a named command is invoked. + "A set of functions called before +a named command is invoked. Each function will be passed the command name and arguments that were passed to `eshell-named-command'. @@ -173,7 +174,12 @@ For example: Although useless, the above code will cause any non-glob, non-Lisp command (i.e., `ls' as opposed to `*ls' or `(ls)') to be replaced by a -call to `cd' using the arguments that were passed to the function." +call to `cd' using the arguments that were passed to the function. + +When adding a function to this hook, you should also set the property +`eshell-which-function' for the function. This property should hold a +function that takes a single COMMAND argument and returns a string +describing where Eshell will find the function." :type 'hook) (defcustom eshell-pre-rewrite-command-hook @@ -1299,34 +1305,18 @@ have been replaced by constants." (defun eshell/which (command &rest names) "Identify the COMMAND, and where it is located." (dolist (name (cons command names)) - (let (program alias direct) - (if (eq (aref name 0) eshell-explicit-command-char) - (setq name (substring name 1) - direct t)) - (if (and (not direct) - (fboundp 'eshell-lookup-alias) - (setq alias - (eshell-lookup-alias name))) - (setq program - (concat name " is an alias, defined as \"" - (cadr alias) "\""))) - (unless program - (setq program - (let* ((esym (eshell-find-alias-function name)) - (sym (or esym (intern-soft name)))) - (if (and (or esym (and sym (fboundp sym))) - (or eshell-prefer-lisp-functions (not direct))) - (or (with-output-to-string - (require 'help-fns) - (princ (format "%s is " sym)) - (help-fns-function-description-header sym)) - name) - (eshell-search-path name))))) - (if (not program) - (eshell-error (format "which: no %s in (%s)\n" - name (string-join (eshell-get-path t) - (path-separator)))) - (eshell-printn program))))) + (condition-case error + (eshell-printn + (catch 'found + (run-hook-wrapped + 'eshell-named-command-hook + (lambda (hook) + (when-let (((symbolp hook)) + (which-func (get hook 'eshell-which-function)) + (result (funcall which-func command))) + (throw 'found result)))) + (eshell-plain-command--which name))) + (error (eshell-error (format "which: %s\n" (cadr error))))))) (put 'eshell/which 'eshell-no-numeric-conversions t) @@ -1376,17 +1366,31 @@ COMMAND may result in an alias being executed, or a plain command." (if (functionp sym) sym)))) +(defun eshell--find-plain-lisp-command (command) + "Look for `eshell/COMMAND' and return it when COMMAND should use it." + (let* ((esym (eshell-find-alias-function command)) + (sym (or esym (intern-soft command)))) + (when (and sym (fboundp sym) + (or esym eshell-prefer-lisp-functions + (not (eshell-search-path command)))) + sym))) + +(defun eshell-plain-command--which (command) + (if-let ((sym (eshell--find-plain-lisp-command command))) + (or (with-output-to-string + (require 'help-fns) + (princ (format "%s is " sym)) + (help-fns-function-description-header sym)) + command) + (eshell-external-command--which command))) + (defun eshell-plain-command (command args) "Insert output from a plain COMMAND, using ARGS. COMMAND may result in either a Lisp function being executed by name, or an external command." - (let* ((esym (eshell-find-alias-function command)) - (sym (or esym (intern-soft command)))) - (if (and sym (fboundp sym) - (or esym eshell-prefer-lisp-functions - (not (eshell-search-path command)))) - (eshell-lisp-command sym args) - (eshell-external-command command args)))) + (if-let ((sym (eshell--find-plain-lisp-command command))) + (eshell-lisp-command sym args) + (eshell-external-command command args))) (defun eshell-exec-lisp (printer errprint func-or-form args form-p) "Execute a Lisp FUNC-OR-FORM, maybe passing ARGS. diff --git a/lisp/eshell/esh-ext.el b/lisp/eshell/esh-ext.el index df8f7198917..3c4deb32601 100644 --- a/lisp/eshell/esh-ext.el +++ b/lisp/eshell/esh-ext.el @@ -182,6 +182,11 @@ commands on your local host by using the \"/local:\" prefix, like (add-hook 'eshell-named-command-hook #'eshell-quoted-file-command nil t) (add-hook 'eshell-named-command-hook #'eshell-explicit-command nil t)) +(defun eshell-explicit-command--which (command) + (when (and (> (length command) 1) + (eq (aref command 0) eshell-explicit-command-char)) + (eshell-external-command--which (substring command 1)))) + (defun eshell-explicit-command (command args) "If a command name begins with \"*\", always call it externally. This bypasses all Lisp functions and aliases." @@ -194,6 +199,13 @@ This bypasses all Lisp functions and aliases." (error "%s: external command not found" (substring command 1)))))) +(put 'eshell-explicit-command 'eshell-which-function + #'eshell-explicit-command--which) + +(defun eshell-quoted-file-command--which (command) + (when (file-name-quoted-p command) + (eshell-external-command--which (file-name-unquote command)))) + (defun eshell-quoted-file-command (command args) "If a command name begins with \"/:\", always call it externally. Similar to `eshell-explicit-command', this bypasses all Lisp functions @@ -201,6 +213,9 @@ and aliases, but it also ignores file name handlers." (when (file-name-quoted-p command) (eshell-external-command (file-name-unquote command) args))) +(put 'eshell-quoted-file-command 'eshell-which-function + #'eshell-quoted-file-command--which) + (defun eshell-remote-command (command args) "Insert output from a remote COMMAND, using ARGS. A \"remote\" command in Eshell is something that executes on a different @@ -239,6 +254,11 @@ current working directory." (eshell-gather-process-output (car interp) (append (cdr interp) args))))) +(defun eshell-external-command--which (command) + (or (eshell-search-path command) + (error "no %s in (%s)" command + (string-join (eshell-get-path t) (path-separator))))) + (defun eshell-external-command (command args) "Insert output from an external COMMAND, using ARGS." (cond diff --git a/test/lisp/eshell/esh-cmd-tests.el b/test/lisp/eshell/esh-cmd-tests.el index d84f8802bdc..4d27f6db2ee 100644 --- a/test/lisp/eshell/esh-cmd-tests.el +++ b/test/lisp/eshell/esh-cmd-tests.el @@ -517,4 +517,50 @@ NAME is the name of the test case." ;; Make sure we can call another command after throwing. (eshell-match-command-output "echo again" "\\`again\n"))) + +;; `which' command + +(ert-deftest esh-cmd-test/which/plain/eshell-builtin () + "Check that `which' finds Eshell built-in functions." + (eshell-command-result-match "which cat" "\\`eshell/cat")) + +(ert-deftest esh-cmd-test/which/plain/external-program () + "Check that `which' finds external programs." + (skip-unless (executable-find "sh")) + (eshell-command-result-equal "which sh" + (concat (executable-find "sh") "\n"))) + +(ert-deftest esh-cmd-test/which/plain/not-found () + "Check that `which' reports an error for not-found commands." + (skip-when (executable-find "nonexist")) + (eshell-command-result-match "which nonexist" "\\`which: no nonexist in")) + +(ert-deftest esh-cmd-test/which/alias () + "Check that `which' finds aliases." + (with-temp-eshell + (eshell-insert-command "alias cat '*cat $@*'") + (eshell-match-command-output "which cat" "\\`cat is an alias"))) + +(ert-deftest esh-cmd-test/which/explicit () + "Check that `which' finds explicitly-external programs." + (skip-unless (executable-find "cat")) + (eshell-command-result-match "which *cat" + (concat (executable-find "cat") "\n"))) + +(ert-deftest esh-cmd-test/which/explicit/not-found () + "Check that `which' reports an error for not-found explicit commands." + (skip-when (executable-find "nonexist")) + (eshell-command-result-match "which *nonexist" "\\`which: no nonexist in")) + +(ert-deftest esh-cmd-test/which/quoted-file () + "Check that `which' finds programs with quoted file names." + (skip-unless (executable-find "cat")) + (eshell-command-result-match "which /:cat" + (concat (executable-find "cat") "\n"))) + +(ert-deftest esh-cmd-test/which/quoted-file/not-found () + "Check that `which' reports an error for not-found quoted commands." + (skip-when (executable-find "nonexist")) + (eshell-command-result-match "which /:nonexist" "\\`which: no nonexist in")) + ;; esh-cmd-tests.el ends here diff --git a/test/lisp/eshell/eshell-tests-helpers.el b/test/lisp/eshell/eshell-tests-helpers.el index a15fe611676..bfd829c95e9 100644 --- a/test/lisp/eshell/eshell-tests-helpers.el +++ b/test/lisp/eshell/eshell-tests-helpers.el @@ -30,6 +30,8 @@ (require 'esh-mode) (require 'eshell) +(defvar eshell-aliases-file nil) +(defvar eshell-command-aliases-list nil) (defvar eshell-history-file-name nil) (defvar eshell-last-dir-ring-file-name nil) @@ -60,6 +62,8 @@ beginning of the test file." ;; just enable this selectively when needed.) See also ;; `eshell-test-command-result' below. (eshell-debug-command (cons 'process eshell-debug-command)) + (eshell-aliases-file nil) + (eshell-command-aliases-list nil) (eshell-history-file-name nil) (eshell-last-dir-ring-file-name nil) (eshell-module-loading-messages nil)) @@ -196,6 +200,28 @@ inserting the command." (eshell-test-command-result command) result))))) +(defun eshell-command-result--match (_command regexp actual) + "Compare the ACTUAL result of a COMMAND with REGEXP." + (string-match regexp actual)) + +(defun eshell-command-result--match-explainer (command regexp actual) + "Explain the result of `eshell-command-result--match'." + `(mismatched-result + (command ,command) + (result ,actual) + (regexp ,regexp))) + +(put 'eshell-command-result--match 'ert-explainer + #'eshell-command-result--match-explainer) + +(defun eshell-command-result-match (command regexp) + "Execute COMMAND non-interactively and compare it to REGEXP." + (ert-info (#'eshell-get-debug-logs :prefix "Command logs: ") + (let ((eshell-module-loading-messages nil)) + (should (eshell-command-result--match + command regexp + (eshell-test-command-result command)))))) + (provide 'eshell-tests-helpers) ;;; eshell-tests-helpers.el ends here