Iteruj po liście plików ze spacjami

201

Chcę iterować listę plików. Ta lista jest wynikiem findpolecenia, więc wymyśliłem:

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

Jest w porządku, chyba że plik ma spacje w nazwie:

$ 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

Co mogę zrobić, aby uniknąć podziału na spacje?

Gregseth
źródło
Jest to w zasadzie określona podgrupa Kiedy zawijać cudzysłowy wokół zmiennej powłoki?
tripleee

Odpowiedzi:

253

Możesz zastąpić iterację opartą na słowach iteracją opartą na wierszach:

find . -iname "foo*" | while read f
do
    # ... loop body
done
Martin Clayton
źródło
31
To jest bardzo czyste. I sprawia, że ​​czuję się milszy niż zmiana IFS w połączeniu z pętlą for
Derrick
15
Spowoduje to podział pojedynczej ścieżki pliku, która zawiera \ n. OK, nie powinno ich być w pobliżu, ale można je utworzyć:touch "$(printf "foo\nbar")"
Ollie Saunders
4
Aby zapobiec interpretacji danych wejściowych (odwrotne ukośniki, spacje początkowe i końcowe), użyj IFS= while read -r fzamiast tego.
mklement0
2
Ta odpowiedź pokazuje bezpieczniejszą kombinację findi pętlę while.
moi
5
Wygląda na to, wskazując na oczywiste, ale w prawie wszystkich prostych przypadkach -execbędzie czystsze niż wyraźnego pętli: find . -iname "foo*" -exec echo "File found: {}" \;. Dodatkowo, w wielu przypadkach można zastąpić ten ostatni \;ze +umieścić wiele plików w jednym poleceniu.
naught101 27.09.16
152

Istnieje kilka praktycznych sposobów na osiągnięcie tego.

Jeśli chcesz ściśle trzymać się oryginalnej wersji, możesz to zrobić w ten sposób:

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

To nadal nie powiedzie się, jeśli w nazwach plików znajdują się dosłowne znaki nowego wiersza, ale spacje go nie złamią.

Jednak bałagan w IFS nie jest konieczny. Oto mój preferowany sposób:

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

Jeśli < <(command)składnia jest dla Ciebie nieznana, powinieneś przeczytać o podstawianiu procesów . Zaletą tego for file in $(find ...)jest to, że pliki ze spacjami, znakami nowej linii i innymi znakami są poprawnie obsługiwane. Działa to, ponieważ findz -print0użyje null(aka \0) jako terminatora dla każdej nazwy pliku i, w przeciwieństwie do newline, null nie jest prawnym znakiem w nazwie pliku.

Przewaga tego nad prawie równoważną wersją

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

Czy to zachowanie dowolnego przypisania zmiennej w treści pętli while. Oznacza to, że jeśli potokujesz w taki sposób, whilejak powyżej, to ciało whilejest w podpowłoce, co może nie być tym, czego chcesz.

Zaleta wersji zastępującej proces find ... -print0 | xargs -0jest minimalna: xargswersja jest w porządku, jeśli wystarczy wydrukować linię lub wykonać jedną operację na pliku, ale jeśli trzeba wykonać wiele kroków, wersja pętli jest łatwiejsza.

EDYCJA : Oto fajny skrypt testowy, abyś mógł zrozumieć różnicę między różnymi próbami rozwiązania tego problemu

#!/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
źródło
1
Akceptowane odpowiedź: najbardziej kompletne i ciekawe - ja nie wiedziałem o $IFSi < <(cmd)składnia. Nadal jedno jest dla mnie niejasne, dlaczego $w $'\0'? Wielkie dzięki.
gregseth,
2
+1, ale należy dodać ... while IFS= read... do obsługi plików rozpoczynających się lub kończących na białych znakach.
Gordon Davisson,
1
Jest jedno zastrzeżenie dla rozwiązania zastępowania procesu. Jeśli w pętli pojawi się jakikolwiek monit (lub czytasz ze STDIN w jakikolwiek inny sposób), dane wejściowe zostaną wypełnione przez zawartość, którą podajesz do pętli. (może to należy dodać do odpowiedzi?)
andsens
2
@uvsmtid: To pytanie zostało oznaczone, bashwięc czułem się bezpiecznie, korzystając z funkcji specyficznych dla bash. Podstawianie procesów nie jest przenośne dla innych powłok (sam sh najprawdopodobniej nigdy nie otrzyma tak znaczącej aktualizacji).
sorpigal
2
Łączenie IFS=$'\n'z forzapobiega wewnętrznemu dzieleniu słów na linie, ale nadal powoduje, że wynikowe linie podlegają globowaniu, więc to podejście nie jest w pełni niezawodne (chyba że najpierw wyłączysz globowanie). Podczas pracy read -d $'\0'działa nieco $'\0'myląco , ponieważ sugeruje, że możesz użyć do tworzenia NUL-ów - nie możesz: a \0w łańcuchu cytowanym w ANSI skutecznie przerywa łańcuch, więc -d $'\0'jest to tak samo jak -d ''.
mklement0
29

Istnieje również bardzo proste rozwiązanie: polegaj na globowaniu 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'

Zauważ, że nie jestem pewien, czy to zachowanie jest domyślne, ale nie widzę żadnych specjalnych ustawień w moim shopt, więc powiedziałbym, że powinien być „bezpieczny” (testowany na OSX i Ubuntu).

marszowe
źródło
13
find . -iname "foo*" -print0 | xargs -L1 -0 echo "File found:"
Karoly Horvath
źródło
6
na marginesie, zadziała to tylko wtedy, gdy chcesz wykonać polecenie. Wbudowana powłoka nie będzie działać w ten sposób.
Alex
11
find . -name "fo*" -print0 | xargs -0 ls -l

Zobaczyć man xargs.

Torp
źródło
6

Ponieważ nie używasz żadnego innego rodzaju filtrowania find, możesz użyć następujących opcji od wersji bash4.0:

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

**/Pasuje zero lub więcej katalogów, więc pełny wzór będzie pasował foo*w bieżącym katalogu lub jakichkolwiek podkatalogów.

chepner
źródło
3

Bardzo lubię pętle i iteracje tablic, więc myślę, że dodam tę odpowiedź do miksu ...

Podobał mi się również głupi przykład pliku marchelbling. :)

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

W katalogu testowym:

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

Spowoduje to dodanie każdej linii listy plików do tablicy bash o nazwie arrz usuniętym końcowym znakiem nowej linii.

Powiedzmy, że chcemy nadać tym plikom lepsze nazwy ...

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

$ {! arr [@]} rozwija się do 0 1 2, więc „$ {arr [$ i]}” jest i- tym elementem tablicy. Cytaty wokół zmiennych są ważne dla zachowania spacji.

Rezultatem są trzy pliki o zmienionych nazwach:

$ ls -1
smarter_file1
smarter_file2
smarter_file_3
terafl0ps
źródło
2

findma -execargument, który zapętla wyniki wyszukiwania i wykonuje dowolne polecenie. Na przykład:

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

Tutaj {}reprezentuje znalezione pliki, a zawinięcie go ""pozwala wynikowemu poleceniu powłoki zająć się spacjami w nazwie pliku.

W wielu przypadkach możesz zastąpić to ostatnie \;(które uruchamia nowe polecenie) znakiem \+, który umieści wiele plików w jednym poleceniu (niekoniecznie wszystkie jednocześnie, zobacz man findwięcej szczegółów).

naught101
źródło
0

W niektórych przypadkach, jeśli po prostu chcesz skopiować lub przenieść listę plików, możesz również przesłać tę listę do awk.
Ważne \"" "\"wokół pola $0(w skrócie twoje pliki, jedna linia-lista = jeden plik).

find . -iname "foo*" | awk '{print "mv \""$0"\" ./MyDir2" | "sh" }'
Steve
źródło
0

Ok - mój pierwszy post na Stack Overflow!

Chociaż moje problemy z tym zawsze były w csh, nie bash rozwiązanie, które przedstawię, na pewno zadziała w obu. Problem polega na interpretacji przez powłokę zwrotów „ls”. Możemy usunąć „ls” z problemu, po prostu używając rozszerzenia powłoki z *symboli wieloznacznych - ale daje to błąd „brak dopasowania”, jeśli w bieżącym (lub określonym folderze) nie ma plików - aby obejść ten problem, po prostu rozszerzamy rozszerzenie o pliki kropkowe w ten sposób: * .*- zawsze da to wyniki, ponieważ pliki. i .. zawsze będzie obecny. Więc w csh możemy użyć tego konstruktu ...

foreach file (* .*)
   echo $file
end

jeśli chcesz odfiltrować standardowe pliki kropek, jest to dość łatwe ...

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

Kod w pierwszym poście w tym wątku zostałby zapisany w następujący sposób: -

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

Mam nadzieję że to pomoże!

Andy Foster
źródło
0

Kolejne rozwiązanie dla pracy ...

Celem było:

  • wybieraj / filtruj nazwy plików rekurencyjnie w katalogach
  • obsłużyć każdą nazwę (niezależnie od miejsca na ścieżce ...)
#!/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.
źródło
Dziękuję za konstruktywną uwagę, ale: 1- to prawdziwy problem, 2- powłoka mogła ewoluować w czasie ... jak wszyscy zakładam; 3- Żadna odpowiedź powyżej nie może zaspokoić BEZPOŚREDNIEGO rozwiązania pb bez zmiany problemu lub rozprawy :-)
Vince B