2 Punkte von GN⁺ 2024-11-09 | 1 Kommentare | Auf WhatsApp teilen
  • sqleibniz ist ein statisches Analysewerkzeug, das für SQL im SQLite-Dialekt die Syntax, das Vorhandensein von Tabellen, Spalten und Funktionen sowie Laufzeitbedingungen prüfen soll; dafür sind Tokenisierung und Parsing zentrale Schritte
  • Rusts macro_rules! hilft dabei, AST-Node-Structs, Implementierungen des Node-Traits und tabellenbasierte Tests im Go-Stil ohne Wiederholungen zu erstellen
  • Mit matches!- und match-Patterns lassen sich Grammatikverzweigungen wie SQLite-Zahlenliterale, Identifikatoren, Symbole und EXPLAIN QUERY PLAN nah am Code abbilden
  • Option mit is_some_and, map, map_or sowie der ?-Operator machen bei der Verarbeitung von Eingaben und Token-Streams das Prüfen auf vorhandene Werte, Transformationen, Default-Werte und Fehlerweitergabe kompakt
  • Rust-Iteratoren werden zum Entfernen von _ aus Zahlenliteralen, zur Validierung hexadezimaler Zeichen in Blobs und zur Berechnung von Fehlerpositionen genutzt; daraus ergibt sich der Vorteil, dass Tokenisierungs- und Parsing-Code besser lesbar wird

Analyseablauf von sqleibniz

  • sqleibniz ist ein SQL-Analysewerkzeug in Entwicklung, das auf den SQLite-Dialekt ausgerichtet ist
  • Für SQL-Eingaben soll es Syntaxprüfungen durchführen, das Vorhandensein von Tabellen, Spalten und Funktionen verifizieren und Bedingungen in Kombination mit der eingebetteten SQLite-Runtime prüfen
  • Fehlermeldungen sollen Kontext und Erklärungen liefern und es ermöglichen, bestimmte Diagnosen zu ignorieren
  • Der Analyseablauf beginnt mit lexikalischer Analyse/Tokenisierung, führt über das Parsen von SQL gemäß der SQLite-Dokumentation und anschließend zur Analyse der Ergebnisstruktur
  • Nach Abschluss des statischen Analyse-Teils ist außerdem geplant, einen LSP-Server für SQL zu schreiben

Wiederholungen bei AST-Nodes mit Makros entfernen

  • Die AST-Nodes von sqleibniz sind Structs, die ein Token enthalten, und alle Nodes müssen den Node-Trait implementieren
  • Der Node-Trait nutzt std::fmt::Debug als Supertrait, sodass nur Typen, die Debug erfüllen, Node implementieren können
  • Damit nicht für jeden Node die Struct-Definition und die Implementierung von fn token(&self) -> &Token wiederholt werden müssen, erzeugt das Makro node! diese Teile
    • Der Node-Name wird als ident-Metavariable entgegengenommen
    • Der Dokumentationsstring wird als literal-Metavariable entgegengenommen
    • Zusätzliche Felder werden in der Form $($field_name:ident:$field_type:ty),* wiederholt verarbeitet
  • Der Node Literal hat nur das Token-Feld, während der Node Explain zusätzlich das Feld child: Option<Box<dyn Node>> besitzt
  • Dokumentationskommentare werden nicht als ///, sondern in der Form #[doc = $documentation] als Makroargumente an den Compiler übergeben

Tabellenbasierte Tests im Go-Stil mit Rust-Makros umsetzen

  • Wie bei tabellenbasierten Tests in Go, bei denen ein Array von Eingabefällen durchlaufen wird und jeder Fall als eigenständiger Test ausgeführt wird, wird dieses Vorgehen mit Rust-Makros nachgebildet
  • Lexer-Tests verwenden die Makros test_group_pass_assert! und test_group_fail!
    • Erfolgreiche Tests geben die Eingabe an den Lexer weiter und vergleichen die Liste der Token-Typen aus dem Ergebnis von Lexer.run() mit dem erwarteten Wert
    • Fehlgeschlagene Tests prüfen, ob der Ergebnis-Token-Vektor leer ist und Lexer.errors mindestens einen Fehler enthält
    • Beim Ausführen von cargo test gibt jeder Fall Feedback wie eine separate Testfunktion mit ok oder fail aus
  • Parser-Tests folgen derselben Struktur, initialisieren aber nach dem Lexer-Lauf den Parser und prüfen das Ergebnis von parse()
    • EXPLAIN VACUUM; und EXPLAIN QUERY PLAN VACUUM; sind Erfolgsfälle
    • EXPLAIN; und EXPLAIN QUERY PLAN; sind Fehlerfälle
    • Die Fehlerfälle prüfen die Bedingung aus der SQLite-sql-stmt-Grammatik, dass nach EXPLAIN eine Anweisung erforderlich ist

Unbequeme Seiten von macro_rules!

  • Innerhalb von macro_rules! ist die Unterstützung durch rust-analyzer eingeschränkt
    • Es gibt keine echte IntelliSense
    • Es gibt kein Springen zur Definition
    • Es gibt kein Hover für Literale und Signaturen von Sprachstrukturen
  • cargo fmt formatiert oder rückt weder das Innere von macro_rules! noch Makroaufrufe ein
  • treesitter und chroma haben gelegentlich Schwierigkeiten mit Syntax-Highlighting für macro_rules!
  • Auch die Makro-Dokumentation ist eher unzureichend

Wo matches! und match beim Zeichen-Matching glänzen

  • Zeichenvergleiche im Lexer sind die Grundlage weiterer Verarbeitung, und Rusts matches!-Makro sowie match-Patterns machen diesen Teil kompakt
  • Die Erkennung von SQLite-Zahlen wird mit matches! geschrieben
    • +, -
    • _
    • .
    • a..=f, A..=F
    • 0..=9
  • Auch die Erkennung von Identifikatoren wird etwa als matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') ausgedrückt
  • Die Hauptschleife des Lexers teilt das aktuelle Zeichen mit match auf
    • Leerzeichen werden übersprungen
    • *, ;, ,, % usw. erzeugen jeweils das entsprechende Token
    • Das Beispiel für die Behandlung unbekannter Symbole wird ausgelassen und mit panic!("whoops") dargestellt

SQL-Grammatikstrukturen durch Token-Matching verarbeiten

  • Der Lexer wandelt einen Zeichenstrom in einen Stream von Token-Structs mit Positions- und Typinformationen um, und der Parser konsumiert diesen, um einen AST zu erstellen
  • Das Enum Type enthält unter anderem Keyword, Ident, Number, String, Blob, Boolean, ParamName, Param, Dot, Asteriks, Semicolon, Percent, Comma, Eof
  • sql_stmt_prefix ist eine Parser-Funktion, die die EXPLAIN-Anweisung aus der SQLite-Dokumentation verarbeitet
    • Wenn das aktuelle Token Type::Keyword(Keyword::EXPLAIN) ist, erzeugt sie einen Explain-Node und konsumiert EXPLAIN
    • Wenn das nächste Token QUERY ist, konsumiert sie nacheinander QUERY und PLAN
    • Danach wird die eigentliche SQL-Anweisung als child geparst
    • Wenn es sich nicht um EXPLAIN handelt, ruft sie die normale Verarbeitung von sql_stmt auf
  • literal_value erstellt aus Strings, Zahlen, Blobs und Booleans sowie Keyword-Literalen wie NULL, CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP einen Literal-Node

Fehleranzeige und Nutzung von Option

  • Lexer und Parser zeigen dem Nutzer Fälle wie ein fehlendes Semikolon am Ende einer SQL-Anweisung als Fehler an
  • Rusts ?-Operator wird zur Fehlerbehandlung und Fehlerweitergabe verwendet
  • Option::is_some_and wird genutzt, um zu prüfen, ob das nächste oder aktuelle Zeichen vorhanden ist und eine Bedingung erfüllt
    • self.source.get(self.pos + 1).is_some_and(...)
    • self.source.get(self.pos).is_some_and(...)
  • Option::map wird verwendet, um bei einer Vec<u8>-Eingabe das nächste Byte in ein char umzuwandeln
  • Option::map_or wird so genutzt, dass ein Typvergleich nur durchgeführt wird, wenn das aktuelle oder nächste Token vorhanden ist; andernfalls wird false zurückgegeben

Zahlen und Blobs mit Iteratoren verarbeiten

  • Beim Parsen von SQLite-Zahlen ist _ erlaubt, während Rusts Zahlenparser _ nicht erlaubt; daher konsumiert der Lexer die Zahl inklusive _ und entfernt sie vor dem Parsen
  • Diese Verarbeitung wird als Iterator-Chain geschrieben
    • Ein Byte-Slice wird geholt
    • Jedes Byte wird in ein char umgewandelt
    • Nur Zeichen, die nicht _ sind, werden gefiltert
    • Das Ergebnis wird als String gesammelt
  • In dieser Situation wird unwrap_or_default() verwendet, aber ein leerer String ist als Zahl ohnehin ungültig, sodass der Parser in jedem Fall fehlschlägt
  • In Go müsste man für dieselbe Verarbeitung eine Zeichenliste durchlaufen, Bytes in einen strings.Builder schreiben und daraus wieder einen String erzeugen
  • SQLite-Blobs erlauben hexadezimale Daten in der Form x'<hex>'; daher wird jedes Zeichen des Strings mit chars().enumerate() durchlaufen und mit is_ascii_hexdigit() geprüft
    • enumerate wird verwendet, um die Position eines ungültigen Zeichens für die Fehleranzeige zu ermitteln
    • Wenn ein ungültiges hexadezimales Zeichen gefunden wird, wird ein Fehler erzeugt und die Verarbeitung abgebrochen

1 Kommentare

 
GN⁺ 2024-11-09
Hacker-News-Kommentare
  • Noch vor zwei Monaten hätte ich genauso gedacht wie der Autor, bin aber immer wieder gegen Rusts harte Grenze gestoßen: den Borrow Checker.
    Algebraische Datentypen, etwa Enums und Pattern Matching, fand ich wirklich gut, aber wegen des Borrow Checkers und der Low-Level-Überlegungen zum Speicher verbrachte ich mehr Zeit damit, gegen den Borrow Checker zu kämpfen, als mit dem eigentlichen Kern des Projekts, also Problemen rund um Programmiersprachen.
    Tokenisierung und Parsing waren deshalb in Ordnung, aber Interpreter und Typprüfung wurden zur Qual, und auf der Suche nach einer besser geeigneten Sprache prüfte ich F#, Zig/C und Go und entdeckte schließlich OCaml.
    Die Syntax wirkt wie ein freundlicheres Haskell, und es sieht aus wie Rust ohne Lifetimes; das hat mich überzeugt. Außerdem wurde der erste Rust-Compiler in OCaml geschrieben, und OCaml ist im Bereich Programmiersprachen gut bekannt.
    Ich lerne es noch, daher ist eine faire Bewertung schwierig, aber bisher kommt es ziemlich genau dem nahe, wonach ich gesucht habe.

    • Wenn Go zur Sprache kommt, bin ich aus irgendeinem Grund immer genervt.
      Es ist pragmatisch und schnell, nicht wirklich Low-Level, kompiliert schnell und ist vor allem so populär, dass es alle Bibliotheken gibt, die man braucht — eigentlich sollte ich es wohl benutzen.
      Aber die Sprache selbst mag ich fast schon irrational nicht, und ich empfinde alles an ihr als hässlich.
      Es ist eine Sprache, die 2009 von Leuten aus der C-Ecke gebaut wurde, und selbst für damalige Maßstäbe wirkt es so, als hätten sie nichts von den interessanten Entwicklungen im Programmiersprachen-Design der vorangegangenen 20 Jahre mitbekommen.
      Selbst PHP im Jahr 2009 war eine modernere und besser entworfene Sprache als Go, und ich werde das Gefühl nicht los, dass Go seitdem auch nicht wesentlich besser geworden ist.
    • Meiner Ansicht nach ist beim Umgang mit einem abstrakten Syntaxbaum in Rust der entscheidende Punkt, keine Dinge wie Strings im Baum zu speichern.
      Stattdessen sollte man etwas verwenden, das wie eine Bibliothek für statische Strings cheap clones und Interning ermöglicht, und für Textpositionen am besten nur Indizes nutzen.
      Wenn möglich, sollte man das Speichern von Referenzen unbedingt vermeiden.
      Je mehr man über Dinge hält, die sich billig/kostenlos klonen lassen, desto weniger kämpft man mit dem Borrow Checker, und wenn nötig kann man einfach auf einen Clone ausweichen.
      Für den eigentlichen Interpreter sind Bibliotheken, die bei der Speicherverwaltung mit Ansätzen wie Arenas helfen, ziemlich nützlich.
      Das ist ein sehr spezieller Bereich, bietet aber Performance und Nutzbarkeit zugleich; auch Projekte wie Ruffle verwenden solche Muster häufig.
      Allerdings erledigen OCaml und Haskell solche Dinge dank eingebautem Reference Counting und Garbage Collection „gratis“, wobei mir die Idee trotzdem gefällt, mit Rust sehr schnell zu werden.
    • Ich habe im letzten Jahr viel Go benutzt, würde es aber wohl nicht zum Schreiben eines Parsers verwenden.
      Go ist eher ein modernisiertes C, und das Modell, das es bietet, ist sehr einfach.
      Aus C# kommend war es wegen dieser Einfachheit eher schwieriger zu lernen; die geringe konzeptionelle Last ist ein Vorteil, und es passt gut zu kleinen, fokussierten Anwendungen, bei denen man den Trade-off der Ausführlichkeit akzeptieren kann.
      Wenn ich etwas empfehlen sollte, dann F#, oder auch modernes C#.
      Microsoft ist zwar beteiligt, aber es wird schwierig, wenn man in einer Welt leben will, in der man gar nichts nutzt, was von einem bösen Großkonzern stammt.
      Java, Go, Python, TypeScript/JavaScript und Swift fallen alle ebenfalls darunter, und dann bleiben kaum noch Optionen.
      Ich wäre neugierig, was du nach ungefähr einem Jahr mit OCaml denkst.
      Haskell-artige Sprachen sind interessant, aber bei Haskell selbst war für mich das Verhältnis von Lernkurve zu Nutzen nicht gut, und bei Rust ist es ähnlich.
      In C# habe ich mich intensiv in das Typsystem eingearbeitet und es gemeistert, aber ich habe nicht die Zeit, bei Rust ähnlich tief einzusteigen.
    • Go wird auch auf der Client-Seite viel verwendet, und auf Mobile ist die Unterstützung dank go-mobile ziemlich gut.
      Natürlich kommen dadurch etwa 10–20 MB bei Binary und Speicherverbrauch hinzu, aber nach heutigen Maßstäben ist das fast nichts.
      Tailscale etwa scheint Go in seinen Mobile- und Desktop-Apps als plattformübergreifende WireGuard-Schicht zu verwenden, und das scheint gut zu funktionieren.
      Eine native UI würde ich nicht in Go bauen, aber für Low-Level-Aufgaben ist es hervorragend.
      TinyGo ermöglicht außerdem, Go für Mikrocontroller oder WebAssembly zu schreiben; vieles wird zwar nicht unterstützt, aber ein beträchtlicher Teil der Standardbibliothek ist nutzbar.
    • Ich würde Go nicht als serverseitige Sprache bezeichnen.
      Zum Beispiel ist auch der Go-Compiler in Go geschrieben.
      Dank Cross-Compilation und vergleichsweise kleiner Binaries ist die Bereitstellung sehr einfach.
      Allerdings stimmt es, dass es an syntaktischem Zucker mangelt, und für Pattern Matching im funktionalen Stil ist es nicht gut geeignet.
  • Als Herangehensweise ans Parsing wirkt das etwas merkwürdig, und ich habe den Eindruck, dass der Autor mit Rust und den zugrunde liegenden Konzepten von Programmiersprachen vergleichsweise wenig vertraut ist.
    Um ein paar Punkte herauszugreifen: Der AST ließe sich wahrscheinlich viel einfacher als algebraischer Datentyp definieren.
    Es ist nicht zu erwarten, dass die sqlite-Syntax plötzlich so erweitert wird, dass massenhaft neue Knoten entstehen und dafür eine komplizierte Kodierung nötig wäre.
    Die aktuelle Kodierung wirkt wie etwas, das jemandem einfällt, der mit Objektorientierung vertraut ist, aber nicht mit algebraischen Datentypen.
    Die Aussage „Makros funktionieren in den meisten Sprachen unterschiedlich, aber der Hauptgrund ist, Code-Duplikation zu vermeiden und Wiederholungen zu reduzieren“ könnte man auch über jedes andere Abstraktionsmittel wie Funktionen sagen.
    Das definierende Merkmal von Makros ist, dass sie zur Compile-Zeit ausgeführt werden.
    Wenn man sehen möchte, wie man Parser sauber strukturiert, kann die Forschung zu Parser-Kombinatoren ein guter Ausgangspunkt sein.

    • Der Autor hat nie behauptet, ein erfahrener Programmierer zu sein.
      Der Blogtitel lautet auch „Why I love ...“, und die Kritikpunkte wirken zwar berechtigt, aber auf mangelnde Erfahrung hinzuweisen scheint mir nicht wirklich nötig.
      Es ist gut, wenn jemand Freude am Programmieren hat, und die Erfahrung wird schon kommen.
    • Im Kontext des Blogposts geht es darum, Strukturdefinitionen erzeugen zu wollen.
      Das geht mit Funktionen nicht.
  • Aus Sicht von jemandem, der einen kleinen Parser [0] für die Forsyth-Edwards-Notation im Schach geschrieben hat, ist Haskell in Sachen Einfachheit und Lesbarkeit meiner Meinung nach klar überlegen.
    Es liest sich fast wie BNF und hat kaum technisches Zeremoniell, sodass man sich tatsächlich auf die Grammatik konzentrieren kann, die man parsen will.
    [0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
    [1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...

    • Haskell ist bei der Nutzung von Parser-Kombinatoren definitiv ganz vorn, aber um mit dem Ergebnis zu arbeiten, muss man weiterhin bei Haskell bleiben.
    • Das ist doch nicht nur reines Haskell, sondern eine Parser-Kombinator-Bibliothek, oder?
      Ich frage mich, ob es einen klaren Grund gibt, warum ein ähnlicher Ansatz in Rust nicht gehen sollte.
      Zum Beispiel scheint winnow [1] einen ausreichend deklarativen Stil zu bieten, und es gibt in Rust auch mehrere andere Parser-Kombinator-Bibliotheken.
      [1]: https://docs.rs/winnow/latest/winnow/
    • FEN halte ich nicht für ein gutes Parsing-Beispiel.
      Es lässt sich nämlich mit einer einfachen Funktion mit einer einzelnen Schleife implementieren.
      Vor ein paar Tagen habe ich einen FEN-„Parser“ für eine experimentelle Quad-Bitboard-Implementierung geschrieben, und der schrieb sich praktisch von selbst.
      Nebenbei: Ich bin der Autor von chessIO auf Hackage.
  • Ich habe in Rust einen eBPF-Disassembler und einen halb fertigen Emulator geschrieben, und Rust war für Parsing-artige Aufgaben eine ziemlich angenehme Sprache.
    Allerdings wirkt es so, als würde der Autor seine eigene These schwächen, wenn er noch nicht einmal bei 1/6 der Fallstudie angekommen ist und schon Makros braucht.
    Makros sind keine vollständige Codegenerierung, aber es fühlt sich auch nicht besonders stark danach an, idiomatisch innerhalb der Sprache zu arbeiten.
    Das soll kein Vorwurf sein; ich sehe Rust tatsächlich als ziemlich stark in diesem Bereich.

    • Wie kann man in Rust unendliche Grammatiken definieren?
      Zum Beispiel kann die kontextfreie Regel S ::= abc|aabbcc|aaabbbccc|... effektiv a^Nb^Nc^N parsen, und das ist ein Beispiel für eine kontextsensitive Grammatik.
      Es ist ein einfaches Beispiel, aber Ähnliches sieht man auch in der Praxis; ein Beispiel sind Sprachen, die die Definition von Operatoren erlauben.
      Wie geht Rust damit um?
    • Wenn möglich, wäre es schön, wenn du einen Link zum eBPF-Disassembler teilen könntest.
      Sieht interessant aus.
  • Aus meiner Erfahrung mit dem Schreiben von Parsern und Lexern mit Ragel sowie mit Go, Java, C++ und C ist selbst reines C mit einem gewissen Boilerplate-Generator genauso gut wie der vom Autor beschriebene Rust-Code.
    Wegen seiner Einfachheit ist es vielleicht sogar besser.
    Für einen JSON-Parser braucht man zum Beispiel im Wesentlichen nur Code in diesem Umfang:
    https://github.com/gritzko/librdx/blob/master/JSON.lex
    Tatsächlich erzeugt dieses eBNF nur den Lexer; der Parser-Teil ist auch nicht besonders beeindruckend, 120 Zeilen lang und recht repetitiv.
    https://github.com/gritzko/librdx/blob/master/JSON.c
    Am Ende entwickelt sich Parser-Infrastruktur bis zu dem Punkt, an dem man aus eBNF allein einen Parser erzeugen kann, und das ist meiner Meinung nach der Sättigungspunkt.

    • Diese Wiederholung kann man eher als Nachteil denn als Vorteil sehen.
      Ich finde, Rusts algebraische Datentypen machen den Umgang mit dem erzeugten Syntaxbaum deutlich einfacher.
      Ich stimme aber zu, dass ein wenig Codegenerierung oder Makro-Magie C deutlich handhabbarer machen kann.
    • Ich mag Ragel wirklich sehr.
      Aber akzeptiert der Code hier
      https://github.com/gritzko/librdx/blob/master/JSON.lex
      nicht [ als gültiges JSON?
      delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;
      primitive = Number | String | Literal;
      JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;
      Root = JSON;
      Es sieht so aus, als könne man in JSON nur einen delimiter auswählen und alles Übrige jeweils nullmal.
      Normalerweise fängt man beim RFC an.
      https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
      Ich bin mir auch nicht sicher, ob man JSON mit Ragel implementieren kann.
      Soweit ich weiß, kann Ragel nur reguläre Sprachen verarbeiten, während JSON eine kontextfreie Sprache ist.
    • Cloudbleed wurde durch einen C/Ragel-Bug verursacht und war auch ein Grund dafür, dass Cloudflare auf Rust umgestiegen ist.
      https://en.wikipedia.org/wiki/Cloudbleed
  • In diesem Zusammenhang mag ich Rob Pikes Vortrag über lexikalisches Scanning in Go
    Ein lehrreicher und eleganter Ansatz
    https://www.youtube.com/watch?v=HxaD_trXwRE

    • Der Vortrag ist großartig, aber ich erinnere mich, später eine Diskussion gesehen zu haben, dass Go diese Technik in der Praxis gar nicht verwendet
      Der Grund waren wohl der Scheduling-Overhead von Goroutinen oder ineffiziente Muster bei der Speicherallokation
      Die beste Diskussion, die ich gefunden habe, ist [1]
      Ein weiterer hervorragender Vortrag zum Bau effizienter Lexer und Parser ist Andrew Kelleys „Practical Data Oriented Design“ [2]
      Kurz gesagt beschreibt er mehrere Strategien, um den Speichverbrauch eines Programms zu senken und es cache-freundlicher zu machen, um den Durchsatz zu erhöhen
      1: https://news.ycombinator.com/item?id=31649617
      2: https://www.youtube.com/watch?v=IroPQ150F6c
    • In dem Vortrag scheint es weniger um Lexing an sich zu gehen, sondern eher darum, wie man Nebenläufigkeit in einem Problem ausdrückt, bei dem Nebenläufigkeit natürlich naheliegt
  • Für mich gab es eine erstaunliche Erfahrung
    Ich konnte eine Parser-Kombinator-Bibliothek, die ich für einen High-Level-Compiler-Parser nutzte, unverändert in einer no-std-Umgebung verwenden, sie für einen Mikrocontroller kompilieren und dann als leistungsfähigen Protokollparser in einer Embedded-Umgebung ausliefern
    Es war genau dieselbe Bibliothek
    Der Unterschied bestand ungefähr darin, weniger String und mehr &'static str zu verwenden
    Deshalb lässt sich das Herumspielen mit Compilern ziemlich gut in die Fähigkeit übertragen, Embedded-Protokollparser zu bauen

  • Was mir beim Schreiben eines vollständigen AST-Parsers in Rust schwerfiel, war, eine Hierarchie konkreter AST-Typen einschließlich Upcasting und Downcasting auszudrücken
    Ich habe zwar einen Weg gefunden, brauchte dafür aber seltsame Typ-Spielereien wie PhantomData und Makros
    Auch hier waren, glaube ich, ziemlich übertriebene Makros nötig
    Ich frage mich, wie einschlägige Vorarbeiten dazu aussehen

    • Rusts algebraische Datentypen und Matching-Syntax scheinen gut geeignet zu sein
      Zumindest bis man bei Upcasting/Downcasting ankommt
      Ich habe nicht genug Rust-Erfahrung, um zu wissen, ob es dafür eine gute Lösung gibt
      Dynamische Traits könnten vielleicht möglich sein
    • Falls das Open Source ist, würde mich interessieren, ob es ein öffentliches Repository gibt
  • Wie debuggt man solchen Makro-Code, oder wie versteht ihn jemand, der neu in die Codebase kommt?
    Selbst wenn man die Verwendungsstellen des node!-Makros und die Makrodefinition anschaut, dürfte es schwer sein zu erkennen, welcher Code tatsächlich erzeugt wird
    Ich frage mich, ob man ein Beispiel ausführt und schaut, welche Type-Hints erscheinen, ob man in der IDE per Hover die expandierte Version sehen kann, oder ob man, wenn man sicher sein will, auf den kompilierten Code Bezug nehmen muss
    Da ich nur mit JS/TS arbeite und keine Makros anfasse, interessiert mich dieser Workflow

    • Mit $ cargo expand kann man sich den resultierenden Code ansehen
      Rust ist eigentlich fast mehrere Sprachen: „vanilla“ Rust, deklarative Makros und prozedurale Makros haben jeweils etwas unterschiedliche Fähigkeiten und Dialekte
      Mit der Zeit gewöhnt man sich daran, mit jedem davon umzugehen
      Unit-Tests sind ebenfalls ein guter Experimentierraum, um die Auswirkungen von Makroänderungen zu verstehen
    • rust-analyzer, der Rust-LSP, der etwa in VSCode verwendet wird, kann deklarative und prozedurale Makros rekursiv expandieren
      Das ist nicht allzu schlecht, aber je weniger prozedurale Makros in einer Codebase, desto besser
      Deklarative Makros sind etwas leichter zu verstehen, und Wartung und Tests sind deutlich einfacher
      Bei opaker Codegenerierung in anderen Sprachen empfinde ich es ähnlich
  • Viel Glück beim Parsen der SQLite-Syntax
    Vor ein paar Jahren musste ich beruflich einen Parser für eine recht kleine Teilmenge von SQLite schreiben
    Ich mag SQLite wirklich sehr und es ist immer eine Quelle der Inspiration
    Railroad-Diagramme sind enorm nützlich
    https://www.sqlite.org/syntaxdiagrams.html
    Der Parser-Generator lemon wird meiner Meinung nach nicht genug gewürdigt
    https://sqlite.org/src/doc/trunk/doc/lemon.html
    Was die Sprachwahl angeht, passt alles gut, was algebraische Datentypen hat
    Sogar TypeScript kann für diesen Zweck großartig sein
    Früher habe ich auch einen kleinen Einführungsartikel über das Schreiben eines Parsers von Hand in Rust verfasst
    [https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...](https://www.nhatcher.com/post/a-rustic-invitation-to-parsing/