Iterar sobre una lista de archivos con espacios

201

Quiero iterar sobre una lista de archivos. Esta lista es el resultado de un findcomando, así que se me ocurrió:

getlist() {
  for f in $(find . -iname "foo*")
  do
    echo "File found: $f"
    # do something useful
  done
}

Está bien, excepto si un archivo tiene espacios en su nombre:

$ ls
foo_bar_baz.txt
foo bar baz.txt

$ getlist
File found: foo_bar_baz.txt
File found: foo
File found: bar
File found: baz.txt

¿Qué puedo hacer para evitar la división en espacios?

Gregseth
fuente
¿Esto es básicamente un subcase específico de Cuándo ajustar las comillas alrededor de una variable de shell?
tripleee

Respuestas:

253

Puede reemplazar la iteración basada en palabras con una basada en líneas:

find . -iname "foo*" | while read f
do
    # ... loop body
done
Martin Clayton
fuente
31
Esto es extremadamente limpio. Y me hace sentir mejor que cambiar IFS junto con un bucle for
Derrick
15
Esto dividirá una única ruta de archivo que contiene un \ n. OK, esos no deberían estar cerca pero pueden crearse:touch "$(printf "foo\nbar")"
Ollie Saunders
44
Para evitar cualquier interpretación de la entrada (barras invertidas, espacios en blanco iniciales y finales), utilice IFS= while read -r fen su lugar.
mklement0
2
Esta respuesta muestra una combinación más segura de findy un ciclo while.
moi
55
Parece que señalar lo obvio, pero en casi todos los casos simples, -execva a ser más limpio que un bucle explícito: find . -iname "foo*" -exec echo "File found: {}" \;. Además, en muchos casos puede sustituir a la última \;con la +que poner un montón de archivos en el sistema.
naught101
152

Hay varias formas viables para lograr esto.

Si quisieras apegarte a tu versión original, podrías hacerlo de esta manera:

getlist() {
        IFS=$'\n'
        for file in $(find . -iname 'foo*') ; do
                printf 'File found: %s\n' "$file"
        done
}

Esto seguirá fallando si los nombres de archivo tienen nuevas líneas literales, pero los espacios no lo romperán.

Sin embargo, no es necesario jugar con IFS. Aquí está mi forma preferida de hacer esto:

getlist() {
    while IFS= read -d $'\0' -r file ; do
            printf 'File found: %s\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

Si la < <(command)sintaxis le resulta desconocida, debe leer sobre la sustitución de procesos . La ventaja de esto for file in $(find ...)es que los archivos con espacios, líneas nuevas y otros caracteres se manejan correctamente. Esto funciona porque findla -print0va a utilizar un null(alias \0) como terminador para cada nombre de archivo y, a diferencia de nueva línea, null no es un carácter legal en un nombre de archivo.

La ventaja de esto sobre la versión casi equivalente

getlist() {
        find . -iname 'foo*' -print0 | while read -d $'\0' -r file ; do
                printf 'File found: %s\n' "$file"
        done
}

Es que cualquier asignación variable en el cuerpo del ciclo while se conserva. Es decir, si se canaliza whilecomo se indica anteriormente, el cuerpo del whileestá en una subshell que puede no ser lo que desea.

La ventaja de la versión de sustitución del proceso find ... -print0 | xargs -0es mínima: la xargsversión está bien si todo lo que necesita es imprimir una línea o realizar una sola operación en el archivo, pero si necesita realizar varios pasos, la versión en bucle es más fácil.

EDITAR : Aquí hay un buen script de prueba para que pueda tener una idea de la diferencia entre los diferentes intentos de resolver este problema

#!/usr/bin/env bash

dir=/tmp/getlist.test/
mkdir -p "$dir"
cd "$dir"

touch       'file not starting foo' foo foobar barfoo 'foo with spaces'\
    'foo with'$'\n'newline 'foo with trailing whitespace      '

# while with process substitution, null terminated, empty IFS
getlist0() {
    while IFS= read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

# while with process substitution, null terminated, default IFS
getlist1() {
    while read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done < <(find . -iname 'foo*' -print0)
}

# pipe to while, newline terminated
getlist2() {
    find . -iname 'foo*' | while read -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# pipe to while, null terminated
getlist3() {
    find . -iname 'foo*' -print0 | while read -d $'\0' -r file ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# for loop over subshell results, newline terminated, default IFS
getlist4() {
    for file in "$(find . -iname 'foo*')" ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}

# for loop over subshell results, newline terminated, newline IFS
getlist5() {
    IFS=$'\n'
    for file in $(find . -iname 'foo*') ; do
            printf 'File found: '"'%s'"'\n' "$file"
    done
}


# see how they run
for n in {0..5} ; do
    printf '\n\ngetlist%d:\n' $n
    eval getlist$n
done

rm -rf "$dir"
sorigal
fuente
1
Aceptó su respuesta: la más completa e interesante, no sabía sobre $IFSla < <(cmd)sintaxis. Todavía queda una cosa que oscurecen a mí, por qué el $de $'\0'? Muchas gracias.
gregseth
2
+1, pero debe agregar ... while IFS= read... para manejar archivos que comienzan o terminan con espacios en blanco.
Gordon Davisson el
1
Hay una advertencia para la solución de sustitución del proceso. Si tiene algún mensaje dentro del bucle (o está leyendo STDIN de alguna otra manera), la entrada se completará con las cosas que alimente en el bucle. (¿tal vez esto debería agregarse a la respuesta?)
andsens
2
@uvsmtid: esta pregunta fue etiquetada, bashasí que me sentí seguro usando funciones específicas de bash. La sustitución del proceso no es portátil a otros shells (es probable que sh no reciba una actualización tan significativa).
sorigal
2
La combinación IFS=$'\n'con forpreviene la división de palabras interna de la línea, pero aún así hace que las líneas resultantes estén sujetas a glob, por lo que este enfoque no es completamente robusto (a menos que también desactive el globbing primero). Si bien read -d $'\0'funciona, es un poco engañoso, ya que sugiere que puede usar $'\0'para crear NUL; no puede: a \0en una cadena con comillas ANSI C termina efectivamente la cadena, por lo que -d $'\0'efectivamente es lo mismo -d ''.
mklement0
29

También hay una solución muy simple: confiar en bash globbing

$ mkdir test
$ cd test
$ touch "stupid file1"
$ touch "stupid file2"
$ touch "stupid   file 3"
$ ls
stupid   file 3  stupid file1     stupid file2
$ for file in *; do echo "file: '${file}'"; done
file: 'stupid   file 3'
file: 'stupid file1'
file: 'stupid file2'

Tenga en cuenta que no estoy seguro de que este comportamiento sea el predeterminado, pero no veo ninguna configuración especial en mi tienda, así que diría que debería ser "seguro" (probado en osx y ubuntu).

marchelbling
fuente
13
find . -iname "foo*" -print0 | xargs -L1 -0 echo "File found:"
Karoly Horvath
fuente
66
Como nota al margen, esto solo funcionará si desea ejecutar un comando. Una carcasa incorporada no funcionará de esta manera.
Alex
11
find . -name "fo*" -print0 | xargs -0 ls -l

Ver man xargs.

Torp
fuente
6

Como no está haciendo ningún otro tipo de filtrado find, puede usar lo siguiente a partir de bash4.0:

shopt -s globstar
getlist() {
    for f in **/foo*
    do
        echo "File found: $f"
        # do something useful
    done
}

El **/coincidirá con cero o más directorios, por lo que el patrón completo coincidirá foo*en el directorio actual o cualquier subdirectorio.

chepner
fuente
3

Realmente me gustan los bucles y la iteración de matrices, así que creo que agregaré esta respuesta a la mezcla ...

También me gustó el estúpido ejemplo de archivo de marchelbling. :)

$ mkdir test
$ cd test
$ touch "stupid file1"
$ touch "stupid file2"
$ touch "stupid   file 3"

Dentro del directorio de prueba:

readarray -t arr <<< "`ls -A1`"

Esto agrega cada línea de listado de archivos en una matriz bash nombrada arrcon cualquier nueva línea final eliminada.

Digamos que queremos dar mejores nombres a estos archivos ...

for i in ${!arr[@]}
do 
    newname=`echo "${arr[$i]}" | sed 's/stupid/smarter/; s/  */_/g'`; 
    mv "${arr[$i]}" "$newname"
done

$ {! arr [@]} se expande a 0 1 2, por lo que "$ {arr [$ i]}" es el elemento número i de la matriz. Las comillas alrededor de las variables son importantes para preservar los espacios.

El resultado son tres archivos renombrados:

$ ls -1
smarter_file1
smarter_file2
smarter_file_3
terafl0ps
fuente
2

findtiene un -execargumento que recorre los resultados de búsqueda y ejecuta un comando arbitrario. Por ejemplo:

find . -iname "foo*" -exec echo "File found: {}" \;

Aquí {}representa los archivos encontrados, y envolverlo ""permite que el comando de shell resultante trate con espacios en el nombre del archivo.

En muchos casos, puede reemplazar ese último \;(que inicia un nuevo comando) con un \+, que colocará varios archivos en el mismo comando (aunque no necesariamente todos a la vez, consulte man findpara obtener más detalles).

nada101
fuente
0

En algunos casos, aquí si solo necesita copiar o mover una lista de archivos, también puede canalizar esa lista a awk.
Importante \"" "\"alrededor del campo $0(en resumen, sus archivos, una lista de líneas = un archivo).

find . -iname "foo*" | awk '{print "mv \""$0"\" ./MyDir2" | "sh" }'
Steve
fuente
0

Ok, ¡mi primera publicación en Stack Overflow!

Aunque mis problemas con esto siempre han estado en csh, no bash, la solución que presento funcionará, estoy seguro, en ambos. El problema es con la interpretación del shell de los retornos "ls". Podemos eliminar "ls" del problema simplemente usando la expansión de shell del *comodín, pero esto da un error de "no coincidencia" si no hay archivos en la carpeta actual (o en la carpeta especificada), para solucionar esto simplemente ampliamos el expansión para incluir archivos de puntos por lo tanto: * .*- esto siempre dará resultados desde los archivos. y ... siempre estará presente. Entonces, en csh podemos usar esta construcción ...

foreach file (* .*)
   echo $file
end

si desea filtrar los archivos de puntos estándar, entonces eso es bastante fácil ...

foreach file (* .*)
   if ("$file" == .) continue
   if ("file" == ..) continue
   echo $file
end

El código en la primera publicación de este hilo se escribiría así:

getlist() {
  for f in $(* .*)
  do
    echo "File found: $f"
    # do something useful
  done
}

¡Espero que esto ayude!

Andy Foster
fuente
0

Otra solución para el trabajo ...

El objetivo era:

  • seleccionar / filtrar nombres de archivos de forma recursiva en directorios
  • manejar cada nombre (cualquier espacio en la ruta ...)
#!/bin/bash  -e
## @Trick in order handle File with space in their path...
OLD_IFS=${IFS}
IFS=$'\n'
files=($(find ${INPUT_DIR} -type f -name "*.md"))
for filename in ${files[*]}
do
      # do your stuff
      #  ....
done
IFS=${OLD_IFS}

Vince B
fuente
Gracias por la observación constructiva, pero: 1- este es un problema real, 2- shell podría haber evolucionado en el tiempo ... como todos supongo; 3- Ninguna respuesta anterior podría satisfacer una resolución DIRECTA del pb sin cambiar el problema o disertando :-)
Vince B