sqleibnizé uma ferramenta de análise estática que busca verificar a sintaxe do SQL no dialeto SQLite, a existência de tabelas, colunas e funções, e condições em tempo de execução; para isso, tokenização e parsing são etapas centrais- O
macro_rules!do Rust ajuda a criar estruturas de nós de AST, implementações do traitNodee testes baseados em tabela no estilo Go sem repetição - Usando padrões com
matches!ematch, é possível transportar para o código, de forma próxima, ramificações gramaticais como literais numéricos do SQLite, identificadores, símbolos eEXPLAIN QUERY PLAN is_some_and,mapemap_ordeOption, junto com o operador?, tornam concisos o tratamento da presença de valores, transformações, valores padrão e propagação de erros ao processar entradas e fluxos de tokens- Iteradores de Rust são usados para remover
_de literais numéricos, validar caracteres hexadecimais dentro de blobs e calcular posições de erro, o que torna o código de tokenização e parsing mais legível
Fluxo de análise do sqleibniz
sqleibnizé uma ferramenta de análise SQL em desenvolvimento voltada ao dialeto SQLite- Para uma entrada SQL, ela pretende realizar verificação de sintaxe, confirmar a existência de tabelas, colunas e funções, e validar condições em conjunto com o runtime SQLite embutido
- As mensagens de erro têm como objetivo oferecer contexto e explicações, além de permitir ignorar diagnósticos específicos
- O fluxo de análise começa com análise léxica/tokenização, segue para o parsing de SQL conforme a documentação do SQLite e depois para a análise da estrutura resultante
- Depois de concluir a parte de análise estática, também está planejada a criação de um servidor LSP para SQL
Removendo repetição em nós de AST com macros
- Os nós de AST do
sqleibnizsão estruturas que contêm umToken, e todos os nós precisam implementar o traitNode - O trait
Nodeusastd::fmt::Debugcomo supertrait, de modo que apenas tipos que satisfazemDebugpodem implementarNode - Para evitar repetir, em cada nó, a definição da estrutura e a implementação de
fn token(&self) -> &Token, a macronode!gera esses elementos- O nome do nó é recebido pela metavariável
ident - A string de documentação é recebida pela metavariável
literal - Campos adicionais são tratados de forma repetida no formato
$($field_name:ident:$field_type:ty),*
- O nome do nó é recebido pela metavariável
- O nó
Literaltem apenas o campo de token, enquanto o nóExplaintem também o campochild: Option<Box<dyn Node>> - Comentários de documentação são passados ao compilador como argumento da macro na forma
#[doc = $documentation], em vez de///
Implementando testes baseados em tabela no estilo Go com macros Rust
- O padrão de testes baseados em tabela do Go, em que se percorre um array de casos de entrada e cada caso é executado como um teste independente, é reproduzido em Rust com macros
- Os testes do lexer usam as macros
test_group_pass_assert!etest_group_fail!- Os testes que passam inserem a entrada no
Lexere comparam a lista de tipos de token do resultado deLexer.run()com o valor esperado - Os testes que falham verificam se o vetor de tokens resultante está vazio e se há um ou mais erros em
Lexer.errors - Ao executar
cargo test, cada caso produz feedbackokoufailcomo se fosse uma função de teste separada
- Os testes que passam inserem a entrada no
- Os testes do parser seguem a mesma estrutura, mas inicializam o
Parserdepois de executar o lexer e verificam o resultado deparse()EXPLAIN VACUUM;eEXPLAIN QUERY PLAN VACUUM;são casos de sucessoEXPLAIN;eEXPLAIN QUERY PLAN;são casos de falha- Os casos de falha verificam a condição de que, pela gramática
sql-stmtdo SQLite, é necessária uma instrução depois deEXPLAIN
Pontos incômodos do macro_rules!
- Dentro de
macro_rules!, o suporte dorust-analyzeré limitado- Não há IntelliSense real
- Não há navegação para definição
- Não há hover para assinaturas de literais e estruturas da linguagem
- O
cargo fmtnão formata nem indenta o interior demacro_rules!nem os locais de chamada de macros treesitterechromaàs vezes têm dificuldade com realce de sintaxe demacro_rules!- A documentação sobre macros também é relativamente insuficiente
matches! e match se destacam na correspondência de caracteres
- A comparação de caracteres do lexer é a base de outros processamentos, e a macro
matches!e os padrões commatchdo Rust tornam essa parte concisa - A identificação de números do SQLite é escrita com
matches!+,-_.a..=f,A..=F0..=9
- A identificação de identificadores também é expressa como
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') - O loop principal do lexer divide o caractere atual com
match- Caracteres de espaço em branco são ignorados
*,;,,,%etc. geram seus tokens correspondentes- O exemplo de tratamento de símbolos desconhecidos é omitido e marcado com
panic!("whoops")
Tratando estruturas gramaticais SQL por correspondência de tokens
- O lexer transforma o fluxo de caracteres em um fluxo de estruturas
Tokencom informações de posição e tipo, e o parser consome isso para criar a AST - O enum
TypeincluiKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eofetc. sql_stmt_prefixé uma função do parser que trata a instruçãoEXPLAINda documentação do SQLite- Se o token atual for
Type::Keyword(Keyword::EXPLAIN), ela cria um nóExplaine consomeEXPLAIN - Se o próximo token for
QUERY, consomeQUERYePLANem sequência - Depois disso, faz o parsing da instrução SQL real como
child - Se não for
EXPLAIN, chama o tratamento normal desql_stmt
- Se o token atual for
literal_valuecria nósLiteralpara strings, números, blobs e booleanos, bem como para literais de palavra-chave comoNULL,CURRENT_TIME,CURRENT_DATEeCURRENT_TIMESTAMP
Exibição de erros e uso de Option
- O lexer e o parser mostram erros ao usuário em casos como ausência de ponto e vírgula no fim de uma instrução SQL
- O operador
?do Rust é usado para tratamento e propagação de erros Option::is_some_andé usado para verificar se o próximo caractere ou o caractere atual existe e satisfaz uma condiçãoself.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapé usado para converter o próximo byte de uma entradaVec<u8>emcharOption::map_oré usado para comparar o tipo apenas quando há um token atual ou próximo token; caso contrário, retornafalse
Tratando números e blobs com iteradores
- O parsing de números do SQLite permite
_, mas o parsing de números em Rust não permite_; portanto, o lexer consome o valor incluindo_e remove esse caractere antes do parsing - Esse tratamento é escrito como uma cadeia de iteradores
- Obtém uma fatia de bytes
- Converte cada byte em
char - Filtra apenas os caracteres que não são
_ - Coleta tudo em uma
String
- Nesse caso, usa-se
unwrap_or_default(), mas uma string vazia não é um número válido, então o parser falhará de qualquer forma - Em Go, para fazer o mesmo processamento, seria necessário percorrer a lista de caracteres, escrever bytes em um
strings.Buildere então criar a string novamente - Blobs SQLite permitem dados hexadecimais no formato
x'<hex>', então cada caractere da string é percorrido comchars().enumerate()para verificar se éis_ascii_hexdigit()enumerateé usado para obter a posição do caractere inválido para exibição do erro- Ao encontrar um caractere hexadecimal inválido, um erro é criado e o processamento é interrompido
1 comentários
Comentários do Hacker News
Até dois meses atrás eu provavelmente pensaria como o autor, mas continuei esbarrando na fronteira rígida do Rust, o borrow checker.
Tipos de dados algébricos, como Enum e pattern matching, eram realmente ótimos, mas por causa do borrow checker e das considerações de memória de baixo nível, acabei passando mais tempo brigando com o borrow checker do que com o problema de linguagem de programação que era o núcleo do projeto.
Então tokenização e parsing foram OK, mas o interpretador e a verificação de tipos viraram um sofrimento; procurando uma linguagem mais adequada, avaliei F#, Zig/C e Go até descobrir OCaml.
A sintaxe parecia um Haskell amigável e algo como Rust sem lifetimes, o que me convenceu; além disso, o primeiro compilador de Rust foi escrito em OCaml, e ela é bem conhecida na área de linguagens de programação.
Ainda estou aprendendo, então é difícil fazer uma avaliação justa, mas até agora parece muito próximo do que eu estava procurando.
Ele é prático e rápido, não é realmente de baixo nível, compila rápido e, acima de tudo, é popular, então tem todas as bibliotecas, o que faz parecer que eu deveria usá-lo.
Mas eu detesto a linguagem em si de um jeito quase irracional, e sinto que tudo nela é feio.
É uma linguagem criada em 2009 por pessoas do lado do C e, mesmo para os padrões da época, parece ignorar as coisas interessantes que aconteceram no design de linguagens de programação nos 20 anos anteriores.
Mesmo o PHP de 2009 era uma linguagem mais moderna e mais bem projetada que Go, e não consigo me livrar da sensação de que Go não melhorou muito desde então.
Em vez disso, é melhor usar algo como uma biblioteca de strings estáticas que permita clone barato e interning, e usar apenas índices para posições no texto.
Se possível, deve-se evitar a todo custo armazenar referências.
Quanto mais você guardar coisas que tenham clone barato/gratuito, menos vai brigar com o borrow checker e, se necessário, pode simplesmente recorrer a clone.
Na parte do interpretador em si, bibliotecas que ajudam no gerenciamento de memória, como arenas, são bastante úteis.
É um domínio muito especializado, mas oferece desempenho e usabilidade ao mesmo tempo, e projetos como o Ruffle usam bastante esse padrão.
Dito isso, OCaml e Haskell fazem essas coisas “de graça” graças à contagem de referências e à coleta de lixo embutidas, mas ainda gosto da ideia de ir muito rápido com Rust.
Go está mais próximo de um C modernizado, e o modelo que ele oferece é muito simples.
Vindo de C#, essa simplicidade na verdade tornou o aprendizado mais difícil para mim; a baixa carga conceitual é uma vantagem, e ele se encaixa bem em aplicações pequenas e focadas, em que se aceita o trade-off da verbosidade.
Se fosse recomendar, eu diria F#, ou até C# moderno.
A Microsoft está envolvida, sim, mas fica difícil viver em um mundo em que você não usa absolutamente nada feito por uma megacorporação maligna.
Java, Go, Python, TypeScript/JavaScript e Swift também caem nessa categoria; aí quase não sobra opção.
Tenho curiosidade sobre o que você vai achar depois de usar OCaml por mais ou menos um ano.
Linguagens do tipo Haskell são interessantes, mas o Haskell em si não me trouxe uma boa relação entre curva de aprendizado e benefício, e Rust é parecido.
Em C#, eu me aprofundei bastante no sistema de tipos e fiquei proficiente, mas não tenho tempo para mergulhar nesse nível em Rust.
Claro, isso adiciona algo como 10–20 MB ao binário e ao uso de memória, mas pelos padrões de hoje isso é quase nada.
Por exemplo, o Tailscale parece usar Go como camada WireGuard multiplataforma em seus apps mobile e desktop, e parece funcionar bem.
Eu não criaria uma UI nativa em Go, mas para tarefas de baixo nível ele é excelente.
O TinyGo também permite escrever Go para microcontroladores ou WebAssembly; há muita coisa sem suporte, mas boa parte da biblioteca padrão pode ser usada.
Por exemplo, o compilador de Go também é escrito em Go.
Graças à compilação cruzada e aos binários relativamente pequenos, a distribuição é muito fácil.
Dito isso, é verdade que falta açúcar sintático, e ele não se encaixa bem em pattern matching no estilo funcional.
Como abordagem para parsing, parece meio estranha, e fico com a impressão de que o autor não está muito familiarizado com Rust e com os conceitos de linguagens de programação que o fundamentam.
Para citar alguns pontos: a AST provavelmente ficaria muito mais simples se fosse definida como tipos de dados algébricos.
Não parece provável que a gramática do sqlite se expanda de repente criando um monte de novos nós a ponto de exigir uma codificação complexa.
A codificação atual parece algo que uma pessoa acostumada a orientação a objetos, mas não a tipos de dados algébricos, imaginaria.
A afirmação de que “macros funcionam de forma diferente na maioria das linguagens, mas o principal motivo é eliminar duplicação de código e reduzir repetição” também poderia ser feita sobre qualquer mecanismo de abstração, como funções.
A característica definidora de macros é que elas são executadas em tempo de compilação.
Para ver maneiras de estruturar um parser de forma limpa, a pesquisa sobre combinadores de parser pode ser um bom ponto de partida.
O título do blog também é “Why I love ...”, e embora as críticas pareçam válidas, não parece necessário apontar falta de experiência.
É bom que alguém goste de programar, e a experiência virá com o tempo.
Isso não dá para fazer com funções.
Como alguém que já escreveu um pequeno parser [0] para a notação de xadrez Forsyth-Edwards, acho que Haskell é imbatível em termos de simplicidade e legibilidade
Ele é lido quase como BNF e tem pouquíssima cerimônia técnica, então dá para focar na gramática que você está realmente tentando fazer o parsing
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
Fico curioso se há algum motivo claro para uma abordagem parecida não funcionar em Rust
Por exemplo, winnow [1] parece oferecer um estilo bastante declarativo, e há várias outras bibliotecas de parser combinators em Rust
[1]: https://docs.rs/winnow/latest/winnow/
Porque dá para implementá-lo como uma função simples com um único loop
Alguns dias atrás escrevi um “parser” de FEN para uma implementação experimental de quad-bitboard, e ele praticamente se escreveu sozinho
A propósito, sou o autor de chessIO no Hackage
Escrevi um disassembler de eBPF e um emulador meio inacabado em Rust, e Rust foi uma linguagem bem agradável para trabalhos do tipo parsing
Dito isso, quando o autor precisa de macros antes mesmo de passar por 1/6 do estudo de caso, isso parece enfraquecer o argumento dele
Macros não são geração completa de código, mas também não dão uma sensação muito forte de estar trabalhando idiomaticamente dentro da linguagem
Não estou tentando criticar; acho que Rust é, de fato, bem forte nessa área
Por exemplo, a regra livre de contexto
S ::= abc|aabbcc|aaabbbccc|...consegue fazer parsing efetivo dea^Nb^Nc^N, e isso é um exemplo de gramática sensível ao contextoÉ um exemplo simples, mas coisas parecidas aparecem na prática; um caso é quando a linguagem permite definições de operadores
Como Rust lida com isso?
Parece interessante
Pela minha experiência escrevendo parsers e lexers com Ragel e usando Go, Java, C++ e C, se você tiver pelo menos um gerador de boilerplate, C puro fica tão bom quanto o código Rust descrito pelo autor
Talvez até melhor, pela simplicidade
Por exemplo, o código necessário para um parser de JSON é basicamente isto
https://github.com/gritzko/librdx/blob/master/JSON.lex
Na verdade, esse eBNF só cria o lexer; a parte do parser também não é tão impressionante, tem 120 linhas e é bem repetitiva
https://github.com/gritzko/librdx/blob/master/JSON.c
No fim, acho que a infraestrutura de parsers evolui até o ponto em que é possível criar um parser só com eBNF, e esse é o ponto de saturação
Sinto que os tipos algébricos de dados de Rust tornam muito mais fácil lidar com a árvore sintática gerada
Ainda assim, concordo que um pouco de geração de código ou magia de macros pode tornar C bastante manejável
Mas este código aqui
https://github.com/gritzko/librdx/blob/master/JSON.lex
não aceita
[como JSON válido?delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;Parece que dá para escolher apenas um delimiter em JSON e todo o resto como zero ocorrências
Normalmente começo olhando pela RFC
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
Nem tenho certeza se é possível implementar JSON com Ragel
Pelo que sei, Ragel só consegue lidar com linguagens regulares, e JSON é uma linguagem livre de contexto
https://en.wikipedia.org/wiki/Cloudbleed
Sobre isso, gosto da palestra de Rob Pike sobre varredura léxica em Go
É uma abordagem didática e elegante
https://www.youtube.com/watch?v=HxaD_trXwRE
Acho que o motivo era o overhead do escalonamento de goroutines ou padrões ineficientes de alocação de memória
A melhor discussão que encontrei é [1]
Outra excelente palestra sobre como criar lexers e parsers eficientes é “Practical Data Oriented Design”, de Andrew Kelley [2]
Em resumo, ela explica várias estratégias para aumentar o throughput reduzindo o uso de memória do programa e tornando-o mais amigável ao cache
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
Tive uma experiência surpreendente
Consegui usar, sem mudanças, uma biblioteca de combinadores de parser que eu usava em parsers de compiladores de alto nível em um ambiente no-std, compilá-la para microcontroladores e implantá-la como um parser de protocolo de alta performance em ambiente embarcado
Usei exatamente a mesma biblioteca
A diferença foi basicamente reduzir o uso de String e usar mais
&'static strEntão brincar com compiladores acaba se transferindo muito bem para a capacidade de criar parsers de protocolos embarcados
A dificuldade que tive ao escrever um parser de AST completo em Rust foi representar uma hierarquia de tipos concretos de AST, incluindo upcasting e downcasting
Encontrei uma forma de fazer isso, mas precisei de brincadeiras estranhas com tipos, como
PhantomData, e macrosAcho que aqui também foram necessárias macros bem exageradas
Tenho curiosidade sobre como são os trabalhos anteriores relacionados
Pelo menos até chegar a upcasting/downcasting
Não tenho experiência suficiente com Rust para saber se há uma boa forma de lidar com isso
Talvez traits dinâmicas funcionem
Como se depura esse tipo de código de macro, ou como alguém recém-chegado à codebase entende isso?
Mesmo olhando os usos da macro
node!e a definição da macro, parece difícil entender que código é realmente geradoFico curioso se basta rodar exemplos e ver que dicas de tipo aparecem, se o IDE mostra a versão expandida ao passar o mouse, ou se, para ter certeza, é preciso consultar o código compilado
Como só trabalho com JS/TS e não mexo com macros, tenho curiosidade sobre esse fluxo de trabalho
$ cargo expand, você consegue ver o código resultanteRust, na verdade, é quase várias linguagens: Rust “vanilla”, macros declarativas e macros procedurais, cada uma com capacidades e dialetos um pouco diferentes
Com o tempo, você se acostuma a lidar com cada uma delas
Testes unitários também são um bom espaço de experimentação para entender o impacto de mudanças em macros
Não é tão ruim, mas, quanto menos macros procedurais houver em uma codebase, melhor
Macros declarativas são um pouco mais fáceis de entender, e muito mais fáceis de manter e testar
Sinto algo parecido em relação à geração de código opaca em outras linguagens
Boa sorte ao fazer parsing da sintaxe do sqlite
Alguns anos atrás, tive que escrever no trabalho um parser para um subconjunto bem pequeno do sqlite
Gosto muito do sqlite e ele sempre é uma fonte de inspiração
Diagramas ferroviários são extremamente úteis
https://www.sqlite.org/syntaxdiagrams.html
Acho que o gerador de parser lemon não recebe o devido reconhecimento
https://sqlite.org/src/doc/trunk/doc/lemon.html
Em termos de escolha de linguagem, qualquer linguagem com tipos de dados algébricos se encaixa bem
Até TypeScript pode ser excelente para esse uso
Há algum tempo também escrevi um pequeno texto introdutório sobre criar um parser à mão em Rust
https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...