From bbb92dde01ec3fc46b24247fb2d181a21dbcc40a Mon Sep 17 00:00:00 2001 From: Jim Porter Date: Tue, 8 Mar 2022 17:07:26 -0800 Subject: [PATCH] Add unit tests and documentation for Eshell pattern-based globs * lisp/eshell/em-glob.el (eshell-extended-glob): Fix docstring. (eshell-glob-entries): Refer to '**/' in error (technically, '**' can end a glob, but it means the same thing as '*'). (Bug#54470) * test/lisp/eshell/em-glob-tests.el: New file. * doc/misc/eshell.texi (Globbing): Document pattern-based globs. --- doc/misc/eshell.texi | 94 ++++++++++++++-- lisp/eshell/em-glob.el | 14 ++- test/lisp/eshell/em-glob-tests.el | 171 ++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 test/lisp/eshell/em-glob-tests.el diff --git a/doc/misc/eshell.texi b/doc/misc/eshell.texi index 372e4c3ffbd..648917f62d1 100644 --- a/doc/misc/eshell.texi +++ b/doc/misc/eshell.texi @@ -1089,15 +1089,91 @@ the result of @var{expr} is not a string or a sequence. @node Globbing @section Globbing -Eshell's globbing syntax is very similar to that of Zsh. Users coming -from Bash can still use Bash-style globbing, as there are no -incompatibilities. Most globbing is pattern-based expansion, but there -is also predicate-based expansion. @xref{Filename Generation, , , -zsh, The Z Shell Manual}, -for full syntax. To customize the syntax and behavior of globbing in -Eshell see the Customize@footnote{@xref{Easy Customization, , , emacs, -The GNU Emacs Manual}.} -groups ``eshell-glob'' and ``eshell-pred''. +@vindex eshell-glob-case-insensitive +Eshell's globbing syntax is very similar to that of Zsh +(@pxref{Filename Generation, , , zsh, The Z Shell Manual}). Users +coming from Bash can still use Bash-style globbing, as there are no +incompatibilities. + +By default, globs are case sensitive, except on MS-DOS/MS-Windows +systems. You can control this behavior via the +@code{eshell-glob-case-insensitive} option. You can further customize +the syntax and behavior of globbing in Eshell via the Customize group +``eshell-glob'' (@pxref{Easy Customization, , , emacs, The GNU Emacs +Manual}). + +@table @samp + +@item * +Matches any string (including the empty string). For example, +@samp{*.el} matches any file with the @file{.el} extension. + +@item ? +Matches any single character. For example, @samp{?at} matches +@file{cat} and @file{bat}, but not @file{goat}. + +@item **/ +Matches zero or more subdirectories in a file name. For example, +@samp{**/foo.el} matches @file{foo.el}, @file{bar/foo.el}, +@file{bar/baz/foo.el}, etc. Note that this cannot be combined with +any other patterns in the same file name segment, so while +@samp{foo/**/bar.el} is allowed, @samp{foo**/bar.el} is not. + +@item ***/ +Like @samp{**/}, but follows symlinks as well. + +@cindex character sets, in Eshell glob patterns +@cindex character classes, in Eshell glob patterns +@item [ @dots{} ] +Defines a @dfn{character set} (@pxref{Regexps, , , emacs, The GNU +Emacs Manual}). A character set matches characters between the two +brackets; for example, @samp{[ad]} matches @file{a} and @file{d}. You +can also include ranges of characters in the set by separating the +start and end with @samp{-}. Thus, @samp{[a-z]} matches any +lower-case @acronym{ASCII} letter. Note that, unlike in Zsh, +character ranges are interpreted in the Unicode codepoint order, not +in the locale-dependent collation order. + +Additionally, you can include @dfn{character classes} in a character +set. A @samp{[:} and balancing @samp{:]} enclose a character class +inside a character set. For instance, @samp{[[:alnum:]]} +matches any letter or digit. @xref{Char Classes, , , elisp, The Emacs +Lisp Reference Manual}, for a list of character classes. + +@cindex complemented character sets, in Eshell glob patterns +@item [^ @dots{} ] +Defines a @dfn{complemented character set}. This behaves just like a +character set, but matches any character @emph{except} the ones +specified. + +@cindex groups, in Eshell glob patterns +@item ( @dots{} ) +Defines a @dfn{group}. A group matches the pattern between @samp{(} +and @samp{)}. Note that a group can only match a single file name +component, so a @samp{/} inside a group will signal an error. + +@item @var{x}|@var{y} +Inside of a group, matches either @var{x} or @var{y}. For example, +@samp{e(m|sh)-*} matches any file beginning with @file{em-} or +@file{esh-}. + +@item @var{x}# +Matches zero or more copies of the glob pattern @var{x}. For example, +@samp{fo#.el} matches @file{f.el}, @file{fo.el}, @file{foo.el}, etc. + +@item @var{x}## +Matches one or more copies of the glob pattern @var{x}. Thus, +@samp{fo#.el} matches @file{fo.el}, @file{foo.el}, @file{fooo.el}, +etc. + +@item @var{x}~@var{y} +Matches anything that matches the pattern @var{x} but not @var{y}. For +example, @samp{[[:digit:]]#~4?} matches @file{1} and @file{12}, but +not @file{42}. Note that unlike in Zsh, only a single @samp{~} +operator can be used in a pattern, and it cannot be inside of a group +like @samp{(@var{x}~@var{y})}. + +@end table @node Input/Output @chapter Input/Output diff --git a/lisp/eshell/em-glob.el b/lisp/eshell/em-glob.el index 842f27a4920..52531ff8939 100644 --- a/lisp/eshell/em-glob.el +++ b/lisp/eshell/em-glob.el @@ -233,7 +233,10 @@ resulting regular expression." "\\'"))) (defun eshell-extended-glob (glob) - "Return a list of files generated from GLOB, perhaps looking for DIRS-ONLY. + "Return a list of files matched by GLOB. +If no files match, signal an error (if `eshell-error-if-no-glob' +is non-nil), or otherwise return GLOB itself. + This function almost fully supports zsh style filename generation syntax. Things that are not supported are: @@ -243,12 +246,7 @@ syntax. Things that are not supported are: foo~x(a|b) (a|b) will be interpreted as a predicate/modifier list Mainly they are not supported because file matching is done with Emacs -regular expressions, and these cannot support the above constructs. - -If this routine fails, it returns nil. Otherwise, it returns a list -the form: - - (INCLUDE-REGEXP EXCLUDE-REGEXP (PRED-FUNC-LIST) (MOD-FUNC-LIST))" +regular expressions, and these cannot support the above constructs." (let ((paths (eshell-split-path glob)) eshell-glob-matches message-shown) (unwind-protect @@ -287,7 +285,7 @@ the form: glob (car globs) len (length glob))))) (if (and recurse-p (not glob)) - (error "`**' cannot end a globbing pattern")) + (error "`**/' cannot end a globbing pattern")) (let ((index 1)) (setq incl glob) (while (and (eq incl glob) diff --git a/test/lisp/eshell/em-glob-tests.el b/test/lisp/eshell/em-glob-tests.el new file mode 100644 index 00000000000..9976b32ffe7 --- /dev/null +++ b/test/lisp/eshell/em-glob-tests.el @@ -0,0 +1,171 @@ +;;; em-glob-tests.el --- em-glob test suite -*- lexical-binding:t -*- + +;; Copyright (C) 2022 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: + +;; Tests for Eshell's glob expansion. + +;;; Code: + +(require 'ert) +(require 'em-glob) + +(defmacro with-fake-files (files &rest body) + "Evaluate BODY forms, pretending that FILES exist on the filesystem. +FILES is a list of file names that should be reported as +appropriate by `file-name-all-completions'. Any file name +component ending in \"symlink\" is treated as a symbolic link." + (declare (indent 1)) + `(cl-letf (((symbol-function 'file-name-all-completions) + (lambda (file directory) + (cl-assert (string= file "")) + (setq directory (expand-file-name directory)) + `("./" "../" + ,@(delete-dups + (remq nil + (mapcar + (lambda (file) + (setq file (expand-file-name file)) + (when (string-prefix-p directory file) + (replace-regexp-in-string + "/.*" "/" + (substring file (length directory))))) + ,files)))))) + ((symbol-function 'file-symlink-p) + (lambda (file) + (string-suffix-p "symlink" file)))) + ,@body)) + +;;; Tests: + +(ert-deftest em-glob-test/match-any-string () + "Test that \"*\" pattern matches any string." + (with-fake-files '("a.el" "b.el" "c.txt" "dir/a.el") + (should (equal (eshell-extended-glob "*.el") + '("a.el" "b.el"))))) + +(ert-deftest em-glob-test/match-any-character () + "Test that \"?\" pattern matches any character." + (with-fake-files '("a.el" "b.el" "ccc.el" "d.txt" "dir/a.el") + (should (equal (eshell-extended-glob "?.el") + '("a.el" "b.el"))))) + +(ert-deftest em-glob-test/match-recursive () + "Test that \"**/\" recursively matches directories." + (with-fake-files '("a.el" "b.el" "ccc.el" "d.txt" "dir/a.el" "dir/sub/a.el" + "dir/symlink/a.el" "symlink/a.el" "symlink/sub/a.el") + (should (equal (eshell-extended-glob "**/a.el") + '("a.el" "dir/a.el" "dir/sub/a.el"))))) + +(ert-deftest em-glob-test/match-recursive-follow-symlinks () + "Test that \"***/\" recursively matches directories, following symlinks." + (with-fake-files '("a.el" "b.el" "ccc.el" "d.txt" "dir/a.el" "dir/sub/a.el" + "dir/symlink/a.el" "symlink/a.el" "symlink/sub/a.el") + (should (equal (eshell-extended-glob "***/a.el") + '("a.el" "dir/a.el" "dir/sub/a.el" "dir/symlink/a.el" + "symlink/a.el" "symlink/sub/a.el"))))) + +(ert-deftest em-glob-test/match-recursive-mixed () + "Test combination of \"**/\" and \"***/\"." + (with-fake-files '("dir/a.el" "dir/sub/a.el" "dir/sub2/a.el" + "dir/symlink/a.el" "dir/sub/symlink/a.el" "symlink/a.el" + "symlink/sub/a.el" "symlink/sub/symlink/a.el") + (should (equal (eshell-extended-glob "**/sub/***/a.el") + '("dir/sub/a.el" "dir/sub/symlink/a.el"))) + (should (equal (eshell-extended-glob "***/sub/**/a.el") + '("dir/sub/a.el" "symlink/sub/a.el"))))) + +(ert-deftest em-glob-test/match-character-set-individual () + "Test \"[...]\" for individual characters." + (with-fake-files '("a.el" "b.el" "c.el" "d.el" "dir/a.el") + (should (equal (eshell-extended-glob "[ab].el") + '("a.el" "b.el"))) + (should (equal (eshell-extended-glob "[^ab].el") + '("c.el" "d.el"))))) + +(ert-deftest em-glob-test/match-character-set-range () + "Test \"[...]\" for character ranges." + (with-fake-files '("a.el" "b.el" "c.el" "d.el" "dir/a.el") + (should (equal (eshell-extended-glob "[a-c].el") + '("a.el" "b.el" "c.el"))) + (should (equal (eshell-extended-glob "[^a-c].el") + '("d.el"))))) + +(ert-deftest em-glob-test/match-character-set-class () + "Test \"[...]\" for character classes." + (with-fake-files '("1.el" "a.el" "b.el" "c.el" "dir/a.el") + (should (equal (eshell-extended-glob "[[:alpha:]].el") + '("a.el" "b.el" "c.el"))) + (should (equal (eshell-extended-glob "[^[:alpha:]].el") + '("1.el"))))) + +(ert-deftest em-glob-test/match-character-set-mixed () + "Test \"[...]\" with multiple kinds of members at once." + (with-fake-files '("1.el" "a.el" "b.el" "c.el" "d.el" "dir/a.el") + (should (equal (eshell-extended-glob "[ac-d[:digit:]].el") + '("1.el" "a.el" "c.el" "d.el"))) + (should (equal (eshell-extended-glob "[^ac-d[:digit:]].el") + '("b.el"))))) + +(ert-deftest em-glob-test/match-group-alternative () + "Test \"(x|y)\" matches either \"x\" or \"y\"." + (with-fake-files '("em-alias.el" "em-banner.el" "esh-arg.el" "misc.el" + "test/em-xtra.el") + (should (equal (eshell-extended-glob "e(m|sh)-*.el") + '("em-alias.el" "em-banner.el" "esh-arg.el"))))) + +(ert-deftest em-glob-test/match-n-or-more-characters () + "Test that \"x#\" and \"x#\" match zero or more instances of \"x\"." + (with-fake-files '("h.el" "ha.el" "hi.el" "hii.el" "dir/hi.el") + (should (equal (eshell-extended-glob "hi#.el") + '("h.el" "hi.el" "hii.el"))) + (should (equal (eshell-extended-glob "hi##.el") + '("hi.el" "hii.el"))))) + +(ert-deftest em-glob-test/match-n-or-more-groups () + "Test that \"(x)#\" and \"(x)#\" match zero or more instances of \"(x)\"." + (with-fake-files '("h.el" "ha.el" "hi.el" "hii.el" "dir/hi.el") + (should (equal (eshell-extended-glob "hi#.el") + '("h.el" "hi.el" "hii.el"))) + (should (equal (eshell-extended-glob "hi##.el") + '("hi.el" "hii.el"))))) + +(ert-deftest em-glob-test/match-n-or-more-character-sets () + "Test that \"[x]#\" and \"[x]#\" match zero or more instances of \"[x]\"." + (with-fake-files '("w.el" "wh.el" "wha.el" "whi.el" "whaha.el" "dir/wha.el") + (should (equal (eshell-extended-glob "w[ah]#.el") + '("w.el" "wh.el" "wha.el" "whaha.el"))) + (should (equal (eshell-extended-glob "w[ah]##.el") + '("wh.el" "wha.el" "whaha.el"))))) + +(ert-deftest em-glob-test/match-x-but-not-y () + "Test that \"x~y\" matches \"x\" but not \"y\"." + (with-fake-files '("1" "12" "123" "42" "dir/1") + (should (equal (eshell-extended-glob "[[:digit:]]##~4?") + '("1" "12" "123"))))) + +(ert-deftest em-glob-test/no-matches () + "Test behavior when a glob fails to match any files." + (with-fake-files '("foo.el" "bar.el") + (should (equal (eshell-extended-glob "*.txt") + "*.txt")) + (let ((eshell-error-if-no-glob t)) + (should-error (eshell-extended-glob "*.txt"))))) + +;; em-glob-tests.el ends here -- 2.39.2