Substituição de comando: divisão na nova linha, mas não no espaço

30

Sei que posso resolver esse problema de várias maneiras, mas estou me perguntando se existe uma maneira de fazê-lo usando apenas os bash built-ins e, se não, qual é a maneira mais eficiente de fazer isso.

Eu tenho um arquivo com conteúdos como

AAA
B C DDD
FOO BAR

pelo que quero dizer apenas que ele tem várias linhas e cada linha pode ou não ter espaços. Eu quero executar um comando como

cmd AAA "B C DDD" "FOO BAR"

Se eu usar cmd $(< file)eu recebo

cmd AAA B C DDD FOO BAR

e se eu usar cmd "$(< file)"eu recebo

cmd "AAA B C DDD FOO BAR"

Como obtenho cada linha tratada com exatamente um parâmetro?

Old Pro
fonte

Respostas:

26

Portably:

set -f              # turn off globbing
IFS='
'                   # split at newlines only
cmd $(cat <file)
unset IFS
set +f

Ou usando um subshell para fazer as IFSalterações na opção e local:

( set -f; IFS='
'; exec cmd $(cat <file) )

O shell executa divisão de campo e geração de nome de arquivo no resultado de uma substituição de variável ou comando que não está entre aspas duplas. Portanto, você precisa desativar a geração de nome de arquivo com set -fe configurar a divisão de campo com IFSpara fazer apenas novas linhas separarem campos.

Não há muito a ganhar com construções bash ou ksh. Você pode tornar IFSlocal uma função, mas não set -f.

No bash ou no ksh93, você pode armazenar os campos em uma matriz, se precisar passá-los para vários comandos. Você precisa controlar a expansão no momento em que cria a matriz. Em seguida, "${a[@]}"expande para os elementos da matriz, um por palavra.

set -f; IFS=$'\n'
a=($(cat <file))
set +f; unset IFS
cmd "${a[@]}"
Gilles 'SO- parar de ser mau'
fonte
10

Você pode fazer isso com uma matriz temporária.

Configuração:

$ cat input
AAA
A B C
DE F
$ cat t.sh
#! /bin/bash
echo "$1"
echo "$2"
echo "$3"

Preencha a matriz:

$ IFS=$'\n'; set -f; foo=($(<input))

Use a matriz:

$ for a in "${foo[@]}" ; do echo "--" "$a" "--" ; done
-- AAA --
-- A B C --
-- DE F --

$ ./t.sh "${foo[@]}"
AAA
A B C
DE F

Não é possível descobrir uma maneira de fazer isso sem essa variável temporária - a menos que a IFSalteração não seja importante cmd, nesse caso:

$ IFS=$'\n'; set -f; cmd $(<input) 

deve fazê-lo.

Esteira
fonte
IFSsempre me deixa confusa. IFS=$'\n' cmd $(<input)não funciona IFS=$'\n'; cmd $(<input); unset IFSfunciona. Por quê? Eu acho que vou usar(IFS=$'\n'; cmd $(<input))
Old Pro
6
@OldPro IFS=$'\n' cmd $(<input)não funciona porque apenas define IFSno ambiente de cmd. $(<input)é expandido para formar o comando, antes que a atribuição IFSseja executada.
Gilles 'SO- stop be evil'
8

Parece que a maneira canônica de fazer isso bashé algo como

unset args
while IFS= read -r line; do 
    args+=("$line") 
done < file

cmd "${args[@]}"

ou, se sua versão do bash tiver mapfile:

mapfile -t args < filename
cmd "${args[@]}"

A única diferença que posso encontrar entre o mapfile e o loop while-read versus o one-liner

(set -f; IFS=$'\n'; cmd $(<file))

é que o primeiro converterá uma linha em branco em um argumento vazio, enquanto o one-liner ignorará uma linha em branco. Nesse caso, o comportamento de uma linha é o que eu preferiria de qualquer maneira, portanto, um bônus duplo por ser compacto.

Eu usaria, IFS=$'\n' cmd $(<file)mas não funciona, porque $(<file)é interpretado para formar a linha de comando antes de IFS=$'\n'entrar em vigor.

Embora não funcione no meu caso, agora aprendi que muitas ferramentas suportam linhas de terminação, em null (\000)vez das newline (\n)quais facilitam muito isso quando se lida com, por exemplo, nomes de arquivos, fontes comuns dessas situações :

find / -name '*.config' -print0 | xargs -0 md5

alimenta uma lista de nomes de arquivos totalmente qualificados como argumentos para o md5 sem globbing, interpolação ou qualquer outra coisa. Isso leva à solução não integrada

tr "\n" "\000" <file | xargs -0 cmd

embora isso também ignore linhas vazias, mas captura linhas que possuem apenas espaço em branco.

Old Pro
fonte
Usar cmd $(<file)valores sem citar (usando a capacidade do bash de dividir palavras) é sempre uma aposta arriscada. Se houver alguma linha, *ela será expandida pelo shell para uma lista de arquivos.
3

Você pode usar o bash embutido mapfilepara ler o arquivo em uma matriz

mapfile -t foo < filename
cmd "${foo[@]}"

ou, não testado, xargspode fazê-lo

xargs cmd < filename
Glenn Jackman
fonte
Na documentação do mapfile: "mapfile não é um recurso comum ou portátil do shell". E, de fato, não é suportado no meu sistema. xargstambém não ajuda.
Old Pro
Você precisaria xargs -douxargs -L
James Youngman 27/05
@ James, não, eu não tenho uma -dopção e xargs -L 1executa o comando uma vez por linha, mas ainda divide os argumentos no espaço em branco.
Old Pro
1
@ OldPro, bem, você pediu "uma maneira de fazer isso usando apenas bash built-ins" em vez de "um recurso de shell comum ou portátil". Se sua versão do bash for muito antiga, você pode atualizá-la?
Glenn Jackman
mapfileé muito útil para mim, pois pega linhas em branco como itens de matriz, o que o IFSmétodo não faz. IFStrata as novas linhas contíguas como um único delimitador ... Obrigado por apresentá-lo, como eu não estava ciente do comando (embora, com base nos dados de entrada do OP e na linha de comando esperada, parece que ele realmente deseja ignorar as linhas em branco).
Peter.O
0
old=$IFS
IFS='  #newline
'
array=`cat Submissions` #input the text in this variable
for ...  #use parts of variable in the for loop
... 
done
IFS=$old

A melhor maneira que eu pude encontrar. Apenas funciona.

Rahul Bali
fonte
E por que funciona se você definir o IFSespaço, mas a questão é não dividir no espaço?
RalfFriedl
0

Arquivo

O loop mais básico (portátil) para dividir um arquivo em novas linhas é:

#!/bin/sh
while read -r line; do            # get one line (\n) at a time.
    set -- "$@" "$line"           # store in the list of positional arguments.
done <infile                      # read from a file called infile.
printf '<%s>' "$@" ; echo         # print the results.

Qual será impresso:

$ ./script
<AAA><A B C><DE F>

Sim, com IFS padrão = spacetabnewline.

Por que funciona

  • O IFS será usado pelo shell para dividir a entrada em várias variáveis. Como existe apenas uma variável, nenhuma divisão é realizada pelo shell. Portanto, nenhuma mudança é IFSnecessária.
  • Sim, espaços / guias iniciais e finais estão sendo removidos, mas não parece ser um problema neste caso.
  • Não, nenhum globbing é feito, pois nenhuma expansão é sem aspas . Então, não é set -fnecessário.
  • A única matriz usada (ou necessária) são os parâmetros posicionais do tipo matriz.
  • A -ropção (bruta) é evitar a remoção da maioria das barras invertidas.

Isso não funcionará se for necessária a divisão e / ou globbing. Nesses casos, é necessária uma estrutura mais complexa.

Se você precisar (ainda portátil) de:

  • Evite a remoção de espaços / guias à direita e à direita, use: IFS= read -r line
  • Linha de divisão para vars sobre algum personagem, use: IFS=':' read -r a b c.

Divida o arquivo em algum outro caractere (não portátil, funciona com ksh, bash, zsh):

IFS=':' read -d '+' -r a b c

Expansão

Obviamente, o título da sua pergunta é sobre a divisão de uma execução de comando em novas linhas, evitando a divisão em espaços.

A única maneira de se separar do shell é deixar uma expansão sem aspas:

echo $(< file)

Isso é controlado pelo valor do IFS e, em expansões não citadas, o globbing também é aplicado. Para mkae esse trabalho, você precisa:

  • Definir IFS a nova linha única , para obter a divisão na nova linha única.
  • Desative a opção de concha de cobertura set +f:

    set + f IFS = '' cmd $ (<arquivo)

Obviamente, isso altera o valor do IFS e de globbing pelo resto do script.

Isaac
fonte