sqleibnizest un outil d’analyse statique destiné à vérifier la syntaxe du SQL dans le dialecte SQLite, l’existence de tables, colonnes et fonctions, ainsi que des conditions d’exécution ; la tokenisation et le parsing en sont donc des étapes centrales- Le
macro_rules!de Rust aide à créer sans répétition les structs de nœuds d’AST, les implémentations du traitNodeet des tests pilotés par table à la manière de Go - Avec
matches!et les motifsmatch, on peut transposer au plus près du code des branches grammaticales comme les littéraux numériques SQLite, les identifiants, les symboles ouEXPLAIN QUERY PLAN - Les méthodes
is_some_and,map,map_ord’Optionet l’opérateur?rendent concis le traitement des flux d’entrée et de tokens : présence d’une valeur, transformation, valeur par défaut et propagation des erreurs - Les itérateurs Rust servent à supprimer les
_dans les littéraux numériques, à valider les caractères hexadécimaux à l’intérieur des blobs et à calculer la position des erreurs, ce qui rend le code de tokenisation et de parsing plus lisible
Le flux d’analyse de sqleibniz
sqleibnizest un outil d’analyse SQL en cours d’écriture pour le dialecte SQLite- Pour une entrée SQL, il vise à effectuer une vérification syntaxique, à vérifier l’existence de tables, colonnes et fonctions, ainsi qu’à valider des conditions en combinaison avec le runtime SQLite intégré
- Les messages d’erreur ont pour objectif de fournir du contexte et une explication, et de permettre d’ignorer certains diagnostics
- Le flux d’analyse commence par l’analyse lexicale/la tokenisation, se poursuit avec le parsing SQL selon la documentation SQLite, puis avec l’analyse de la structure résultante
- Une fois la partie analyse statique terminée, l’écriture d’un serveur LSP pour SQL est également prévue
Supprimer les répétitions des nœuds d’AST avec des macros
- Les nœuds d’AST de
sqleibnizsont des structs contenant unToken, et tous les nœuds doivent implémenter le traitNode - Le trait
Nodeutilisestd::fmt::Debugcomme supertrait, de sorte que seuls les types satisfaisantDebugpeuvent implémenterNode - Pour éviter de répéter, pour chaque nœud, la définition de la struct et l’implémentation de
fn token(&self) -> &Token, la macronode!les génère- Le nom du nœud est reçu sous forme de métavariable
ident - La chaîne de documentation est reçue sous forme de métavariable
literal - Les champs supplémentaires sont traités de manière répétée sous la forme
$($field_name:ident:$field_type:ty),*
- Le nom du nœud est reçu sous forme de métavariable
- Le nœud
Literalne possède que le champ de token, tandis que le nœudExplainpossède en plus un champchild: Option<Box<dyn Node>> - Les commentaires de documentation sont transmis au compilateur via l’argument de macro sous la forme
#[doc = $documentation], plutôt qu’avec///
Implémenter en Rust, avec des macros, des tests pilotés par table à la manière de Go
- Comme les tests pilotés par table de Go, une macro Rust reproduit une approche où l’on parcourt un tableau de cas d’entrée et où chaque cas s’exécute comme un test indépendant
- Les tests du lexer utilisent les macros
test_group_pass_assert!ettest_group_fail!- Les tests qui réussissent passent l’entrée au
Lexeret comparent la liste des types de tokens du résultat deLexer.run()à la valeur attendue - Les tests qui échouent vérifient que le vecteur de tokens résultat est vide et que
Lexer.errorscontient au moins une erreur - Lors de l’exécution de
cargo test, chaque cas produit un retourokoufailcomme s’il s’agissait d’une fonction de test séparée
- Les tests qui réussissent passent l’entrée au
- Les tests du parser suivent la même structure, mais initialisent un
Parseraprès l’exécution du lexer et vérifient le résultat deparse()EXPLAIN VACUUM;etEXPLAIN QUERY PLAN VACUUM;sont des cas de réussiteEXPLAIN;etEXPLAIN QUERY PLAN;sont des cas d’échec- Les cas d’échec vérifient la condition de la grammaire SQLite
sql-stmtselon laquelle une instruction est nécessaire aprèsEXPLAIN
Les points pénibles de macro_rules!
- À l’intérieur de
macro_rules!, la prise en charge parrust-analyzerest limitée- Pas de véritable IntelliSense
- Pas de navigation vers la définition
- Pas de hover sur les littéraux ni sur les signatures des structures du langage
cargo fmtne formate ni n’indente l’intérieur demacro_rules!ni les sites d’appel de macrostreesitteretchromarencontrent parfois des difficultés avec la coloration syntaxique demacro_rules!- La documentation sur les macros est également assez limitée
matches! et match brillent dans la correspondance de caractères
- La comparaison de caractères du lexer sert de base aux autres traitements, et la macro
matches!ainsi que les motifsmatchde Rust rendent cette partie concise - La détection des nombres SQLite est écrite avec
matches!+,-_.a..=f,A..=F0..=9
- La détection des identifiants s’exprime également ainsi :
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') - La boucle principale du lexer répartit le caractère courant avec
match- Les espaces sont ignorés
*,;,,,%, etc. génèrent chacun le token correspondant- L’exemple de gestion d’un symbole inconnu est omis et indiqué par
panic!("whoops")
Traiter la structure grammaticale SQL par correspondance de tokens
- Le lexer transforme le flux de caractères en un flux de structs
Tokenportant des informations de position et de type, puis le parser les consomme pour créer l’AST - L’enum
Typeinclut notammentKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eof sql_stmt_prefixest la fonction du parser qui traite l’instructionEXPLAINde la documentation SQLite- Si le token courant est
Type::Keyword(Keyword::EXPLAIN), elle crée un nœudExplainet consommeEXPLAIN - Si le token suivant est
QUERY, elle consomme successivementQUERYetPLAN - Elle parse ensuite l’instruction SQL réelle comme
child - Si ce n’est pas
EXPLAIN, elle appelle le traitement général desql_stmt
- Si le token courant est
literal_valuecrée un nœudLiteralpour les chaînes, nombres, blobs et booléens, ainsi que pour les littéraux mots-clés commeNULL,CURRENT_TIME,CURRENT_DATEetCURRENT_TIMESTAMP
Affichage des erreurs et utilisation d’Option
- Le lexer et le parser affichent à l’utilisateur les erreurs telles que l’absence de point-virgule en fin d’instruction SQL
- L’opérateur
?de Rust est utilisé pour la gestion et la propagation des erreurs Option::is_some_andsert à vérifier que le caractère suivant ou courant existe et satisfait une conditionself.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapest utilisé pour convertir encharl’octet suivant depuis une entréeVec<u8>Option::map_orest utilisé de façon à ne comparer le type que s’il existe un token courant ou suivant, et à renvoyerfalsesinon
Traiter nombres et blobs avec des itérateurs
- Le parsing des nombres SQLite autorise
_, mais le parsing des nombres en Rust ne l’autorise pas ; le lexer consomme donc les_, puis les supprime avant le parsing - Ce traitement est écrit sous forme de chaîne d’itérateurs
- Récupérer une tranche d’octets
- Convertir chaque octet en
char - Filtrer uniquement les caractères qui ne sont pas
_ - Collecter le résultat dans une
String
- Dans cette situation,
unwrap_or_default()est utilisé, mais une chaîne vide n’étant pas un nombre valide, le parser échoue de toute façon - En Go, il faudrait parcourir la liste de caractères, écrire les octets dans un
strings.Builder, puis recréer une chaîne pour obtenir le même traitement - Les blobs SQLite autorisent des données hexadécimales de la forme
x'<hex>'; chaque caractère de la chaîne est donc parcouru avecchars().enumerate()pour vérifieris_ascii_hexdigit()enumeratesert à obtenir la position du caractère invalide pour l’affichage de l’erreur- Lorsqu’un caractère hexadécimal invalide est rencontré, une erreur est créée puis le traitement s’arrête
1 commentaires
Commentaires Hacker News
Il y a encore deux mois, j’aurais pensé comme l’auteur, mais je me suis sans cesse heurté à la frontière rigide de Rust qu’est le borrow checker.
Les types de données algébriques, par exemple les Enum et le pattern matching, étaient vraiment agréables, mais à cause du borrow checker et des considérations de mémoire bas niveau, j’ai passé plus de temps à me battre contre le borrow checker qu’à travailler sur le vrai sujet au cœur du projet : les langages de programmation.
Du coup, la tokenisation et le parsing se passaient bien, mais l’interpréteur et la vérification de types sont devenus pénibles ; en cherchant un langage plus adapté, après avoir envisagé F#, Zig/C et Go, j’ai découvert OCaml.
Sa syntaxe ressemble à un Haskell plus accueillant, et il donne l’impression d’un Rust sans durées de vie, ce qui m’a convaincu ; le premier compilateur Rust a d’ailleurs été écrit en OCaml, et le langage est bien connu dans le domaine des langages de programmation.
Je suis encore en train de l’apprendre, donc il est difficile d’en faire une évaluation équitable, mais pour l’instant c’est très proche de ce que je cherchais.
Il est pragmatique et rapide, pas vraiment bas niveau, compile vite et, surtout, il est très populaire, donc il dispose de toutes les bibliothèques nécessaires, ce qui donne l’impression qu’il faudrait l’utiliser.
Mais je déteste presque irrationnellement le langage lui-même, et j’ai l’impression que tout y est laid.
C’est un langage créé en 2009 par des gens venant du C, et même selon les standards de l’époque, on dirait qu’ils ignoraient les choses intéressantes apparues dans la conception des langages de programmation au cours des vingt années précédentes.
Même le PHP de 2009 était un langage plus moderne et mieux conçu que Go, et je n’arrive pas à me défaire de l’impression que Go ne s’est pas beaucoup amélioré depuis.
Il vaut mieux utiliser quelque chose qui permette un clonage peu coûteux et de l’interning, comme une bibliothèque de chaînes statiques, et n’utiliser que des indices pour les positions dans le texte.
Si possible, il faut absolument éviter de stocker des références.
Plus on conserve d’éléments pouvant être clonés à bas coût/gratuitement, moins on se bat avec le borrow checker, et on peut passer par un clone si nécessaire.
Du côté de l’interpréteur proprement dit, des bibliothèques qui aident à gérer la mémoire avec des approches de type arena sont assez utiles.
C’est un domaine très spécialisé, mais cela apporte à la fois performance et ergonomie, et des projets comme Ruffle utilisent beaucoup ce genre de patterns.
Cela dit, OCaml et Haskell font cela « gratuitement » grâce au comptage de références et au ramasse-miettes intégrés ; malgré tout, l’idée d’aller très vite avec Rust me plaît.
Go ressemble plutôt à un C modernisé, et le modèle qu’il propose est très simple.
En venant de C#, cette simplicité a paradoxalement rendu l’apprentissage plus difficile ; son faible poids conceptuel est un avantage, et il convient bien à de petites applications ciblées où l’on accepte le compromis de la verbosité.
Si je devais recommander quelque chose, je dirais F#, ou même du C# moderne.
Microsoft est bien impliqué, mais vivre dans un monde où l’on refuse absolument d’utiliser quoi que ce soit provenant d’une grande entreprise maléfique devient compliqué.
Java, Go, Python, TypeScript/JavaScript et Swift tombent aussi dans cette catégorie, et il ne reste alors presque plus de choix.
Je serais curieux de connaître ton avis après environ un an d’utilisation d’OCaml.
Les langages de la famille Haskell sont intéressants, mais Haskell lui-même ne m’a pas offert un bon rapport bénéfice/courbe d’apprentissage, et Rust me fait un peu le même effet.
J’ai approfondi et maîtrisé le système de types de C#, mais je n’ai pas le temps de creuser Rust aussi profondément.
Bien sûr, cela ajoute quelque chose comme 10 à 20 Mo au binaire et à l’utilisation mémoire, mais selon les standards actuels, ce n’est presque rien.
Par exemple, Tailscale semble utiliser Go comme couche WireGuard multiplateforme dans ses applications mobiles et desktop, et cela a l’air de bien fonctionner.
Je ne ferais pas d’UI native en Go, mais il est excellent pour les tâches bas niveau.
TinyGo permet aussi d’écrire du Go pour des microcontrôleurs ou WebAssembly ; beaucoup de choses ne sont pas prises en charge, mais une part importante de la bibliothèque standard est utilisable.
Par exemple, le compilateur Go lui-même est écrit en Go.
Grâce à la compilation croisée et à des binaires relativement petits, le déploiement est très simple.
En revanche, il est vrai qu’il manque de sucre syntaxique, et il se prête mal au pattern matching de style fonctionnel.
La manière d’aborder le parsing me paraît un peu étrange, et j’ai l’impression que l’auteur n’est pas très familier avec Rust ni avec les concepts de langages de programmation qui le sous-tendent.
Pour prendre quelques points, l’AST serait probablement beaucoup plus simple s’il était défini comme un type de données algébrique.
La grammaire de sqlite ne semble pas susceptible de s’étendre soudainement en ajoutant quantité de nouveaux nœuds au point de nécessiter un encodage complexe.
L’encodage actuel ressemble à ce que proposerait quelqu’un à l’aise avec l’orienté objet, mais pas avec les types de données algébriques.
Dire que « les macros se comportent différemment dans la plupart des langages, mais leur raison principale est d’éliminer la duplication de code et de réduire la répétition » pourrait aussi s’appliquer à n’importe quel mécanisme d’abstraction, comme les fonctions.
La caractéristique qui définit les macros est qu’elles s’exécutent au moment de la compilation.
Si l’on veut voir comment structurer proprement un parseur, les travaux sur les combinateurs de parseurs peuvent constituer un bon point de départ.
Le titre du blog est aussi « Why I love ... », et même si les critiques semblent pertinentes, souligner son manque d’expérience ne me paraît pas vraiment nécessaire.
C’est une bonne chose que quelqu’un aime programmer, et l’expérience viendra avec le temps.
Ce n’est pas quelque chose qu’on peut faire avec une fonction.
Pour avoir écrit un petit parseur [0] pour la notation d’échecs Forsyth-Edwards, je trouve que Haskell l’emporte largement en matière de simplicité et de lisibilité
Ça se lit presque comme du BNF, avec très peu de cérémonial technique, ce qui permet de se concentrer sur la grammaire qu’on cherche réellement à parser
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
Je me demande s’il y a une raison évidente pour laquelle une approche similaire ne fonctionnerait pas en Rust
Par exemple, winnow [1] semble offrir un style suffisamment déclaratif, et il existe aussi plusieurs autres bibliothèques de combinateurs de parseurs en Rust
[1]: https://docs.rs/winnow/latest/winnow/
Parce qu’on peut l’implémenter avec une fonction simple comportant une seule boucle
Il y a quelques jours, j’ai écrit un « parseur » FEN pour une implémentation expérimentale en quad-bitboard, et il s’est quasiment écrit tout seul
Au passage, je suis l’auteur de chessIO sur Hackage
J’ai écrit en Rust un désassembleur eBPF et un émulateur à moitié terminé, et Rust m’a paru être un langage assez agréable pour les tâches de parsing
Cela dit, quand l’auteur a besoin de macros avant même d’avoir parcouru 1/6 de son étude de cas, j’ai l’impression que cela affaiblit son argument
Les macros ne sont pas de la génération de code complète, mais elles ne donnent pas non plus vraiment l’impression de travailler idiomatiquement dans le langage
Ce n’est pas une critique gratuite : je pense que Rust est effectivement assez fort dans ce domaine
Par exemple, la règle hors contexte
S ::= abc|aabbcc|aaabbbccc|...peut parser efficacementa^Nb^Nc^N, ce qui est un exemple de grammaire contextuelleC’est un exemple simple, mais on voit des choses similaires dans la pratique, par exemple lorsqu’un langage autorise la définition d’opérateurs
Comment Rust gère-t-il ce genre de cas ?
Ça a l’air cool
Pour avoir écrit des parseurs et des lexers avec Ragel, et utilisé Go, Java, C++ et C, je dirais qu’avec un générateur de boilerplate suffisant, le C pur est aussi agréable que le code Rust décrit par l’auteur
Il peut même être meilleur, grâce à sa simplicité
Par exemple, le code nécessaire pour un parseur JSON ressemble surtout à ça
https://github.com/gritzko/librdx/blob/master/JSON.lex
En réalité, cet eBNF ne produit qu’un lexer, et la partie parseur n’est pas très impressionnante non plus : 120 lignes, assez répétitives
https://github.com/gritzko/librdx/blob/master/JSON.c
Au final, l’infrastructure de parsing évolue jusqu’au point où l’on peut produire un parseur à partir du seul eBNF, et c’est selon moi le point de saturation
J’ai l’impression que les types de données algébriques de Rust rendent l’arbre syntaxique généré beaucoup plus facile à manipuler
Cela dit, je suis d’accord pour dire qu’un peu de génération de code ou de magie à base de macros peut rendre le C nettement plus agréable à utiliser
Mais est-ce que ce code-ci
https://github.com/gritzko/librdx/blob/master/JSON.lex
n’accepte pas
[comme du JSON valide ?delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;On dirait qu’en JSON on peut choisir un seul delimiter et mettre tout le reste à 0 occurrence
En général, je commence par regarder la RFC
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
Je ne suis même pas sûr qu’on puisse implémenter JSON avec Ragel
Ragel ne peut traiter que des langages réguliers, et il me semble que JSON est un langage hors contexte
https://en.wikipedia.org/wiki/Cloudbleed
À ce sujet, j’aime la conférence de Rob Pike sur le scan lexical en Go
C’est une approche pédagogique et élégante
https://www.youtube.com/watch?v=HxaD_trXwRE
Il me semble que c’était à cause du surcoût de planification des goroutines ou de schémas d’allocation mémoire inefficaces
La meilleure discussion que j’aie trouvée est [1]
Une autre excellente conférence sur la création de lexers et de parsers efficaces est “Practical Data Oriented Design” d’Andrew Kelley [2]
En résumé, elle décrit plusieurs stratégies pour réduire l’empreinte mémoire d’un programme tout en le rendant favorable au cache afin d’augmenter le débit
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
J’ai eu une expérience surprenante
J’ai pu reprendre telle quelle une bibliothèque de combinateurs de parsers que j’utilisais pour un parser de compilateur de haut niveau, l’utiliser dans un environnement no-std, la compiler pour un microcontrôleur, puis la déployer comme parser de protocole haute performance dans un environnement embarqué
C’était exactement la même bibliothèque
La seule différence consistait plus ou moins à réduire l’usage de String et à utiliser davantage
&'static strDu coup, bricoler avec des compilateurs se transfère plutôt bien en compétence pour écrire des parsers de protocoles embarqués
La difficulté que j’ai rencontrée en écrivant un parser d’AST complet en Rust a été de représenter une hiérarchie de types d’AST concrets, y compris l’upcasting et le downcasting
J’ai bien trouvé une façon de faire, mais il fallait des bidouillages de types étranges comme
PhantomDataet des macrosLà aussi, il me semble qu’il fallait des macros assez excessives
Je serais curieux de voir à quoi ressemblent les travaux antérieurs sur ce sujet
Jusqu’au moment où l’on arrive à l’upcasting/downcasting, en tout cas
Je n’ai pas assez d’expérience en Rust pour savoir s’il existe une bonne manière de gérer cela
Les traits dynamiques pourraient peut-être fonctionner
Comment débogue-t-on ce genre de code de macro, ou comment une personne qui arrive dans la base de code peut-elle le comprendre ?
Même en regardant les usages de la macro
node!et sa définition, j’ai l’impression qu’il est difficile de savoir quel code est réellement généréJe me demande s’il suffit de lancer un exemple et de regarder les indices de type obtenus, si l’IDE affiche la version développée au survol, ou s’il faut se référer au code compilé pour en être sûr
Comme je ne travaille qu’en JS/TS et que je ne touche pas aux macros, ce workflow m’intéresse
$ cargo expand, on peut voir le code résultantRust ressemble en fait à plusieurs langages : le Rust « vanilla », les macros déclaratives et les macros procédurales ont chacun des capacités et des dialectes légèrement différents
Avec le temps, on s’habitue à manipuler chacun d’eux
Les tests unitaires sont aussi un bon terrain d’expérimentation pour comprendre l’impact des modifications de macros
Ce n’est pas si mauvais, mais moins il y a de macros procédurales dans une base de code, mieux c’est
Les macros déclaratives sont un peu plus faciles à comprendre, et beaucoup plus simples à maintenir et à tester
Je ressens la même chose à propos de la génération de code opaque dans d’autres langages
Pour parser la syntaxe de sqlite, bonne chance
Il y a quelques années, j’ai dû écrire pour le travail un parser d’un sous-ensemble assez réduit de sqlite
J’aime vraiment sqlite et c’est toujours une source d’inspiration
Les diagrammes ferroviaires sont extrêmement utiles
https://www.sqlite.org/syntaxdiagrams.html
Le générateur de parser lemon n’est pas assez reconnu, à mon avis
https://sqlite.org/src/doc/trunk/doc/lemon.html
Côté choix de langage, n’importe quel langage avec des types algébriques de données convient bien
Même TypeScript peut être excellent pour cet usage
J’avais aussi écrit autrefois un petit billet d’introduction sur l’écriture manuelle d’un parser en Rust
https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...