Iterar sobre uma lista de arquivos com espaços

201

Eu quero iterar sobre uma lista de arquivos. Esta lista é o resultado de um findcomando, então eu vim com:

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

Tudo bem, exceto se um arquivo tiver espaços em seu nome:

$ 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

O que posso fazer para evitar a divisão de espaços?

Gregseth
fonte
Esta é basicamente uma subcaixa específica de Quando colocar aspas em torno de uma variável de shell?
tripleee 6/06

Respostas:

253

Você pode substituir a iteração baseada em palavras por uma iterativa baseada em linhas:

find . -iname "foo*" | while read f
do
    # ... loop body
done
Martin Clayton
fonte
31
Isso é extremamente limpo. E me faz sentir mais agradável do que mudar IFS em conjunto com um loop
Derrick
15
Isso dividirá um único caminho de arquivo que contém um \ n. OK, eles não devem estar por perto, mas podem ser criados:touch "$(printf "foo\nbar")"
Ollie Saunders
4
Para impedir qualquer interpretação da entrada (barras invertidas, espaços em branco à esquerda e à direita), use em seu IFS= while read -r flugar.
usar o seguinte comando
2
Esta resposta mostra uma combinação mais segura de finde um loop while.
moi
5
Parece que apontar o óbvio, mas em quase todos os casos simples, -execvai ser mais limpo do que um loop explícita: find . -iname "foo*" -exec echo "File found: {}" \;. Além disso, em muitos casos, você pode substituir esse último \;por +colocar muitos arquivos no único comando.
precisa saber é o seguinte
152

Existem várias maneiras viáveis ​​de conseguir isso.

Se você quisesse manter sua versão original, isso poderia ser feito da seguinte maneira:

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

Isso ainda falhará se os nomes dos arquivos tiverem novas linhas literais, mas os espaços não serão interrompidos.

No entanto, não é necessário mexer com o IFS. Aqui está minha maneira preferida de fazer isso:

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

Se você achar a < <(command)sintaxe desconhecida, leia sobre a substituição de processos . A vantagem disso for file in $(find ...)é que arquivos com espaços, novas linhas e outros caracteres são manipulados corretamente. Isso funciona porque findwith -print0usará um null(aka \0) como terminador para cada nome de arquivo e, diferentemente da nova linha, null não é um caractere legal em um nome de arquivo.

A vantagem disso em relação à versão quase equivalente

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

É que qualquer atribuição de variável no corpo do loop while é preservada. Ou seja, se você canalizar whilecomo acima, o corpo do whileestá em um subshell que pode não ser o que você deseja.

A vantagem da versão de substituição de processo find ... -print0 | xargs -0é mínima: a xargsversão é boa se tudo o que você precisa é imprimir uma linha ou executar uma única operação no arquivo, mas se você precisar executar várias etapas, a versão do loop será mais fácil.

EDIT : Aqui está um bom script de teste para que você possa ter uma idéia da diferença entre diferentes tentativas de resolver esse 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"
sorpigal
fonte
1
Aceitei sua resposta: a mais completa e interessante - eu não conhecia $IFSe a < <(cmd)sintaxe. Ainda uma coisa permanece obscura para mim, porque o $em $'\0'? Muito obrigado.
gregseth
2
+1, mas você deve adicionar ... while IFS= read... para lidar com arquivos que iniciam ou terminam com espaço em branco.
Gordon Davisson
1
Há uma ressalva na solução de substituição do processo. Se você tiver algum prompt dentro do loop (ou estiver lendo STDIN de qualquer outra maneira), a entrada será preenchida pelo material que você alimentar no loop. (talvez isso deva ser adicionado à resposta?)
andsens 12/12
2
@uvsmtid: Esta pergunta foi marcada bash então me senti seguro usando recursos específicos do bash. A substituição do processo não é portável para outros shells (o próprio sh provavelmente não receberá uma atualização tão significativa).
sorpigal
2
A combinação IFS=$'\n'com forimpede a divisão interna das palavras, mas ainda sujeita as linhas resultantes a globbing, portanto essa abordagem não é totalmente robusta (a menos que você também desative o globbing primeiro). Enquanto read -d $'\0'funciona, é um pouco enganador, pois sugere que você pode usar $'\0'para criar NULs - você não pode: a \0em uma seqüência de caracteres citada por ANSI C efetivamente termina a sequência, de modo que -d $'\0'é efetivamente o mesmo que -d ''.
usar o seguinte comando
29

Há também uma solução muito simples: confie no globing bash

$ 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'

Observe que não tenho certeza de que esse comportamento seja o padrão, mas não vejo nenhuma configuração especial no meu shopt; portanto, diria que deveria ser "seguro" (testado no osx e no ubuntu).

marchando
fonte
13
find . -iname "foo*" -print0 | xargs -L1 -0 echo "File found:"
Karoly Horvath
fonte
6
como uma observação lateral, isso só funcionará se você quiser executar um comando. Um shell embutido não funcionará dessa maneira.
27415 Alex
11
find . -name "fo*" -print0 | xargs -0 ls -l

Veja man xargs.

Torp
fonte
6

Como você não está fazendo nenhum outro tipo de filtragem find, é possível usar o seguinte a partir da bash4.0:

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

Ele **/corresponderá a zero ou mais diretórios; portanto, o padrão completo corresponderá foo*ao diretório atual ou a qualquer subdiretório.

chepner
fonte
3

Eu realmente gosto de loops e iteração de matriz, então acho que vou adicionar esta resposta à mistura ...

Também gostei do exemplo de arquivo estúpido de marchelbling. :)

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

Dentro do diretório de teste:

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

Isso adiciona cada linha de listagem de arquivos a uma matriz bash denominada arrcom qualquer nova linha à direita removida.

Digamos que queremos dar um nome melhor a esses arquivos ...

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

$ {! arr [@]} se expande para 0 1 2, então "$ {arr [$ i]}" é o i- ésimo elemento da matriz. As aspas ao redor das variáveis ​​são importantes para preservar os espaços.

O resultado são três arquivos renomeados:

$ ls -1
smarter_file1
smarter_file2
smarter_file_3
terafl0ps
fonte
2

findtem um -execargumento que faz um loop sobre os resultados da busca e executa um comando arbitrário. Por exemplo:

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

Aqui {}representa os arquivos encontrados, e envolvê-los ""permite que o comando shell resultante lide com espaços no nome do arquivo.

Em muitos casos, você pode substituir o último \;(que inicia um novo comando) por a \+, que colocará vários arquivos em um comando (embora não necessariamente todos de uma só vez, veja man findmais detalhes).

naught101
fonte
0

Em alguns casos, aqui, se você apenas precisar copiar ou mover uma lista de arquivos, poderá canalizar essa lista para despertar também.
Importante ao \"" "\"redor do campo $0(em resumo, seus arquivos, uma lista de linhas = um arquivo).

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

Ok - meu primeiro post no Stack Overflow!

Embora meus problemas com isso sempre tenham sido no csh, não bash, a solução que apresento funcionará, tenho certeza, em ambos. O problema está na interpretação do shell dos retornos "ls". Podemos remover "ls" do problema simplesmente usando a expansão do *curinga - mas isso gera um erro "sem correspondência" se não houver arquivos na pasta atual (ou na pasta especificada) - para contornar isso, simplesmente estendemos o arquivo expansão para incluir arquivos de ponto assim: * .*- isso sempre produzirá resultados desde os arquivos. e .. estará sempre presente. Assim, no csh, podemos usar essa construção ...

foreach file (* .*)
   echo $file
end

se você deseja filtrar os arquivos ponto padrão, isso é fácil o suficiente ...

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

O código no primeiro post deste tópico seria escrito assim: -

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

Espero que isto ajude!

Andy Foster
fonte
0

Outra solução para o trabalho ...

O objetivo era:

  • selecione / filtre os nomes de arquivos recursivamente nos diretórios
  • manipular cada nome (qualquer espaço no caminho ...)
#!/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
fonte
Thx para observação construtiva, mas: 1- este é um problema real, 2- shell poderia ter evoluído no tempo ... como todo mundo que eu assumo; 3 Nenhum resposta acima poderia satisfazer uma resolução direta dos pb sem mudar o problema ou dissertando :-)
Vince B