3

I need to join the expansion of a filename generation pattern with newlines, like this:

files=([^.]*(.U))
files=${(F)files}

or basically

files=$(find . -maxdepth 1 -type f -uid $EUID ! -name '.*' -exec basename '{}' \;)

I'd like to know how it is possible with zsh, to do this directly. How can I expand the pattern and assign the joined array to $files as a string in one go?

I tried dozens of variations like files=${(@F)${:-[^.]*(.U:wq)}}. I tried the Pstring glob qualifier, but files=([^.]*(.U^P:\\n:)) inserts a separate word (two spaces, I also tried changing $IFS) and also appends string after the last item. If only there was a join modifier.

Am I missing something?

2

${(F)...} is a parameter expansion. While nested expansions are allowed,

If a ${...} type parameter expression or a $(...) type command substitution is used in place of name above, it is expanded first and the result is used as if it were the value of name.

The nesting is limited to parameter expansions and command substitutions. ([^.]*(.U)) is neither - it's part of an array definition, composed of filename generation. Filename generation just doesn't take place in this context - not until after the parameter expansion is completed (except in nested command substitutions, but that's not particularly useful here). This is expanded upon in the rules for parameter expansion:

At each nested level of substitution, the substituted words undergo all forms of single-word substitution (i.e. not filename generation), including command substitution, arithmetic expansion and filename expansion (i.e. leading ~ and =).

That's why these two can't be used together. If you just want a built-in way to do this, without relying on an external tool like find, command substitution might still provide a way:

files=$(print -lr -- [^.]*(.U))
  • I think the print builtin offers a good solution, and thanks for the clear explanation! – Bart May 24 '20 at 13:05
1

Some more options:

  • use an anonymous function:

    (){ files=${(F)@}; } *(U.^D)
    
  • use printf -v:

    printf -v files '%s\n' *(U.^D)
    

    (that one has a \n at the end). $files must not have been declared as an array beforehand.

  • you can't directly assign glob expansions to a scalar variable all in one assignment as globs generate lists so are only suitable for assignment to arrays or associative arrays. If you really wanted to (but it's not clear to me why you would), you can work around it by using command substitution as @Gilles has shown, though that means forking a subshell and all trailing newlines be removed.

    Another approach that wouldn't involve forking would be to use zsh's dynamic named directory feature, which is one way to have custom code invoked to generate a custom expansion.

    zsh_directory_name_functions+=(expand-glob)
    expand-glob() {
      emulate -L zsh
      set -o extendedglob
      local match
      [[ $1 = n && $2 = (#b)glob:(*):(*) ]] && {
        local sep=$match[1]
        reply=(${~match[2]}(#qN))
        reply=(${(pj:$sep:)reply})
      }
    }
    

    and then do:

    files=~[glob:$'\n':*(#qU.^D)]
    

But are you sure that's what you want? As soon as you join those files with any character other than / or \0, you can't get the original file list back as those (/ and \0) are the only characters that can't occur in a file name.

Why not storing in an array instead:

files=(*(NU.^D))

Then you know you don't lose anything.

And if you need to print that list to the user, you can always do:

print -rC1 -- $files

Or using any of the forms of quoting supported by zsh so as not to lose information:

print -rC1 -- "${(@q+)files}" # human friendly
print -rC1 -- "${(@qq)files}" # safe for reinput to the shell or z flag

Note that those [^.] or ^D should only be needed if you otherwise have set the dotglob option globally.

print -C1 prints on one column, it's the same as print -l except in the case of an empty list of arguments (which could happen here since I used the Nullglob glob qualifier to avoid the error if there's no matching file) where the latter still outputs an empty line.

If you want to store that list of files into a file so you can edit it, and load the result back later while still preserve the all the information, you can do:

files=(*(.U^D))
printf '%q\n' $files > file

With %q, filenames are quoted using zsh quoting so for instance a file called foo<newline>bar would be rendered as $'foo\nbar'.

Then, you can load the edited file into a new array with something like:

newfiles=("${(f@Q)$(<file)}")

Where the Q parameter expansion flag undoes the quoting.

And then do:

(($#files == $#newfiles)) &&
   for old new (${files:^newfiles}) mv -i -- $old $new
  • Thanks, good points, I just wondered if it is possible to use the two together. I actually need it in a shell script for the terminal file manager lf. – Bart May 24 '20 at 13:30
  • @Bart, you lost me there. What do you mean by the two together? What two? How do you pass that list of files to lf? If that lf software takes as input newline-delimited lists of files as input, it is broken by design as that format can't hold arbitrary lists of files (it wouldn't be the first though, cpio or POSIX pax have the same issue). – Stéphane Chazelas May 24 '20 at 13:37
  • The script is similar to bulk-rename but for zsh. When lf invokes the script, the variable $fs would be a delimited "selected files" list. Since a shell can't accept a variable with \0 in it, and my script's purpose is to write the variable to a file to be edited, I chose to ignore the possibility that filenames may contain newlines and just use \n. In this issue, the use of \0 as delimiter is discussed. – Bart May 24 '20 at 13:41
  • "the two": parameter expansion and the context of filename generation – Bart May 24 '20 at 13:42
  • 1
    zsh variables can contain NULs. It can take input with NULs on stdin or files. The limitation is for script arguments or env vars (a limitation in execve(), not shells). In any case, to work around it, you can use some encoding like quoting (${(q+)file} for instance) – Stéphane Chazelas May 24 '20 at 14:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.