How to nest two `forfiles` loops properly (so that

2019-06-14 15:34发布

问题:

I am trying to nest two forfiles loops so that the command of the inner loop receives @ variables from both the outer and the inner loop iteration. For the latter the @ variable replacement needs to be escaped for the outer loop so that the inner forfiles command receives the variable name.

I have got a code snippet that enumerates a given directory (C:\root), and if the iterated item is a directory on its own, all the contained text files (*.txt) are listed.
However, it does not work as expected: I tried to escape the expansion of @file with \, but it expands to the value of the outer loop:

2> nul forfiles /P "C:\root" /M "*" /C "cmd /C if @isdir==TRUE forfiles /P @path /M *.txt /C \"cmd /C echo @relpath -- \@file\""

Besides \@file, also ^@file, ^^@file, ^^^@file, 0x40file (0x40 being the hexadecimal character code representation of @) expand to the value of the outer forfiles variable.
\\@file and ^^^^@file expand to the outer value too, with \ and ^ preceeded, respectively.
Even @^file, @^^file (see this post) does not work (the latter expands to @file literally).

So: is there a way to escape the replacement of @ variables (like @file,...) from the outer forfiles loop so that the inner forfiles iteration receives the literal variable name and expands it to its value?

I am working on Windows 7 64-bit.


Note:
The 2> nul redirection should avoid numerous error messages ERROR: Files of type "*.txt" not found. when no file in the currently processed directory matches the given mask (*.txt).

回答1:

Here's a workaround - forfiles is not a quick beast so any extra processing isn't going to matter much.

@echo off
for /f "delims=" %%a in ('2^>nul forfiles /P "C:\root" /M "*" /C "cmd /C if @isdir==TRUE echo @path"') do forfiles /P %%a /M *.txt /C "cmd /C echo @relpath -- @file"
pause


回答2:

The trick is to use the hexadecimal character code replacement feature of forfiles in the format 0xHH, which can be nested on its own. In this context, 00x7840 is used, hence the first (outer) forfiles loop replaces the 0x78 portion by x, resulting in 0x40, which is in turn resolved by the second (inner) forfiles loop by replacing it with @.

A simple 0x40 does not work as forfiles replaces hexadecimal codes in a first pass and then it handles the @ variables in a second pass, so 0x40file will be replaced by @file first and then expanded to the currently iterated item by the outer forfiles loop both.


The following command line walks through a given root directory and displays the relative path of each immediate sub-directory (iterated by the outer forfiles loop) and all text files found therein (iterated by the inner forfiles loop):

2> nul forfiles /P "C:\root" /M "*" /C "cmd /C if @isdir==TRUE forfiles /P @path /M *.txt /C 0x22cmd /C echo @relpath -- 00x7840file0x22"

The output might look like (relative sub-directory paths left, text files right):

.\data_dir -- "text_msg.txt"

.\logs_dir -- "log_book.txt"
.\logs_dir -- "log_file.txt"

Explanation of Code:

  • as described above, the 00x7840file portion hides the @file variable name from the outer forfiles command and transfers its replacement to the inner forfiles command;
  • to avoid any trouble with quotes " and cmd /C, quotes within the string after the /C switch of the outer forfiles are avoided by stating their hexadecimal code 0x22;
    (forfiles supports escaping quotes like \", however cmd /C does not care about the \ and so it detects the "; 0x22 has no special meaning to cmd and so it is safe)
  • the if statement checks whether the item enumerated by the outer forfiles loop is a directory and, if not, skips the inner forfiles loop;
  • in case the enumerated sub-directory does not contain any items that match the given pattern, forfiles returns an error message like ERROR: Files of type "*.txt" not found. at STDERR; to avoid such messages, redirection 2> nul has been applied;

Step-by-Step Replacement:

Here is the above command line again but with the redirection removed, just for demonstration:

forfiles /P "C:\root" /M "*" /C "cmd /C if @isdir==TRUE forfiles /P @path /M *.txt /C 0x22cmd /C echo @relpath -- 00x7840file0x22"

We will now extract the nested command lines which are going to be executed one after another.

Taking the items of the first line of the above sample output (.\data_dir -- "text_msg.txt"), the command line executed by the outer forfiles command is:

cmd /C if TRUE==TRUE forfiles /P "C:\root" /M *.txt /C "cmd /C echo ".\data_dir" -- 0x40file"

So the inner forfiles command line looks like (cmd /C removed, and the if condition is fulfilled):

forfiles /P "C:\root" /M *.txt /C "cmd /C echo ".\data_dir" -- 0x40file"

Now the command line executed by the inner forfiles command is (notice the removed literal quotes around .\data_dir and the instant replacement of 0x40file by the value of variable @file):

cmd /C echo .\data_dir -- "text_msg.txt"

Walking though these steps from the innermost to the outermost command line like that, you could nest even more than two forfiles loops.


Note:

All path- or file-name-related @-variables are replaced by quoted strings each; however, the above shown sample output does not contain any surrounding quotes for the directory paths; this is because forfiles removes any literal (non-escaped) quotes " from the string after the /C switch; to get them back in the output here, replace @relpath in the command line by 00x7822@relpath00x7822; \\\"@relpath\\\" works too (but is not recommended though to not confuse cmd).


Appendix:

Since forfiles is not an internal command, it should be possible to nest it without the cmd /C prefix, like forfiles /C "forfiles /M *", for instance (unless any additional internal or external command, command concatenation, redirection or piping is used, where cmd /C is mandatory).
However, due to erroneous handling of command line arguments after the /C switch of forfiles, you actually need to state it like forfiles /C "forfiles forfiles /M *", so the inner forfiles command doubled. Otherwise an error message (ERROR: Invalid argument/option) is thrown.
This best work-around has been found at this post: forfiles without cmd /c (scroll to the bottom).