Uma das coisas que você certamente vai encarar um dia como um programador de shell scripts é a necessidade de percorrer um arquivo inteiro lendo cada linha e fazer algo com este conteúdo. Veremos neste artigo como fazer isso de maneira segura, robusta e evitando as possíveis armadilhas que podem aparecer no caminho.
Pra adiantar seu lado, vou logo de cara lhe dar a solução que eu considero mais robusta. Em seguida explica cada detalhe dessa estrutura.
while IFS= read -r linha || [[ -n "$linha" ]]; do
echo "$linha"
# faça algo mais interessante aqui...
done < "$arquivo"
Dei esse grande spoiler pois percebi que esse artigo ficou relativamente grande, e uma das coisas que mais valorizo aqui é o seu tempo.
Se você só quer a solução, ali está ela. Mas se quiser entender cada detalhezinho dessa estrutura, continue lendo.
A minha explicação vai partir da solução naïve (ou seja, a solução mais básica e ingênua) de como percorrer cada linha de um arquivo. E a partir de cada problema que encontrarmos vamos adicionar uma maneira de contornar. No final de tudo teremos aquela estrutura que mostrei acima.
Solução 1: read linha
Para fins didáticos, imaginemos que queremos um script que simplesmente imprima na tela cada linha de um arquivo de maneira similar ao uso mais básico do comando cat
.
Vamos chamar esse script de mycat.sh
, e usaremos redirecionamento para um loop while
para alcançar o nosso objetivo.
A primeira vez que eu ouvi falar de redirecionamento de conteúdo de um arquivo para um loop (há duas décadas atrás), a primeira coisa que tentei foi o seguinte:
#!/usr/bin/env bash
# mycat.sh
#
# versão 1: solução naïve
arquivo="$1"
while read linha; do
echo "$linha"
done < "$arquivo"
Nós vamos testando se o nosso mycat.sh
está legal através da comparação com a saída do comando cat
real.
$ # primeiro com cat
$ cat echo-e.txt
Uma coisa interessante sobre o comando 'echo' é que quando
usamos a opção -e podemos usar algumas sequências de caracteres
com um significado especial.
Por exemplo \n significa uma nova linha, e \t significa <TAB>.
$
$ # depois com mycat.sh
$ ./mycat.sh echo-e.txt
Uma coisa interessante sobre o comando 'echo' é que quando
usamos a opção -e podemos usar algumas sequências de caracteres
com um significado especial.
Por exemplo n significa uma nova linha, e t significa <TAB>.
$
Aparentemente tudo bem. Mas ali na última linha ao invés de exibir \n
e \t
, o script mostrou apenas n
e t
. Ou seja, ele “engoliu” a contra-barra.
Mas nós sabemos que o read
é cheio de opções. Deve ter uma maneira de resolver isso.
Dando uma olhada no help read
encontramos a opção -r
, que diz “do not allow backslashes to escape any characters”. Ou seja, não permitir o contra-barra de “escapar” caracter algum.
OK, hora de melhorar o nosso script…
Solução 2: read -r linha
Direto ao código:
#!/usr/bin/env bash
# mycat.sh
#
# versão 1: solução naïve
# versão 2: lidando com \contrabarras\
arquivo="$1"
while read -r linha; do
echo "$linha"
done < "$arquivo"
Vamos como o mycat.sh
vai lidar com o echo-e.txt
agora:
$ ./mycat.sh echo-e.txt
Uma coisa interessante sobre o comando 'echo' é que quando
usamos a opção -e podemos usar algumas sequências de caracteres
com um significado especial.
Por exemplo \n significa uma nova linha, e \t significa <TAB>.
$
Opa! Legal! Vamos testar agora com um arquivo simples.txt
que possui um pouquinho mais de conteúdo:
$ # primeiro com o cat
$ cat simples.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parágrafo.
Aqui no segundo parágrafo vamos colocar
alguns caracteres com \contrabarra\, como por
exemplo o \n e o \t, só para testes...
Acho que está tudo bem. Vamos terminando
aqui no terceiro parágrafo. Até próxima!
$
$ # agora com o mycat.sh
$ ./mycat.sh simples.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parágrafo.
Aqui no segundo parágrafo vamos colocar
alguns caracteres com \contrabarra\, como por
exemplo o \n e o \t, só para testes...
Acho que está tudo bem. Vamos terminando
aqui no terceiro parágrafo. Até próxima!
$
As contra-barras, OK. Mas agora tem uma outra inconsistência ali: os espaços no início de cada parágrafo foram omitidos. :(
Isso está acontecendo porque usamos o read
para atribuir um valor a uma variável, ele automaticamente ignora os espaços que ficam tanto no começo como no final do conteúdo que você informar como entrada.
Na verdade isso acontece devido a variável de ambiente $IFS
. As letras IFS são um acrônimo para Internal Field Separator, esta variável é responsável por “quebrar” o conteúdo de uma linha de comando em argumentos separados.
Geralmente o conteúdo do IFS
é espaço, tabulação e nova linha. Portanto quando o shell encontra esses caracteres ele os ignora e encara o que vem a seguir como um novo parâmetro.
(Se você não tem a menor ideia do que estou falando, não precisa se desesperar. Fica aqui meu compromisso de posteriormente escrever um artigo detalhando o $IFS
de uma maneira que você nunca mais vai esquecer.)
Para contornar esse problema do script ficar “engolindo” os espaços no começo das linhas, vamos usar um $IFS
vazio. Assim o read
vai considerar que tudo que está vindo como entrada é apenas um único argumento.
Solução 3: IFS= read -r linha
#!/usr/bin/env bash
# mycat.sh
#
# versão 1: solução naïve
# versão 2: lidando com \contrabarras\
# versão 3: lidando com espaços no início/fim
arquivo="$1"
while IFS= read -r linha; do
echo "$linha"
done < "$arquivo"
Essa sintaxe que estamos usando IFS= read -r linha
é uma maneira de dizer ao shell que queremos alterar a variável de ambiente $IFS
apenas para a execução do comando read
que vem a seguir.
Essa “técnica” é mencionada lá na manpage do bash, na seção que fala sobre ENVIRONMENT, num parágrafo que diz o seguinte: (tradução livre):
O ambiente para qualquer comando simples ou função pode ser temporariamente alterado prefixando o comando/função com atribuições de parâmetro (…). Estas atribuições afetam somente o ambiente visto por aquele comando.
Portanto, IFS= read -r linha
vai alterar o IFS
somente durante a execução do read
, depois disso ele volta ao normal.
Vamos testar:
$ # primeiro com cat
$ cat simples.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parágrafo.
Aqui no segundo parágrafo vamos colocar
alguns caracteres com \contrabarra\, como por
exemplo o \n e o \t, só para testes...
Acho que está tudo bem. Vamos terminando
aqui no terceiro parágrafo. Até próxima!
$
$ # agora com mycat.sh
$ ./mycat.sh simples.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parágrafo.
Aqui no segundo parágrafo vamos colocar
alguns caracteres com \contrabarra\, como por
exemplo o \n e o \t, só para testes...
Acho que está tudo bem. Vamos terminando
aqui no terceiro parágrafo. Até próxima!
$
Agora sim! Tudo igualzinho ao cat!
Aparentemente já temos uma solução bastante robusta, não é mesmo?
Porém na nossa vida acontecem coisas inesperadas… Ao longo da sua vida de programador shell-script você vai se deparar com arquivos que não foram gerados num ambiente UNIX-like. Podendo inclusive se deparar com um arquivo texto que não terminha com um caractere nova linha.
Para simular tal situação vamos utilizar o head
com a opção -c
(note que nos exemplos a seguir algumas vezes o prompt aparece em lugares diferentes):
$ # quero apenas 105 bytes de simples.txt
$ head -c 105 simples.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parág$
$ # obs: prompt aqui ----^
$
$ # vamos jogar isso num arquivo
$ head -c 105 simples.txt > atipico.txt
$
$ # testando com cat
$ cat atipico.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parág$
$ # obs: prompt aqui ----^
$
$
$ # agora vamos testar com mycat.sh
$ ./mycat.sh atipico.txt
Este é um arquivo texto muito simples
=====================================
$
Eita! No teste com mycat.sh
a última linha foi omitida!
É que no mundo UNIX o caractere nova linha é realmente levado muito a sério…
O problema do mycat.sh
acontece porque quando o read
não encontra o caracter de nova linha ele “retorna falso” (ou seja, ao final da execução a variável $?
é diferente de zero).
No entanto uma coisa curiosa acontece: mesmo retornando falso, o valor da variável é atualizado.
Observe essa demonstração:
$ # testando com var1
$ read var1
digitei isso e teclei <ENTER>
$ echo "$?"
0
$ # esse zero significa que o read terminou com sucesso
$ echo "$var1"
digitei isso e teclei <ENTER>
$
$ # vamos a outro teste, com var2
$ read var2
mandando um end-of-file com <CTRL+D>$ echo "$?"
1
$ # esse um significa que o read terminou com alguma falha
$ echo "$var2"
mandando um end-of-file com <CTRL+D>
$ # no entanto o conteúdo de var2 foi atualizado!
Desta forma ficou claro que quando o read
recebe alguns caracteres e encontra um end-of-file antes de encontrar um nova linha, acontecem duas coisas:
- O conteúdo da variável é atualizado.
- O
read
retorna com falha.
Vamos nos aproveitar destes dois fatos e tornar o nosso mycat.sh
ainda mais robusto!
Solução 4: IFS= read -r linha || [[ -n "$line" ]]
#!/usr/bin/env bash
# mycat.sh
#
# versão 1: solução naïve
# versão 2: lidando com \contrabarras\
# versão 3: lidando com espaços no início/fim
# versão 4: lidando com término inesperado do input
arquivo="$1"
while IFS= read -r linha || [[ -n "$linha" ]]; do
echo "$linha"
done < "$arquivo"
A sintaxe ||
pode ser “traduzida” como “se, e somente se, o comando a esquerda do ||
falhar, execute o comando a direita”. Portanto, caso o read
falhe o script testa se a variável $linha
não está vazia. Isso é aproveitar aqueles fatos que mencionei acima (1. read
falha; 2. variável é atualizada).
Agora, se o read
falhar e também o teste da variável $linha
vazia falhar, aí chegamos ao fim do while
.
Vamos direto ao teste:
$ # primeiro com cat
$ cat atipico.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parág$
$ # prompt voltou aqui --^
$
$ # agora testando com mycat.sh
$ ./mycat.sh atipico.txt
Este é um arquivo texto muito simples
=====================================
Este é o primeiro parág
$ # <-- o prompt voltou aqui
$
OK… Não ficou 100% igual, pois com o cat
o prompt retorna logo após o final efetivo do arquivo.
O mycat.sh
imprime um nova linha após o final do arquivo. No entanto isso não é um caracter nova linha que milagrosamente apareceu ao final de $linha
, mas sim um nova linha impresso pelo echo
. Portanto se você está usando essa estrutura para percorrer o arquivo linha por linha para fazer um processamento mais sério do que simplesmente imprimir a linha, pode ficar tranquilo que os dados estarão íntegros.
Ufa! Acabamos!