# Adicionando uma suíte de testes a um projeto de 20 anos de idade
Nota de 5 de Maio de 2024
As chuvas pioraram mais do que eu esperava, pelo visto São Pedro estourou um cano enquanto limpava a casa; o estado do Rio Grande do Sul, onde resido, está passando por um período terrível de chuvas e, pela falta de infraestrutura (e até de manutenção) dada à corrupção, incompetência ou até mesmo inocência das autoridades locais, enchentes e rompimento de estradas. Se você reside no Brasil e pode ajudar em algo, acesse ParaQuemDoar.com.br e, por favor, tente ajudar de alguma forma.
Nota de 12 de Maio de 2024
Num geral, as coisas aparentam melhora, mas ainda há risco: o nível do rio Taquari subiu em seis metros e seis barragens estão em alerta de ruptura em virtude de estarem operando em sobrecarga. Felizmente, os órgãos responsáveis por essas instalações garantiram que ações estão sendo realizadas para que não haja tanto desgaste na estrutura das instalações.
Nota de 21 de Junho de 2024
Depois de um bom tempo fora desse artigo por várias questões que aconteceram, resolvi voltar nessa semana a fim de terminá-lo depois de ficar sem abrir o arquivo desde o dia 5.
Nota de 7 de Setembro de 2025
Santo Deus, passou tempo… As enchentes acabaram, os incompetentes foram reeleitos — e alguns competentes perderam a reeleição — e até mesmo se “trocou” de Papa. Acho que é hora do artigo ser finalizado, já ficou “fermentando” um bom tempo. A propósito, feliz dia da Independência.
Data de começo de escrita: 26 de Abril de 2024
Introdução
Uns nove dias atrás — ou, melhor dizendo, cinco, afinal comecei a escrever
este artigo à meia-noite do dia 27 enquanto assistia ao Jornal da Globo mas,
como São Pedro andou arrastando os móveis e lavando o chão, eu tive que parar de
escrever pois, além de faltar luz graças à magnífica infraestrutura da cidade,
entrou água na minha garagem e só estou conseguindo voltar agora, dia 1º de
Maio, sendo que provavelmente irei terminar perto do dia 10 demorar mais de
um ano por conta de diversos fatores —, fiz um artigo estudando o sistema
de montagem do Heirloom.
A partir daquele artigo, aprendi — ou melhor, de novo, aprendemos — sobre
como o sistema em si funciona e qual a forma correta de adicionar uma nova
subpasta no projeto, a fim de criar uma suíte de testes que seja totalmente
integrada.
Em complemento, talvez para tentar despistar um certo
“Sebastianismo amoroso” que
criei depois daquela segunda-feira de doer (e praticar a escrita mais um bocado),
estou escrevendo esse aqui, com maiores detalhes sobre como funcionarão os
testes e algumas escolhas que decidi fazer na última semana desde que foram
importados verbatim do Toybox
0.8.11.
Caso você ainda não tenha lido o primeiro artigo, recomendo fortemente que
leia.
Aqua vitæ: O que descobrimos com a “destilação”?
Certo, tentando condensar o último artigo em uma frase, nós sabemos que a
configuração principal das pastas que fazem parte do projeto é feita no arquivo
makefile
e com a alteração da regra makefiles
para incluir uma nova
pasta sem dependência na variável SUBDIRS
, além de que cada pasta que for
interagir com o sistema de montagem deve ter o seu próprio arquivo
Makefile.mk
com regras definidas (e outros específicos de cada pasta).
Isso é o suficiente para sabermos como implementar uma suíte de testes no
projeto da forma correta.
“Água de beber, camará”: Implementando a suíte de testes
Tentarei sintetizar aqui o que estamos fazendo no sistema de montagem antes de falar dos scripts de teste de alguns dos comandos no pacote — até porque outros não são possíveis de testar por meio de shell scripts por motivos que explicarei depois — pois é a base dos passos a serem tomados a seguir e vai nos dar um bom indicativo de como seguir para rodar os testes em si.
A primeira coisa que eu tentei fazer foi adicionar uma regra nova no
makefile
para que imprimisse uma mensagem, sem criar uma pasta nova ainda.
tests:
@echo "Nde 'ara t'i porang."
Como esperado, ao executar o Make, temos a saída sem mostrar a chamada do
comando echo
em si — ao utilizar o @
, que diz que o comando deve ser
silenciado —, que nos deseja, em Tupi-guarani, um belo dia.
S145% gmake tests
Nde 'ara t'i porang.
Certo, agora seria bacana se conseguíssemos fazer isso por meio de um
Makefile
próprio da pasta de testes. Comecemos criando um diretório e um
arquivo Makefile.mk
.
S145% mkdir build/test
S145% > build/test/Makefile.mk
Como já sabemos, só isso não basta; nós também precisamos citar a existência da
pasta build/test no makefile
, para que o alvo — vulgo arquivo gerado pela
regra — build/test/Makefile.mk
esteja na lista ligada de dependências
(deps
) criada pelo Make.
A coisa mais intuitiva a se fazer seria adicionar o caminho da pasta na variável
SUBDIRS
, mas teria um pequeno problema: sempre que tentássemos compilar o
Heirloom, os testes seriam executados mesmo que contra nossa vontade, pois o
alvo padrão executa um laço for que itera sobre todos as pastas em SUBDIRS
e executa o Makefile de cada uma delas, como vimos anteriormente.
O que fazer?
Bem, podemos adicionar uma nova variável chamada TESTSDIR
e inserir o
caminho da pasta lá, então estaremos independentes da SUBDIRS
:
S145% git diff HEAD makefile
diff --git a/makefile b/makefile
index 04a0655..9141ae2 100644
--- a/makefile
+++ b/makefile
@@ -17,6 +17,8 @@ SUBDIRS = build libwchar libcommon libuxre _install \
tabs tail tapecntl tar tcopy tee test time touch tr true tsort tty \
ul uname uniq units users wc what who whoami whodo xargs yes
+TESTSDIR = build/test
+
dummy: makefiles all
.DEFAULT:
@@ -30,7 +32,7 @@ dummy: makefiles all
.mk:
cat build/mk.head build/mk.config $< build/mk.tail >$@
-makefiles: Makefile $(SUBDIRS:=/Makefile)
+makefiles: Makefile $(SUBDIRS:=/Makefile) $(TESTSDIR:=/Makefile)
Agora podemos tentar escrever um Makefile.mk
e desejar um belo dia de lá:
S145% printf 'all: tests\n\ntests:\n\t@echo "Nde '\''ara t'\''i porang."\n' > build/test/Makefile.mk
S145% cat build/test/Makefile.mk
all: tests
tests:
@echo "Nde 'ara t'i porang."
Ah, temos que mexer no makefile
de novo e dizer que queremos entrar na pasta
de testes e executar o Makefile
de lá na hora de executar os testes, além de
que precisamos adicionar a tal pasta na regra que executa o mrproper
.
@@ -42,7 +44,7 @@ install:
mrproper:
rm -f .foo .FOO Makefile
- + for i in $(SUBDIRS) ;\
+ + for i in $(SUBDIRS) $(TESTSDIR) ;\
do \
(cd "$$i" && $(MAKE) $@) || exit ; \
done
@@ -53,6 +55,9 @@ casecheck: .foo .Foo
.foo .Foo:
echo $@ > $@
+tests:
+ (cd $(TESTSDIR) && $(MAKE)) || exit
+
Se tentarmos executar os testes de primeira, teremos esse erro:
S145% gmake tests
(cd build/test && gmake) || exit
gmake[1]: Entering directory '/tmp/heirloom-07/build/test'
gmake[1]: *** No targets specified and no makefile found. Stop.
gmake[1]: Leaving directory '/tmp/heirloom-07/build/test'
gmake: *** [makefile:59: tests] Error 2
Algo similar acontece com o alvo mrproper
:
S145% gmake mrproper
rm -f .foo .FOO Makefile
for i in build libwchar libcommon libuxre _install banner basename bc bdiff bfs cal calendar cat chmod chown cksum cmp col comm copy cp cpio csplit cut date dc dd deroff diff diff3 dircmp dirname df du echo ed env expand expr factor file find fmt fmtmsg fold getconf getopt grep groups hd head hostname id join kill line listusers ln logins logname ls mail man mesg mkdir mkfifo mknod more mvdir nawk news nice nl nohup oawk od paste pathchk pg pgrep pr printenv printf priocntl ps psrinfo pwd random renice rm rmdir sdiff sed setpgrp shl sleep sort spell split stty su sum sync tabs tail tapecntl tar tcopy tee test time touch tr true tsort tty ul uname uniq units users wc what who whoami whodo xargs yes build/test ;\
do \
(cd "$i" && gmake mrproper) || exit ; \
done
gmake[1]: Entering directory '/tmp/heirloom-07/build'
gmake[1]: *** No rule to make target 'mrproper'. Stop.
gmake[1]: Leaving directory '/tmp/heirloom-07/build'
gmake: *** [makefile:47: mrproper] Error 2
Por mais que não pareça, esse erro não é algo que devemos nos preocupar, já que
ele, claro como o Sol, indica a quem está tentando fazer tal operação que esse
alvo não existe se o resto do pacote não tiver sido compilado antes. Isso também
nos poupa de verificações extras, pois os testes só poderiam ocorrer depois do
Heirloom estar compilado e instalado em alguma localização — algo que
precisaremos nos incomodar depois — de qualquer forma.
Tendo isso em mente, rodaremos o Make até o ponto em que ele gera os
Makefile
s, só para podermos testar se o Makefile
principal dos testes
funciona corretamente.
S145% gmake
cat build/mk.head build/mk.config Makefile.mk build/mk.tail >Makefile
cat build/mk.head build/mk.config build/Makefile.mk build/mk.tail >build/Makefile
# [...]
cat build/mk.head build/mk.config build/test/Makefile.mk build/mk.tail >build/test/Makefile
# [...]
gmake[1]: Leaving directory '/tmp/heirloom-07/build'
^Cgmake: *** [makefile:25: all] Interrupt
E então podemos rodar a regra tests
que, como vimos, entra na pasta de
testes e executa o Makefile.
S145% gmake tests
(cd build/test && gmake) || exit
gmake[1]: Entering directory '/tmp/heirloom-07/build/test'
Nde 'ara t'i porang.
gmake[1]: Leaving directory '/tmp/heirloom-07/build/test'
Agora é interessante que limpemos o sistema de montagem com a regra mrproper
de novo e…
S145% gmake mrproper
# [...]
gmake[1]: Entering directory '/tmp/heirloom-07/build/test'
gmake[1]: *** No rule to make target 'clean', needed by 'mrproper'. Stop.
gmake[1]: Leaving directory '/tmp/heirloom-07/build/test'
gmake: *** [makefile:47: mrproper] Error 2
Epa, esquecemos de declarar uma regra clean
, que é uma das dependências do
mrproper, como podemos ver em build/mk.tail
.
Íntegra do arquivo mk.tail
:
mrproper: clean
rm -f Makefile $(MRPROPER)
Então basta apenas que adicionemos uma declaração em
build/test/Makefile.mk
com a regra fazendo o trabalho de remover os arquivos
que iremos gerar.
S145% printf '\nclean:\n\trm -f passed *~\n' >> build/test/Makefile.mk
S145% cat build/test/Makefile.mk
all: tests
tests:
@echo "Nde 'ara t'i porang."
clean:
rm -f passed *~
O arquivo “passed” ainda não existe e nem é criado pela regra tests
,
mas também não está sendo referenciado ali só para inglês ver, pois é onde
serão salvos os resultados de cada teste.
Ao contrário dos outros arquivos Makefile.mk
, nós não iremos colocar os
arquivos core
e nem log
na lista de arquivos a serem apagados, pois a
aparição deles só faz sentido quando lidamos com um programa em C, não com
uma bateria de shell scripts e Makefiles.
O arquivo “core” é referenciado em todos os arquivos Makefile.mk
do
projeto, é criado pelo núcleo em caso de um despejo de memória (em inglês,
“core dump”) e se localiza na pasta atual de execução do programa. No Linux,
podemos ver evidência disso no arquivo core_pattern
, que fica na pasta
/proc/sys/kernel
:
S145% cat /proc/sys/kernel/core_pattern
core
Sobre o arquivo log
, eu não encontrei nenhuma informação sobre. Como esse
artigo é, de certa forma, apenas um rascunho, talvez essa parte seja
atualizada.
O caractere coringa acompanhado de um til, “*~”, é mencionado para que
qualquer arquivo temporário criado pelo Vim ou pelo Emacs seja apagado.
Agora, após gerar novamente os Makefile
s para que as mudanças se apliquem,
vejamos se está tudo funcionando.
S145% gmake tests
(cd build/test && gmake) || exit
gmake[1]: Entering directory '/tmp/heirloom-07/build/test'
Nde 'ara t'i porang.
gmake[1]: Leaving directory '/tmp/heirloom-07/build/test'
S145% gmake mrproper -C build/test
gmake: Entering directory '/tmp/heirloom-07/build/test'
rm -f passed *~
rm -f Makefile
gmake: Leaving directory '/tmp/heirloom-07/build/test'
Ótimo, conseguimos chegar ao começo do caminho. Já é algo.
Nos ombros dos gigantes… ou numa pilha de anões: como implementar testes de forma correta?
Agora algo que, parafraseando novamente Alighieri, rodeia a cabeça dia e noite
é pensar numa forma sã — e sana, mentalmente falando quanto para quem for
ler tanto para quem for implementar para novos programas — de implementar
esses testes.
Pela razão de diminuir o tempo que levaria-se para escrever cada teste,
importamos os testes da última versão estável do Toybox até esse presente
momento, a 0.8.11, pois lá já existem casos a serem testados e precisaremos
fazer poucas alterações nesse sentido. O problema é que esses testes utilizam
três funções, uma chamada testing
, outra chamada testcmd
e a txpect
,
e elas são muito complexas para sequer pensar em começar a simplificar, além do
estilo de código diferir tanto do nosso que precisaríamos reescrever só para se
encaixar no Heirloom, logo, é preferível que reescrevamos as funções de teste do
zero a fim de se ter código novo e mais simples de manter.
O primeiro pensamento é tentar buscar uma outra referência no mundo da Sun
Microsystems ou da AT&T, pois casaria melhor com um projeto como o Heirloom, mas
até onde pesquisei, nenhuma dessas empresas fez testes na época em que estavam
trabalhando diretamente na userspace do SunOS e do UNIX, respectivamente,
dependendo num geral de um acordo entre cavaleiros de reportar problemas caso
ocorressem e de pacotes de correção. Padrão.
Quando faltam referências, o que resta fazer é sintetizar o que se sabe sobre o
que é desejado com alguma referência moderna.
Muito tempo antes de começar a implementar testes para o Heirloom, eu já tinha
visto os testes do pigz, que totalmente eram escritos
em Makefile — o que nos pouparia dor de cabeça com shell, pois não teríamos
de especificar um novo, como o Korn Shell 93, ou escrever algo cheio de
código-porcelana para compatibilidade e lento para rodar na pior
implementação possível —, e eles são assim:
Linhas 80 até 97, arquivo Makefile
:
test: pigz
./pigz -kf pigz.c ; ./pigz -t pigz.c.gz
./pigz -kfb 32 pigz.c ; ./pigz -t pigz.c.gz
./pigz -kfp 1 pigz.c ; ./pigz -t pigz.c.gz
./pigz -kfz pigz.c ; ./pigz -t pigz.c.zz
./pigz -kfK pigz.c ; ./pigz -t pigz.c.zip
printf "" | ./pigz -cdf | wc -c | test `cat` -eq 0
printf "x" | ./pigz -cdf | wc -c | test `cat` -eq 1
printf "xy" | ./pigz -cdf | wc -c | test `cat` -eq 2
printf "xyz" | ./pigz -cdf | wc -c | test `cat` -eq 3
(printf "w" | gzip ; printf "x") | ./pigz -cdf | wc -c | test `cat` -eq 2
(printf "w" | gzip ; printf "xy") | ./pigz -cdf | wc -c | test `cat` -eq 3
(printf "w" | gzip ; printf "xyz") | ./pigz -cdf | wc -c | test `cat` -eq 4
-@if test "`which compress | grep /`" != ""; then \
echo 'compress -f < pigz.c | ./unpigz | cmp - pigz.c' ;\
compress -f < pigz.c | ./unpigz | cmp - pigz.c ;\
fi
@rm -f pigz.c.gz pigz.c.zz pigz.c.zip
Entretanto, seria impraticável escrever testes mais complexos do que isso para
alguns programas, até porque não estaríamos apenas comparando o comprimento em
caracteres da saída do programa, mas também o valor devolvido. Sem falar que,
considerando que os testes foram originalmente importados do Toybox para que
fossem adaptados, isso também significaria não apenas uma pequena modificação,
mas também uma reescrita completa para algo que, mesmo similar, é outra
linguagem de programação com outras funções e limitações.
O Leonardo (LeonardoCBoar
), um amigo
meu que estuda na UFABC e programa em C++, me recomendou uma batida de olho na
introdução do GoogleTest, que é, resumindo, um
framework para se escrever testes para programas em C++.
Isso é uma enorme simplificação do que ele de fato faz, mas meu interesse é
saber como os testes são escritos e o que pode-se importar de lá. Além de que,
ao contrário do que realmente precisamos fazer, que é testar cada programa já
compilado, o GoogleTest funciona como uma biblioteca que é chamada por um
programa independente em C++ (escrito por quem vai fazer o teste) para testar
funções do projeto em si. Escrevendo de forma mais técnica, o GoogleTest é um
framework para testes estruturais, vulgo testes de caixa-branca (“white box
tests”, em inglês), onde, como o nome diz, se conhece a estrutura do programa
— ou, no caso de um projeto como o LLVM, dos programas e das bibliotecas do
projeto — e utiliza-se dessa vantagem para testar o código-fonte desde sua
fundação; enquanto isso, precisamos fazer testes funcionais, vulgo testes de
caixa-preta (“black box tests”, em inglês), onde, por mais que conheçamos a
estrutura de cada programa do pacote por termos acesso a seu código-fonte
integral, iremos testar a funcionalidade do código-fonte já compilado,
desconhecendo — ou, melhor, ignorando — sua estrutura interna, pois assim
acaba por ser mais eficiente para testar se algum programa tem determinado
problema em determinada plataforma, se os valores quebram em determinadas
condições e afins.
Por mais que pareça loucura (ou idiotice), o objetivo é saber qual os métodos
usados para se escrever os testes em si — ou seja, como se compara o valor
esperado com o valor real que será devolvido e afins —, assim criando (ou
melhorando) uma maneira para se escrever os nossos testes, no estilo
sborniense (ou sbørniano? Talvez sbørnio?) de síntese.
Na página de introdução do
GoogleTest, é apresentado,
além do funcionamento, conceitos interessantes que devem ser anotados para
quando começarmos a implementar a suíte de testes para o Heirloom:
- Organizar os testes assim como o código e, caso haja código que seja repetido entre os testes, tentar manter isso em uma subrotina compartilhada;
- Portabilidade entre sistemas — falaremos disso adiante;
- Liberdade a quem for escrever os testes para que consiga focar no conteúdo dos testes em si e não em sua execução. Caso contrário, não estaríamos fazendo uma suíte com funções para tal, mas sim testes como os do pigz, onde quem vai escrever precisa se preocupar com o método para verificar e não apenas com o conteúdo a ser verificado, algo que um projeto grande, como o Heirloom, não pode se dar o direito, pois criaria testes enormes, difíceis de ler e ineficientes;
- Prover a maior quantidade de informação que tivermos acesso sobre o porquê do erro ter ocorrido, pois assim é mais fácil de se identificar o erro e, caso venha do nosso lado, corrigi-lo;
Após isso, indo para os conceitos básicos sobre testes, vemos que o GoogleTest
trabalha com asseverações, ou seja, afirmações que são verdadeiras ou falsas
dependendo dos resultados em comparação com o que é esperado. Clássica, os
testes do Toybox já faziam isso. Entretanto, temos a diferença de que não é oito
ou oitenta, mas sim sucesso, falha não-fatal e falha fatal. Essa divisão entre
falha não-fatal e fatal é importante em casos onde, por exemplo, não faria
sentido prosseguir — por exemplo, se o tamanho da string de saída for
diferente do tamanho da string esperada, podemos parar de imediato o teste
atual e prosseguir para o próximo.
Essa diferenciação é feita primariamente entre dois “polos”: os macros
da família EXPECT
e da família ASSERT
, como podemos ver aqui:
The assertions come in pairs that test the same thing but have different effects on the current function.
ASSERT_*
versions generate fatal failures when they fail, and abort the current function.EXPECT_*
versions generate nonfatal failures, which don’t abort the current function. UsuallyEXPECT_*
are preferred, as they allow more than one failure to be reported in a test. However, you should useASSERT_*
if it doesn’t make sense to continue when the assertion in question fails.
Em suma, ASSERT
funciona como uma afirmação — “e tenho dito!” — logo,
ela precisa necessariamente ser verdade, caso contrário, algo realmente está
errado e não pode-se continuar, já EXPECT
é algo que supomos — “ser ou
não ser” —, então um erro já não seria fatal, mas sim algo digno de um aviso
para que façamos alguma observação no código para determinar o que causou aquela
diferença.
Qualquer um que já tenha programado em Go, por exemplo, saberia a diferença,
pois lá há funções que tratam o erro e a função panic()
, que para o
programa em caso de um erro grave.
Quando uma asseveração for falsa, ou seja, diferente do resultado que esperamos,
devemos informar o máximo que soubermos sobre o porquê falhou, como foi dito lá
em cima na nossa lista de objetivos. Temos a limitação de estarmos fazendo
testes de caixa-preta, mas podemos trabalhar bem em cima disso mesmo assim.
Organizando as nossas referências, nós precisamos fazer uma função para
específicar o tipo de teste que será feito, uma função que retorne um erro fatal
e outra que retorne erros não-fatais. Inicialmente, considerei em fazer uma
função inteira que testasse o comando, mas acredito que tenhamos mais liberdade
usando duas funções separadas, como é no GoogleTest. Como em shell, desde sua
concepção, variáveis do tipo strings são “cidadãs de primeira-classe”, como
disse Stephen Bourne, e, como eu mesmo costumo dizer, até que se prove o
contrário, as únicas, nós não iremos ir muito fundo além de comparar valores
enquanto strings — como, por exemplo, criar uma função específica para
comparar números com vírgula. É necessário criar esse planejamento desde já
pois, na hora de programar, dificilmente adicionaremos algo novo ao que já
temos no papel.
Algo que descobri enquanto esse artigo “fermentava” é que o go test
funciona
de forma praticamente idêntica, então eu poderia ter utilizado-o de referência
também.
“[…] sunt nate res omnes ab hac re una aptatione”: escrevendo novo código e adaptando testes
Programei — e ainda programo — em shell por anos, mas andei a fuçar com C e
Go e isso acabou por “corromper” minha prática um bocado, pois passei a buscar
fazer coisas de forma mais manual ao invés de utilizar o que era já seria
provido — o que é desincentivado em scripts de shell pois acaba por atrapalhar
a portabilidade, afinal, quanto mais controle você tem sobre as operações que o
interpretador realiza, mais complexa a implementação do interpretador precisaria
ser (ao menos em teoria).
Entretanto, isso se mostra um tanto imprático em shells mais simples do que o
Korn Shell, pois não se tem extensões que permitem que a linguagem se comporte
de forma mais próxima de C, como um macro para ler um caractere único de uma
string por vez.
A fim de não adicionar mais uma dependência no projeto, decidi que não usarei
nem o Korn Shell 93 e nem o GNU Bash para escrever as funções que serão
chamadas pelos scripts de testes, então vejamos o que o README pede — ou
ao menos propõe — que esteja no sistema para ser usado como shell:
The following prerequisites are optional:
Bourne shell It is recommended that the Heirloom Bourne shell from http://heirloom.sourceforge.net/sh.html is installed first. The tools will work with any shell, but use of the Bourne shell ensures maximum compatibility with traditional Unix behavior.
Certo, pelo visto usar o Bourne shell do Heirloom, derivado da implementação SVR4 que é obviamente não-POSIX — lembrem-se disso depois —, é algo meramente opicional, certo? Pois bem:
Linhas 1 até 16, arquivo build/mk.config
:
#
# This is the shell used for the compilation phase, the execution of most
# installed scripts, and the shell escapes in the traditional command
# versions. It needs not conform to POSIX. The system shell should work
# fine; for maximum compatibility with traditional tools, the Heirloom
# Bourne shell is recommended. It then must obviously be compiled and
# installed first.
#
SHELL = /bin/sh
#
# Specify the path name for a POSIX-conforming shell here. For example,
# Solaris users should insert /usr/xpg4/bin/sh. This shell is used for
# the shell escape in the POSIX variants of the ed utility.
#
POSIX_SHELL = /bin/sh
Sim, usar o Bourne shell tradicional é opicional, mas as instruções são taxativas em dizer que praticamente toda a fase de montagem utilizará o shell tradicional e não o POSIX. Ou seja, por uma simples formalidade nós — e por nós, leia-se eu — seremos obrigados a escrever os scripts num “scriptum francus” que funcione tanto no Bourne shell quanto no POSIX, logo, não podemos usar extensões específicas de nenhum dos dois e, por conta disso, iremos provavelmente depender em vários programas independentes ao invés de usar implementações embutidas, ao menos para algumas coisas.
“Por que São Jorge mora na Lua?”: Alguns porquês que encontramos no Bourne shell (e seus “clones”)
Comecei por praticar mexer com o Bourne shell tradicional criando algo simples,
um programa que pudesse contar números de 1 até o número que eu passasse.
É intuitivo, como não existem laços for “chiques” ao estilo-C, usar um
laço condicional while que lide com um valor booleano — que em shell são
dois binários que retornam um valor de estado de saída, mas isso não deve nos
preocupar muito por ora — e que pare quando chegar no valor desejado.
Óbvio que poderíamos simplesmente usar a condicional “$n -eq $1
”, que,
caso você nunca tenha mexido com shell, seria como iterar até que o valor de
n
seja o mesmo do primeiro argumento passado, $1
, de primeira; no
entanto, fazer dessa forma nos permite, em tese, que possamos efetuar
movimentos mais ousados mais à diante como, por exemplo, implementar um
contador de caracteres de uma string sem depender de nenhum comando externo.
p=false
n=0
while ! $p; do
n=$(( $n + 1 ))
printf '%d\n' $n
[ $n -eq $1 ] && p=true
done
Não é a Oitava maravilha, mas parece bom. Vamos testar.
S145% ./sh teste2.sh 10
teste2.sh: syntax error at line 4: `n=$' unexpected
De primeira, já temos uma surpresa e algo que eu francamente havia me esquecido
depois de passar bons anos longe do “shell Bourne tradicional” é o fato de que
ele não suporta operações aritméticas nativamente, mas sim depende do comando
expr
.
Isso me fez lembrar de uma palestra feita pelo próprio Stephen Bourne na BSDcan
de 2015, que está disponível no
YouTube, onde, mesmo com toda a
evolução que houve desde a primeira versão até a versão do UNIX v7, que é de
onde o shell do Heirloom é derivado, é mostrado que o shell foi originalmente
pensado para prover “apenas” estruturas elementares de controle de fluxo, como
laços e condições, variáveis e substituições das mesmas e gerenciamento de
sinais e processos, e que qualquer outra coisa além disso deveria ser
provida por algum meio externo, seja um módulo, programa, etc. Logo, por essa
ideia, que é o âmago da filosofia UNIX que vigorava na época, não haveria
o porquê de implementar algo no shell que já seria implementado pelo comando
expr
(1),
além de que, pelo fato do shell ter apenas strings como tipo (como falei
anteriormente), não se esperaria que se usasse de fato funções aritméticas a
todo momento, apenas filtros de texto como awk
ou grep
; então,
finalizando, não haveria o porquê de “martelar” tais funções no binário do
programa, já que seria mais alguma coisa para carregar na memória em uma época
em que recursos de hardware eram realmente escassos.
Enfim, que se resolva o problema…
4c4
< n=$(( $n + 1 ))
---
> n=`expr $n + 1`
E que tentemos outra vez:
S145% ./sh teste2.sh 10
teste2.sh: !: not found
Ótimo, parece que o !
, que significaria “negar” o status de saída de um
comando, também não está implementado. Estranho, seria algo que veríamos em C
tranquilamente e, por mais que pudéssemos encontrar outro caminho para resolver
isso — e que seria mais lógico, até —, não faria sentido deixar de fora.
Pois bem, segundo o apêndice A do livro “Learning the Korn Shell 1st. Edition”,
lançado pela editora O’Relly em Junho de 1993, que fala sobre a norma POSIX
1003.2 para shell, diz que sim, !
é parte do padrão POSIX desde pelo
menos 1992 — que foi o ano em que esse padrão foi lançado, depois de seis
anos de discussões acaloradas entre programadores e empresas que forneciam
software para UNIX —, mas que isso não significa que o Bourne shell
tenha adotado-o, até porque, segundo o arquivo cmd.c
, que define as
palavras-chave usadas no shell, a última vez que o dito recebeu alguma
alteração por mão da AT&T foi em 1989, ou seja, coisa de três anos
antes do padrão ser lançado; depois disso, apenas alguns reparos foram
feitos por mão da Sun Microsystems por cerca de 1996 e do Gunnar Ritter — e
isso já na era do Heirloom por volta de 2005.
Por mais que para mim e outros programadores de shell seja natural desferir
diversos xingamentos contra a equipe da AT&T ou até mesmo estendê-los para o
comitê do POSIX — “por que diabos vocês não implementaram arrays?”, ou,
caso você esteja do outro lado da força, “por que tanto bloat se o
expr
(1) já implementa isso?” — devemos entender, ou simplesmente ler o
bendito apêndice do livro, que um padrão como o POSIX surge em uma época
caótica no mundo da computação — e mais especificamente no mundo de
aplicação corporativa dessa —, onde, mesmo com uma linguagem tecnicamente
portável (linguagem C) e com progresso constante no campo do hardware, havia
empresas de software disputando contratos trilhardários a tapa e implementando
funções proprietárias para tentar se destacar no mercado — ou seja, se um
estagiário implementasse um comando que parecesse interessante, pararia na
versão de produção do sistema, mesmo que ainda com várias falhas a serem
descobertas —, logo o padrão POSIX tinha a importante missão de estabelecer
o mínimo para que esses sistemas continuassem compatíveis entre si, por isso há
tanto o dito “bloat” (contra o qual o pessoal do site cat-v.org
tanto se
revoltou), quanto a abertura de espaço para que cada um implementasse as
extensões desejadas, de forma que ainda houvesse alguma competitividade —
inclusive, por isso o Korn Shell 93 e, por tabela, o GNU BrokenBourne-Again
Shell, tem funções adicionais que vão desde arrays e laços for estilo-C até
pomposos “pseudo-ponteiros” (os ditos nameref
) e namespaces.
Lógico que críticas são cabíveis, principalmente ao que se chama de
“feature creep”, que seria a sobrecarga de novas utilidades e de novas
funções em programas já existentes e que muitas das vezes acabam por ser
redundantes — algo que eu estranhei no padrão POSIX.1-2024 — mas que se
reconheça, no caso anterior, o porquê dessas diferenças terem se desenvolvido,
e que também se busque formas de mitigar os problemas sintetizando as soluções
com base na filosofia UNIX e as necessidades de um usuário (e de um programador)
médio.
Certo, tendo tudo isso em mente, além de uma lista do que pode não estar
presente no Bourne shell, podemos encontrar uma solução para o problema. Então,
que voltemos ao código.
Bem, nós poderíamos inverter a condição, então ficaria assim:
p=true
n=0
while $p; do
n=`expr $n + 1`
printf '%d\n' $n
[ $n -eq $1 ] && p=false
done
Por mais que isso pareça melhor, afinal seria a solução lógica — se o número
n
se tornou igual ao que queríamos ($1
), então não precisamos mais somar
nada (p=false
) —, lembremos que estamos apenas “brincando” com o shell, ou
seja, isso tudo é um exemplo.
Pensemos que talvez tenhamos de fazer um laço assim utilizando um programa, logo
seria bom ter alguma alternativa mais compacta do que algo como
("$p" || true)
— que, diga-se de passagem, não funciona na prática —, ou
seja, precisaremos implementar uma função para isso. Pois bem, que tal uma
função com o nome de !
? Assim poderíamos ter algo que lembrasse a
palavra-chave original e não precisaríamos mexer no código, apenas fazer uma
condicional para que caso !
não fosse definido usasse nossa função. Certo,
podemos começar a prototipar na forma de um one-liner:
$ !() { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
!: is not an identifier
$ type !
! not found
Bem, por essa eu esperaria em um shell que tivesse !
como palavra-chave
reservada, como o Dash ou o Korn Shell:
S145% echo ${.sh.version}
Version AJM 93u+m/1.1.0-alpha+f2bc1f45 2024-03-24
S145% function ! { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
/usr/bin/ksh: syntax error: `!' unexpected
S145% !() { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
/usr/bin/ksh: syntax error: `(' unexpected
S145% dash
$ !(){ (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
dash: 10: Syntax error: ")" unexpected
Agora o GNU Bash… É, é complicado:
luiz@S145:/run/media/luiz/Novo Volume/heirloom-sh-050706$ echo ${BASH_VERSION}
5.2.26(1)-release
luiz@S145:/run/media/luiz/Novo Volume/heirloom-sh-050706$ !() { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
bash: !: event not found
luiz@S145:/run/media/luiz/Novo Volume/heirloom-sh-050706$ function ! { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
luiz@S145:/run/media/luiz/Novo Volume/heirloom-sh-050706$ type !
! is a shell keyword
luiz@S145:/run/media/luiz/Novo Volume/heirloom-sh-050706$ type -a !
! is a shell keyword
! is a function
function ! ()
{
( exec "$@" );
if [ $? -eq 0 ]; then
return 1;
else
return 0;
fi
}
Bem, o Bash não tem o apelido de “Broken-Again” à toa…
Confesso que geralmente pego pesado com o Bash, mas esse tipo de comportamento
deveria ser considerado simplesmente inaceitável — e muito menos deveria ser
contornado com a desculpa de “a palavra-chave function
é
obsoleta”
dita por alguns “lealistas do Bash”. Não, isso não causa nenhum tipo de
vulnerabilidade — até porque o Bash prioriza suas palavras-chaves antes de
qualquer função (graças a Deus!) — e, apesar do comando type
nos mostrar
isso ao imprimir o nome de palavras-chave antes da função — provavelmente
apenas seguindo a ordem da pilha que armazena as palavras-chave e depois
funções definidas pelo usuário —, decidi “provar” pela experimentação e
fiz essa pequena brincadeira, dessa vez uma função que imprime uma mensagem e
então mata o processo do shell com um belo kill -9
:
luiz@S145:~$ function ! { echo 'Errou!'; kill -9 $$; }
luiz@S145:~$ if ! false; then
> echo 'Glu glu ié ié!'
> fi
Glu glu ié ié!
luiz@S145:~$ type -a !
! is a shell keyword
! is a function
function ! ()
{
echo 'Errou!';
kill -9 $$
}
Digamos que nisso acertaram, they ain’t doing too bad.
Mas, como diria qualquer professor, do “prézinho” até o Ensino Médio,
“é só elogiar que estraga”: esse tipo de brincadeirinha até pode
acontecer caso a nossa função engodo seja declarada com identificador
vulgar, desde que !
seja associado (alias
) a tal pois, como
podemos ver abaixo, esse tipo de associação tem prioridade maior que a
própria palavra-chave da linguagem:
luiz@S145:~/projetos/heirloom-testing$ type -a !
! is a shell keyword
luiz@S145:~/projetos/heirloom-testing$ engodo() { echo 'Errou!'; kill -9 $$; }
luiz@S145:~/projetos/heirloom-testing$ alias !=engodo
luiz@S145:~/projetos/heirloom-testing$ type -a !
! is aliased to `engodo'
! is a shell keyword
luiz@S145:~/projetos/heirloom-testing$ if ! false; then
> echo 'Glu glu ié ié!'
> fi
Errou!
Killed
S145% # De volta ao ksh...
Antes que você diga que o alias não tem efeito em modo não-interativo, saiba que
é possível “envenenar” o .bashrc
com esse código:
S145% printf 'engodo() { echo "Errou!"; kill -9 $$; }\nalias !=engodo\n'>~/.bashrc
S145% cat ~/.bashrc
engodo() { echo "Errou!"; kill -9 $$; }
alias !=engodo
S145% bash
bash-5.2$ type !
! is aliased to `engodo'
… Ou seja, ainda é problema.
À primeira vista, pensei que fosse algo do Bourne shell e que eles replicaram por uma mera questão de compatibilidade, até que testei no Korn Shell — afinal, já estava ali mesmo…
S145% type -a !
! is a keyword
S145% engodo() { print 'Errou!'; kill -9 $$; }
S145% alias !=engodo
Bem, não pestanejou com o identificador… Como será que isso está na pilha de memória?
S145% type -a !
! is a keyword
! is an alias for engodo
Promissor, mas testemos mais um bocado para ter a certeza:
S145% if ! false; then
> echo 'Glu glu ié ié!'
> fi
Glu glu ié ié!
Sem baixas? Beleza pura! Pelo menos temos a certeza de que o Korn Shell é imune
a esse tipo de ataque — apesar de não rejeitar o identificador, como faz no
caso de uma função. Mas, como sempre tem um que vai dizer que o Korn Shell não é
verdadeiramente POSIX ou que não está com o set -o posix
“ativado”, vamos
fazer o exato mesmo teste no Almquist shell — ou, mais especificamente, a sua
bifurcação, o dash
, mas que é efetivamente a mesma coisa. Vamos usar o mesmo
teste de antes:
S145% dash
$ type !
! is a shell keyword
$ engodo() { echo 'Errou!'; kill -9 $$; }
$ alias !=engodo
Certo, até agora temos o exato mesmo comportamento das outras vezes, mas vejam o
que acontece quando eu verifico o que o shell entende por “!
” de novo
utilizando o comando type
:
$ type !
! is a shell keyword
Ou seja, ele nem ao menos considerou o alias, tanto é que, se tentarmos
executar o !
por si só, teremos a mesma resposta que teríamos antes do alias:
$ !
dash: 5: Syntax error: newline unexpected
Antes que pergunte: sim, isso é absolutamente esperado, afinal ele espera pelo menos um comando para ser executado a fim de obter seu estado de saída, diferente do Bash e do Korn Shell que eu francamente não sei como é implementado — talvez eles apenas ignorem caso não seja passado um comando a ser executado.
Poderíamos parar por aqui, mas vamos terminar só por desencargo de consciência:
$ if ! false; then
> echo 'Glu glu ié ié!'
> fi
Glu glu ié ié!
Touché! Isso não é “do POSIX”, é verdadeiramente uma falha do Bash.
Apesar dos trocadilhos que fiz durante esse artigo até agora, não vou — e nem
posso — julgar a equipe do GNU por tal falha, pois não conheço a implementação
por dentro, logo possivelmente eles até já conhecem essa falha, mas não
consertaram-na pois precisaria mexer consideravelmente no código-fonte. Caso não
a conheçam ainda, também não é uma tentativa de tripudiar e nem ao menos criar
pânico para uma transição em direção ao Korn Shell — apesar de scripts
escritos para o Bash poderem rodar com nenhuma (ou pouquíssima) modificação no
Korn Shell —, mas sim apenas de reportar; afinal, concorrência foi, é e sempre
será ótima.
Enfim, voltando ao Bourne shell, !
não seria um bom identificador de
qualquer forma… Que tal ¬
?
$ ¬() { (exec "$@"); if [ $? -eq 0 ]; then return 1; else return 0; fi; }
\302\254: is not an identifier
Ué, também não pode? Engraçado o fato de que o caractere foi impresso como
seu código octal, com \302
sendo o início da representação e \254
o
valor do caractere ¬
em si, de forma separada tal como se apenas suportasse
caracteres ASCII primitivos — podemos ser mais específicos e lembrar que
emuladores de terminal modernos como o Konsole emitem caracteres com
codificação UTF-8 para o comando (nesse caso, o shell), sem falar em editores
de texto como o Vim (que são configurados em sua maioria para escrever arquivos
também codificados em UTF-8), o que explicaria o porquê de “¬” não ter sido
inserido em forma ASCII mas sim como uma sequência UTF-8, o que podemos ver
pelo fato de que começa com \302
.
Aproveitando que estamos na árvore de código-fonte, vamos ver o que determina
o que é um identificador válido ou não.
$ pwd
/run/media/luiz/Novo Volume/heirloom-sh-050706
$ ls -c *.c *.h
args.c echo.c io.c name.h test.c
blok.c error.c jobs.c print.c timeout.h
bltin.c expand.c mac.h pwd.c ulimit.c
brkincr.h fault.c macro.c service.c umask.c
cmd.c func.c main.c setbrk.c version.c
ctype.c getopt.c mapmalloc.c stak.c word.c
ctype.h gmatch.c mbtowi.h stak.h xec.c
defs.c hash.c mode.h string.c
defs.h hash.h msg.c strsig.c
dup.h hashserv.c name.c sym.h
$ find . -type f -name '*.c' -print | xargs grep 'is not an identifier'
./msg.c: const char notid[] = "is not an identifier";
Agora algo bem bacana: programas antigos em C e Assembly já utilizavam algo
que viria a ser parte do princípio D.R.Y. de hoje em dia. Mesmo já conhecendo
o que é o D.R.Y., eu desconhecia o fato dessa prática ser parte do princípio
e, pensando ter um nome à parte — até por ser uma prática comum em Java —,
fui consultar à comunidade He4rt Developers e recebi
uma resposta do André Luís (andreluispy
)
sobre e, bem, antes do conceito de D.R.Y. existir como algo unificado, essa
prática já era feita em código Assembly para diminuir a quantidade de coisa
que precisaria ser digitada e repetida ao criar uma constante com a string de
erro em uma época onde não havia muito espaço disponível no terminal para
representar muitos caracteres e, no caso de uma mensagem de erro utilizada em
várias ocasiões, onde os compiladores não eram tão espertos para cortar uma
possível redundância, ou seja, não custava muito estender isso até C.
Certo, voltando ao código, vejamos em qual arquivo o notid
é mencionado:
$ find . -type f -name '*.c' -print | xargs grep 'notid'
./msg.c: const char notid[] = "is not an identifier";
./name.c: failed(nam, notid);
Linhas 496 até 504, arquivo name.c
:
struct namnod *
lookup(register unsigned char *nam)
{
register struct namnod *nscan = namep;
register struct namnod **prev = NULL;
int LR;
if (!chkid(nam))
failed(nam, notid);
Como esperado, o código verifica se o identificador (char *nam
) é válido
antes de continuar operando em detalhes que eu não vou entrar aqui a fim de
manter esse artigo direto, vejamos o que essa função chkid()
de fato faz.
Linhas 530 até 546, arquivo name.c
:
BOOL
chkid(unsigned char *nam)
{
register unsigned char *cp = nam;
if (!letter(*cp))
return(FALSE);
else
{
while (*++cp)
{
if (!alphanum(*cp))
return(FALSE);
}
}
return(TRUE);
}
Cavando mais um pouco, vemos que letter()
, na realidade, é um macro definido
no arquivo
ctype.h
:
$ find . -type f -print | xargs egrep 'letter\(.*\)'
./ctype.h: #define letter(c) ((c<QUOTE) && sh_ctype2[c]&(T_IDC))
./name.c: if (letter(*argscan))
./name.c: if (!letter(*cp))
./hashserv.c: if (letter(*s))
./macro.c: if (letter(c))
./word.c: if (!letter(arg->argval[0]))
Esse macro é, de tão simples, complexo. Mas, fazendo uma aposta segura baseada
no que eu consegui entender, esse macro verifica se o caractere c
é menor do
que QUOTE
— esse que é definido em
mac.h
como 0200 — e, em seguida, se verifica se o valor de c
está presente no
array sh_ctype2
que é, efetivamente, uma tabela de caracteres usados
dentro do shell para nomes de funções, passando por caracteres reservados,
esses que são denotados por macros próprios ou por zeros, até caracteres
válidos para se usar como identificadores, esses que são denotados pelos
macros _LPC
(para letras minúsculas, “lowercase”) e _UPC
(para
letras maíusculas, “uppercase”), já números são denotados pelo macro
_DIG
(que é uma abreviação clara de “dígito”).
Linhas 87 até 134, arquivo ctype.c
:
const unsigned char sh_ctype2[] =
{
/* 000 001 002 003 004 005 006 007 */
0, 0, 0, 0, 0, 0, 0, 0,
/* bs ht nl vt np cr so si */
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
/* sp ! " # $ % & ' */
0, _PCS, 0, _NUM, _DOL2, 0, 0, 0,
/* ( ) * + , - . / */
0, 0, _AST, _PLS, 0, _MIN, 0, 0,
/* 0 1 2 3 4 5 6 7 */
_DIG, _DIG, _DIG, _DIG, _DIG, _DIG, _DIG, _DIG,
/* 8 9 : ; < = > ? */
_DIG, _DIG, 0, 0, 0, _EQ, 0, _QU,
/* @ A B C D E F G */
_AT, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC,
/* H I J K L M N O */
_UPC, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC,
/* P Q R S T U V W */
_UPC, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC, _UPC,
/* X Y Z [ \ ] ^ _ */
_UPC, _UPC, _UPC, 0, 0, 0, 0, _UPC,
/* ` a b c d e f g */
0, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC,
/* h i j k l m n o */
_LPC, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC,
/* p q r s t u v w */
_LPC, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC, _LPC,
/* x y z { | } ~ del */
_LPC, _LPC, _LPC, _CBR, 0, _CKT, 0, 0
};
Ha, touché, encore: a minha hipótese de que ele só faz uso de caracteres ASCII
“primitivos” acabou de ser comprovada por essa tabela — e também pela
sh_ctype1
, que veremos mais à frente — já que ela é a tabela ASCII de 1977
esculpida em mármore carrara.
Em suma, a função chkid()
executa primeiro o macro letter()
, que
verifica se o primeiro caractere é uma letra — algo que equivale à função
isalpha()
, mas, por mais que essa função tenha aparecido pela primeira vez
no UNIX v7, ela possivelmente não estava disponível quando escreveram essa
seção do código por uma questão de meses, semanas ou dias ou, até mesmo,
tinha uma carga muito grande para o que o shell poderia ter quando comparado
a um simples macro — e, posteriormente, “caminha” pela string usando um
laço while e, utilizando o alphanum()
, também macro — que equivale à
função isalnum()
— verifica se a função contém um dígito ou um número,
entretanto não aceita nada diferente disso.
A setname()
faz exatamente a mesma coisa, com o adendo de que, ao contrário
da lookup()
, como o nome já explicita, não busca pelo identificador em uma
tabela que relacione com a operação a ser feita, mas sim escreve — ou
“configura” (set) — o identificador e seu correspondente na tabela.
Linhas 179 até 191, arquivo name.c
:
void
setname ( /* does parameter assignments */
unsigned char *argi,
int xp
)
{
register unsigned char *argscan = argi;
register struct namnod *n;
if (letter(*argscan))
{
while (alphanum(*argscan))
argscan++;
Bem, só um adendo antes da conclusão: talvez você me pergunte, “Luiz, por que
não usaram a chkid()
ao invés de reescrever a mesma coisa, só que com lógica
diferente?”; a resposta é simples, basta apenas que leiamos o resto da função e
que entendamos que essa função não apenas verifica se um nome é válido e
adiciona-o na tabela, mas sim procura pelo caractere que significa nomear uma
variável, em outros termos o símbolo de igualdade (=
), procura um espaço
para o tal nome na tabela utilizando a função lookup()
, essa que falamos
sobre anteriormente e que já faz a verificação completa, e então atribui o valor
ao nome no espaço da tabela utilizando uma função chamada assign()
.
Ademais, seria interessante que descubramos o que é o tal _PCS
associado ao
!
, não custa nada, não é mesmo? Que procuremos nos arquivos de cabeçalho,
terminados em .h
:
$ find . -type f -name '*.h' -print | xargs grep '_PCS'
./ctype.h: #define _PCS (T_SHN)
Certo, é definido como T_SHN
, que tem o valor numérico de 040 na tabela 2,
como pode-se ver aqui:
Linhas 47 até 54, arquivo ctype.h
:
/* table 2 */
#define T_BRC 01
#define T_DEF 02
#define T_AST 04
#define T_DIG 010
#define T_SHN 040
#define T_IDC 0100
#define T_SET 0200
Esse T_SHN
não parece ter uma abreviação muito clara à primeira vista — e
nem à segunda —, no entanto, poderia-se dizer que significa que tal caractere
é restrito para uso em quaisquer identificadores.
Sobre a tabela 1, definida no array sh_ctype1
, também há uma lista dos
mesmos caracteres, no entanto, são associados com outros macros, esses com
outros nomes, mas mesmos valores:
Linhas 37 até 45, arquivo ctype.h
:
/* table 1 */
#define T_SUB 01
#define T_MET 02
#define T_SPC 04
#define T_DIP 010
#define T_EOF 020
#define T_EOR 040
#define T_QOT 0100
#define T_ESC 0200
Linhas 38 até 85, arquivo ctype.c
:
const unsigned char sh_ctype1[] =
{
/* 000 001 002 003 004 005 006 007 */
_EOF, 0, 0, 0, 0, 0, 0, 0,
/* bs ht nl vt np cr so si */
0, _TAB, _EOR, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
/* sp ! " # $ % & ' */
_SPC, 0, _DQU, 0, _DOL1, 0, _AMP, 0,
/* ( ) * + , - . / */
_BRA, _KET, 0, 0, 0, 0, 0, 0,
/* 0 1 2 3 4 5 6 7 */
0, 0, 0, 0, 0, 0, 0, 0,
/* 8 9 : ; < = > ? */
0, 0, 0, _SEM, _LT, 0, _GT, 0,
/* @ A B C D E F G */
0, 0, 0, 0, 0, 0, 0, 0,
/* H I J K L M N O */
0, 0, 0, 0, 0, 0, 0, 0,
/* P Q R S T U V W */
0, 0, 0, 0, 0, 0, 0, 0,
/* X Y Z [ \ ] ^ _ */
0, 0, 0, 0, _BSL, 0, _HAT, 0,
/* ` a b c d e f g */
_LQU, 0, 0, 0, 0, 0, 0, 0,
/* h i j k l m n o */
0, 0, 0, 0, 0, 0, 0, 0,
/* p q r s t u v w */
0, 0, 0, 0, 0, 0, 0, 0,
/* x y z { | } ~ del */
0, 0, 0, 0, _BAR, 0, 0, 0
};
Essa tabela é utilizada pelos macros eofmeta()
, qotchar()
, eolchar()
,
dipchar()
, subchar()
e eschar()
, todos definidos no nosso agora
famigerado ctype.h
:
$ find . -type f -name '*.h' -print | xargs grep 'sh_ctype1'
./ctype.h: extern const unsigned char sh_ctype1[];
./ctype.h: #define space(c) ((c<QUOTE) && sh_ctype1[c]&(T_SPC))
./ctype.h: #define eofmeta(c) ((c<QUOTE) && sh_ctype1[c]&(_META|T_EOF))
./ctype.h: #define qotchar(c) ((c<QUOTE) && sh_ctype1[c]&(T_QOT))
./ctype.h: #define eolchar(c) ((c<QUOTE) && sh_ctype1[c]&(T_EOR|T_EOF))
./ctype.h: #define dipchar(c) ((c<QUOTE) && sh_ctype1[c]&(T_DIP))
./ctype.h: #define subchar(c) ((c<QUOTE) && sh_ctype1[c]&(T_SUB|T_QOT))
./ctype.h: #define escchar(c) ((c<QUOTE) && sh_ctype1[c]&(T_ESC))
Quase todos esses macros, com exceção dos subchar()
e escchar()
, que
são usados pela função copyto()
, são usados pela função word()
.
Essas duas funções, word()
e copyto()
, são usadas no processamento
dos comandos passados ao shell em si. Logo, a tabela (ou array, chame como
preferir) sh_ctype1
é usada para delimitar caracteres que são usados na
sintaxe da linguagem em si, como colchetes, aspas simples e duplas, etc — já
que é usada por macros que compõem funções que fazem toda a parte de
processamento léxico da linguagem —, enquanto a sh_ctype2
é específica
para os identificadores que são usados em funções e variáveis — já que é
usada por macros que compõem a inserção de valores declarados na memória,
como vimos anteriormente na função setname()
, que chama uma série de
outras funções para lidar com toda a parte de identificadores.
Por isso — não pelo adendo, mas sim pela explicação de como as funções operam
para guardar uma variável/função na memória — um identificador como
37programa
falha enquanto programa37
, não.
No entanto, ainda continuei com uma pulga atrás da orelha: será que a pilha de
memória em que as variáveis e as funções são guardadas é a mesma? Pois, se é,
isso explicaria o porquê de “!”, “#”, “$” e outros caracteres não-permitidos
para uso vulgar estarem na tabela de caracteres para identificadores e com
constantes próprias — como vimos, _PCS
para o “!
”, _NUM
para
“#
” e _DOL2
para “$
”. Ao dar uma espiada na página de manual, os
nomes dessas constantes ganham significado:
The following parameters are automatically set by the shell.
# The number of positional parameters in decimal. - Options supplied to the shell on invocation or by set. ? The value returned by the last executed command in decimal. $ The process number of this shell. ! The process number of the last background command invoked.
Vale lembrar, antes de se continuar, que um “parâmetro” nesse manual
corresponde à mesma coisa que uma variável. No manual do Korn Shell 93, que
teve o mesmo lugar de origem desse Bourne shell, os Laboratórios Bell, o termo
“parâmetro” é utilizado também mas, na seção de “Expansão de Parâmetros”
(“Parameter Expansion”), o manual nos diz que um parâmetro é a mesma coisa
que uma variável. Eu confesso que não tenho ideia imediata de onde esse termo
surgiu, para ser franco.
Voltando ao manual, agora podemos ver que “_PCS
” na realidade corresponde
ao número identificador de processo (P.ID.) do último comando que fora executado
em plano de fundo (ou seja, “process number of the last
background command invoked</i>”) e “_NUM
” ao número de parâmetros passados a
uma função — ou ao próprio shell — (ou seja, “number of positional
parameters_”).
Já “_DOL2
” é apenas o símbolo de dólar, não fizeram nenhum acrônimo em
especial sobre o número identificador de processo do shell, logo presumi que
fosse utilizado em algo além disso, mas não consegui achar referência imediata
no código a essa constante; no entanto, procurei e encontrei um macro chamado
dolchar()
, que é usada em uma função chamada getch()
, essa que é parte
do mecanismo de análise sintática do próprio shell e que lida, justamente,
buscando por caracteres especiais; caso seja encontrado um caractere de dólar
— não por essa constante, mas sim por uma outra chamada DOLLAR
—, começa
a ler os caracteres subsequentes e, utilizando-se justamente da dolchar()
,
busca caracteres que sejam reconhecidos como válidos para compôr o
identificador de uma variável. Nesse processo, ele também busca por símbolos
reservados como números — $n
, que seria o equivalente de argv[n]
,
como pôde-se ver naquele pequeno exemplo que fiz antes de começar toda essa
jornada —, o asterisco — que representa todos os parâmetros posicionais,
como se fosse o argv
íntegro, mas sem respeito aos espaços das strings
que venham a estar ali –, colchetes — que indicam uma expansão de parâmetros
—, os cinco caracteres reservados que vimos acima (se lembra do T_SHN
?) e,
claro, letras quaisquer — que são, independente de _UPC
ou _LPC
, a
mesma constante (T_IDC
).
Linhas 174 até 226, arquivo macro.c
:
static int
getch (
int endch,
int trimflag /* flag to check if an argument is going to be trimmed, here document
output is never trimmed
*/
)
{
register unsigned int d;
int atflag = 0; /* flag to check if $@ has already been seen within double
quotes */
retry:
d = readwc();
if (!subchar(d))
return(d);
if (d == DOLLAR)
{
unsigned int c;
if ((c = readwc(), dolchar(c)))
{
struct namnod *n = (struct namnod *)NIL;
int dolg = 0;
BOOL bra;
BOOL nulflg;
register unsigned char *argp, *v = NULL;
unsigned char idb[2];
unsigned char *id = idb;
if (bra = (c == BRACE))
c = readwc();
if (letter(c))
{
argp = (unsigned char *)relstak();
while (alphanum(c))
{
if (staktop >= brkend)
growstak(staktop);
pushstak(c);
c = readwc();
}
if (staktop >= brkend)
growstak(staktop);
zerostak();
n = lookup(absstak(argp));
setstak(argp);
if (n->namflg & N_FUNCTN)
error(badsub);
v = n->namval;
id = (unsigned char *)n->namid;
peekc = c | MARK;
}
Essa função continua por mais de 100 linhas, é enorme e não é nem ao menos todo
o sistema de análise sintática e execução dos comandos passados ao shell, mas
ainda é admirável como foi bem-construído apesar de todas as limitações
de seu tempo; arrisco dizer, inclusive, que seria uma boa referência para quem
está disposto a escrever um analisador sintático sem precisar depender de
ferramentas como o yacc
(1) — ou que simplesmente não pode utilizá-las.
Certo, mas e sobre uma variável e uma função não poderem compartilhar o mesmo
identificador por ambos serem guardados na mesma pilha de memória?
Bem, por mais que possamos comprovar isso pelo código-fonte que acabamos de
analisar, é válido que vejamos se estamos corretos:
There is only one namespace for both functions and parameters. A function definition will delete a parameter with the same name and vice-versa.
Algo que achei curioso também, além do uso da terminologia “parâmetro”, foi se
utilizar “namespace” para a pilha de memória — o que é engraçado para quem
conhece Korn Shell 93 mais a fundo pois um “namespace” no contexto dele tem o
mesmo significado que em Java, C++ e Go.
E bem, vamos voltar ao código-fonte mais uma vez pois, mesmo que nós tenhamos
visto funções que trabalham com a estrutura namnod
, que contém o
identificador da variável/função e seu conteúdo em si, ainda não vimos como
é essa estrutura e quais dados ela contém. Então que procuremos sua definição.
Sabendo que é uma convenção geral da linguagem C que estruturas de dados sejam
declaradas em cabeçalhos, podemos restringir um bocado a nossa pesquisa:
$ find . -type f -name '*.h' -print | xargs grep 'struct namnod'
# [...]
./name.h: struct namnod
./name.h: struct namnod *namlft;
./name.h: struct namnod *namrgt;
Certo, consegui uma tela cheia de resultados (e que eu obviamente encurtei)
— principalmente do arquivo defs.h
, que contém a definição de funções e
algumas delas já conhecidas nossas, como a assign()
—, mas pude cortar
praticamente todos e deixar só o arquivo name.h
pois é o único que parece
ter uma declaração. Vejamos o que há:
Linhas 37 até 53, arquivo name.h
:
#define N_ENVCHG 0020
#define N_RDONLY 0010
#define N_EXPORT 0004
#define N_ENVNAM 0002
#define N_FUNCTN 0001
#define N_DEFAULT 0
struct namnod
{
struct namnod *namlft;
struct namnod *namrgt;
unsigned char *namid;
unsigned char *namval;
unsigned char *namenv;
int namflg;
};
Tem uma série de coisas interessantes para se notar: primeiro, tem uma série de
constantes declaradas para funções que utilizam essa estrutura consigam lidar
com o que venha a ser armazenado na memória: N_ENVNAM
para que uma variável
pertença ao ambiente e que, assim como a N_EXPORT
, passe para processos e
subshells, já a N_ENVCHG
indica que aquela variável em questão foi
alterada. Curiosamente a palavra-chave readonly
já existe desde essa época,
e é por causa dela que há a constante N_RDONLY
; todas essas constantes são
para variáveis, já, o que diferencia funções de variáveis na pilha de memória é
justamente a N_FUNCTN
. A execução da função é dada pela função execute()
no arquivo xec.c
, mas não irei entrar muito a fundo pois o intuito disso era
apenas saber como o shell consegue colocar tudo numa pilha de memória só — o
que, de certa forma, também é uma influência de C, que também não permite essa
traquinagem.
Também podemos ver que sim, string é o único tipo disponível, o que sim, eu já
sabia, você já sabia, todos nós sabíamos, a questão é que é interessante ver
explicitamente que é do tipo unsigned char
e que usa o identificador
namval
— o que podemos comprovar olhando para a função dfault()
, dessa
vez no arquivo name.c
, e vendo que o valor da variável, nela indicada como
‘v
’, é associado (por meio da função assign()
) à namval
da estrutura
informada, que tem o identificador de n
no protótipo da função:
Linhas 222 até 227, arquivo name.c
:
void
dfault(struct namnod *n, const unsigned char *v)
{
if (n->namval == 0)
assign(n, v);
}
Estou consciente de que minha análise não é tão profunda quanto a que fiz em cima do GNU Make pois, de fato, não depurei o código do Bourne shell utilizando o GDB. Entretanto, caso alguém queira me corrigir com base em uma análise mais profunda, está livre para tal, visto que o código dele já foi portabilizado para UNIX-compatíveis modernos e existem n-maneiras de obtê-lo.
Bem, com tudo isso em mente, podemos voltar ao código do começo: