bash: uso procedural seguro de espaço em branco do find into select

12

Dados esses nomes de arquivo:

$ ls -1
file
file name
otherfile

bash ele funciona perfeitamente com espaço em branco incorporado:

$ for file in *; do echo "$file"; done
file
file name
otherfile
$ select file in *; do echo "$file"; done
1) file
2) file name
3) otherfile
#?

No entanto, às vezes talvez eu não queira trabalhar com todos os arquivos, ou mesmo estritamente dentro $PWD, e é aí que findentra. O que também lida com espaços em branco nominalmente:

$ find -type f -name file\*
./file
./file name
./directory/file
./directory/file name

Eu estou tentando inventar uma versão segura de espaço para script deste scriptlet que pegará a saída finde a apresentará em select:

$ select file in $(find -type f -name file); do echo $file; break; done
1) ./file
2) ./directory/file

No entanto, isso explode com espaço em branco nos nomes de arquivos:

$ select file in $(find -type f -name file\*); do echo $file; break; done
1) ./file        3) name          5) ./directory/file
2) ./file        4) ./directory/file  6) name

Normalmente, eu contornaria isso brincando IFS. Contudo:

$ IFS=$'\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'
$ IFS='\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'

Qual é a solução para isso?

DopeGhoti
fonte
1
Se você estiver usando apenasfind por sua capacidade de corresponder a um nome de arquivo específico, poderá simplesmente usar select file in **/file*(após a configuração shopt -s globstar) em bash4 ou posterior.
Chepner

Respostas:

14

Se você precisar apenas manipular espaços e guias (novas linhas não incorporadas), poderá usar mapfile(ou seu sinônimo readarray) para ler em uma matriz, por exemplo,

$ ls -1
file
other file
somefile

então

$ IFS= mapfile -t files < <(find . -type f)
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
#? 3
./other file

Se você fazer necessidade de novas linhas do punho, e sua bashversão fornece uma delimitada por nulo mapfile1 , então você pode modificar isso para IFS= mapfile -t -d '' files < <(find . -type f -print0). Caso contrário, monte uma matriz equivalente a partir da findsaída delimitada por nulo usando um readloop:

$ touch $'filename\nwith\nnewlines'
$ 
$ files=()
$ while IFS= read -r -d '' f; do files+=("$f"); done < <(find . -type f -print0)
$ 
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
4) ./filename
with
newlines
#? 4
./filename?with?newlines

1 a -dopção foi adicionada mapfilena bashversão 4.4 iirc

chave de aço
fonte
2
+1 para outro verbo que eu não usei antes
roaima
Na verdade, mapfileé um novo para mim também. Parabéns.
DopeGhoti 13/07/19
A while IFS= readversão funciona no bash v3 (o que é importante para nós que usamos o macOS).
Gordon Davisson
3
+1 para a find -print0variante; resmungar por colocá-lo após uma versão conhecida incorreta e descrevê-lo apenas para uso se alguém souber que precisa lidar com novas linhas. Se alguém lida apenas com o inesperado nos locais onde é esperado, nunca lidará com o inesperado.
Charles Duffy
8

Esta resposta tem soluções para qualquer tipo de arquivo. Com novas linhas ou espaços.
Existem soluções para o bash recente, bem como o bash antigo e até as conchas posix antigas.

A árvore listada abaixo nesta resposta [1] é usada para os testes.

selecionar

É fácil começar selecta trabalhar com uma matriz:

$ dir='deep/inside/a/dir'
$ arr=( "$dir"/* )
$ select var in "${arr[@]}"; do echo "$var"; break; done

Ou com os parâmetros posicionais:

$ set -- "$dir"/*
$ select var; do echo "$var"; break; done

Portanto, o único problema real é obter a "lista de arquivos" (delimitada corretamente) dentro de uma matriz ou dentro dos Parâmetros Posicionais. Continue lendo.

bater

Não vejo o problema que você relata com o bash. O Bash pode pesquisar dentro de um diretório:

$ dir='deep/inside/a/dir'
$ printf '<%s>\n' "$dir"/*
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Ou, se você gosta de um loop:

$ set -- "$dir"/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Observe que a sintaxe acima funcionará corretamente com qualquer shell (razoável) (não pelo menos o csh).

O único limite que a sintaxe acima tem é descer para outros diretórios.
Mas o bash poderia fazer isso:

$ shopt -s globstar
$ set -- "$dir"/**/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Para selecionar apenas alguns arquivos (como os que terminam em arquivo), substitua o *:

$ set -- "$dir"/**/*file
$ printf '<%s>\n' "$@"
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/zz last file>

robusto

Quando você coloca um "espaço seguro " no título, vou assumir que o que você quis dizer foi " robusto ".

A maneira mais simples de ser robusto sobre espaços (ou novas linhas) é rejeitar o processamento de entrada que possui espaços (ou novas linhas). Uma maneira muito simples de fazer isso no shell é sair com um erro se algum nome de arquivo se expandir com um espaço. Existem várias maneiras de fazer isso, mas o mais compacto (e posix) (mas limitado ao conteúdo de um diretório, incluindo nomes de subdiretórios e evitando arquivos de ponto) é:

$ set -- "$dir"/file*                            # read the directory
$ a="$(printf '%s' "$@" x)"                      # make it a long string
$ [ "$a" = "${a%% *}" ] || echo "exit on space"  # if $a has an space.
$ nl='
'                    # define a new line in the usual posix way.  

$ [ "$a" = "${a%%"$nl"*}" ] || echo "exit on newline"  # if $a has a newline.

Se a solução usada for robusta em algum desses itens, remova o teste.

No bash, os subdiretórios podem ser testados de uma só vez com o ** explicado acima.

Existem algumas maneiras de incluir arquivos de ponto, a solução Posix é:

set -- "$dir"/* "$dir"/.[!.]* "$dir"/..?*

encontrar

Se a localização precisar ser usada por algum motivo, substitua o delimitador por um NUL (0x00).

bash 4.4+

$ readarray -t -d '' arr < <(find "$dir" -type f -name file\* -print0)
$ printf '<%s>\n' "${arr[@]}"
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/file>

bash 2.05+

i=1  # lets start on 1 so it works also in zsh.
while IFS='' read -d '' val; do 
    arr[i++]="$val";
done < <(find "$dir" -type f -name \*file -print0)
printf '<%s>\n' "${arr[@]}"

POSIXLY

Para criar uma solução POSIX válida em que find não tenha um delimitador NUL e não exista -d(nem -a) para leitura, precisamos de uma abordagem totalmente diferente.

Precisamos usar um complexo -execde find com uma chamada para um shell:

find "$dir" -type f -exec sh -c '
    for f do
        echo "<$f>"
    done
    ' sh {} +

Ou, se o necessário for um select (select faz parte do bash, não sh):

$ find "$dir" -type f -exec bash -c '
      select f; do echo "<$f>"; break; done ' bash {} +

1) deep/inside/a/dir/file name
2) deep/inside/a/dir/zz last file
3) deep/inside/a/dir/file with a
newline
4) deep/inside/a/dir/directory/file name
5) deep/inside/a/dir/directory/zz last file
6) deep/inside/a/dir/directory/file with a
newline
7) deep/inside/a/dir/directory/file
8) deep/inside/a/dir/file
#? 3
<deep/inside/a/dir/file with a
newline>

[1] Esta árvore (as \ 012 são novas linhas):

$ tree
.
└── deep
    └── inside
        └── a
            └── dir
                ├── directory
                   ├── file
                   ├── file name
                   └── file with a \012newline
                ├── file
                ├── file name
                ├── otherfile
                ├── with a\012newline
                └── zz last file

Pode ser construído com estes dois comandos:

$ mkdir -p deep/inside/a/dir/directory/
$ touch deep/inside/a/dir/{,directory/}{file{,\ {name,with\ a$'\n'newline}},zz\ last\ file}
Seta
fonte
6

Você não pode definir uma variável na frente de uma construção em loop, mas pode configurá-la na frente da condição. Aqui está o segmento da página de manual:

O ambiente para qualquer comando ou função simples pode ser aumentado temporariamente, prefixando-o com atribuições de parâmetros, conforme descrito acima em PARÂMETROS.

(Um loop não é um comando simples .)

Aqui está uma construção comumente usada que demonstra os cenários de falha e sucesso:

IFS=$'\n' while read -r x; do ...; done </tmp/file     # Failure
while IFS=$'\n' read -r x; do ...; done </tmp/file     # Success

Infelizmente, não consigo ver uma maneira de incorporar uma alteração IFSna selectconstrução enquanto isso afeta o processamento de um associado $(...). No entanto, não há nada a impedir de IFSser definido fora do loop:

IFS=$'\n'; while read -r x; do ...; done </tmp/file    # Also success

e é com essa construção que eu posso ver trabalhos select:

IFS=$'\n'; select file in $(find -type f -name 'file*'); do echo "$file"; break; done

Ao escrever código defensiva eu recomendo que a cláusula quer ser executado em um subshell, ou IFSe SHELLOPTSsalvos e restaurados em torno do bloco:

OIFS="$IFS" IFS=$'\n'                     # Split on newline only
OSHELLOPTS="$SHELLOPTS"; set -o noglob    # Wildcards must not expand twice

select file in $(find -type f -name 'file*'); do echo $file; break; done

IFS="$OIFS"
[[ "$OSHELLOPTS" !~ noglob ]] && set +o noglob
roaima
fonte
5
Assumir que IFS=$'\n'é seguro não tem fundamento. Os nomes de arquivos podem perfeitamente conter literais de nova linha.
Charles Duffy
4
Estou francamente hesitante em aceitar tais afirmações sobre o possível conjunto de dados pelo valor nominal, mesmo quando presente. O pior evento de perda de dados em que estive presente foi um caso em que um script de manutenção responsável pela limpeza de backups antigos tentou remover um arquivo criado por um script Python usando um módulo C com uma desreferência incorreta de ponteiro que despejava lixo aleatório - incluindo um curinga separado por espaço em branco - no nome.
Charles Duffy
2
As pessoas que construíram o shell script fazendo a limpeza desses arquivos não se deram ao trabalho de citar porque os nomes "não podiam" falhar na correspondência [0-9a-f]{24}. TB de backups de dados usados ​​para suportar o faturamento de clientes foram perdidos.
Charles Duffy
4
Concordo completamente com @CharlesDuffy. Não lidar com casos extremos só é bom quando você está trabalhando interativamente e pode ver o que está fazendo. selectpor seu próprio design, é para soluções com script , portanto, ele deve sempre ser projetado para lidar com casos extremos.
Wildcard
2
@ilkkachu, é claro - você nunca ligaria selectde um shell em que está digitando os comandos a serem executados, mas apenas em um script, em que está respondendo a um prompt fornecido por esse script e onde está esse script executando lógica predefinida (construída sem o conhecimento dos nomes dos arquivos em operação) com base nessa entrada.
Charles Duffy
4

Posso estar fora da minha jurisdição aqui, mas talvez você possa começar com algo assim, pelo menos não tem nenhum problema com o espaço em branco:

find -maxdepth 1 -type f -printf '%f\000' | {
    while read -d $'\000'; do
            echo "$REPLY"
            echo
    done
}

Para evitar possíveis suposições falsas, conforme observado nos comentários, esteja ciente de que o código acima é equivalente a:

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
flerb
fonte
read -dé uma solução inteligente; obrigado por isso.
DopeGhoti 13/07/19
2
read -d $'\000'é exatamente idêntico read -d '', mas para pessoas enganosas sobre os recursos do bash (implicando, incorretamente, que ele é capaz de representar NULs literais dentro de strings). Execute s1=$'foo\000bar'; s2='foo'e tente encontrar uma maneira de distinguir entre os dois valores. (Uma versão futura pode normalizar com o comportamento de substituição de comando, tornando o valor armazenado equivalente a foobar, mas esse não é o caso hoje).
Charles Duffy