sqleibnizadalah alat analisis statis untuk memeriksa sintaks SQL dialek SQLite, keberadaan tabel·kolom·fungsi, dan kondisi runtime, sehingga tokenisasi dan parsing menjadi tahap intimacro_rules!di Rust membantu membuat struct node AST, implementasi traitNode, dan pengujian berbasis tabel ala Go tanpa pengulangan- Dengan pola
matches!danmatch, percabangan tata bahasa seperti literal angka SQLite, identifier, simbol, danEXPLAIN QUERY PLANdapat dipetakan hampir langsung ke dalam kode - Operator
is_some_and,map,map_ormilikOptionserta operator?membuat pengecekan keberadaan nilai, transformasi, nilai default, dan propagasi error dalam pemrosesan input·aliran token menjadi ringkas - Iterator Rust digunakan untuk menghapus
_pada literal angka, memvalidasi karakter heksadesimal di dalam blob, dan menghitung posisi error, yang pada akhirnya membuat kode tokenisasi·parsing lebih mudah dibaca
Alur analisis sqleibniz
sqleibnizadalah alat analisis SQL yang sedang dikembangkan untuk dialek SQLite- Untuk input SQL, alat ini ditujukan melakukan pemeriksaan sintaks, verifikasi keberadaan tabel·kolom·fungsi, serta validasi kondisi yang digabungkan dengan runtime SQLite bawaan
- Pesan error ditargetkan agar menyediakan konteks dan penjelasan, serta memungkinkan diagnostik tertentu untuk diabaikan
- Alur analisis dimulai dari analisis leksikal/tokenisasi, lalu parsing SQL sesuai dokumentasi SQLite, dan berlanjut ke analisis struktur hasilnya
- Setelah bagian analisis statis selesai, ada rencana untuk menulis server LSP untuk SQL
Menghilangkan pengulangan node AST dengan makro
- Node AST di
sqleibnizadalah struct yang menyimpanToken, dan semua node harus mengimplementasikan traitNode - Trait
Nodemenggunakanstd::fmt::Debugsebagai supertrait, sehingga hanya tipe yang memenuhiDebugyang dapat mengimplementasikanNode - Agar tidak perlu mengulang definisi struct dan implementasi
fn token(&self) -> &Tokenuntuk setiap node, makronode!menghasilkan keduanya- Nama node diterima melalui metavariabel
ident - String dokumentasi diterima melalui metavariabel
literal - Field tambahan ditangani berulang dalam bentuk
$($field_name:ident:$field_type:ty),*
- Nama node diterima melalui metavariabel
- Node
Literalhanya memiliki field token, sedangkan nodeExplainmenambahkan fieldchild: Option<Box<dyn Node>> - Komentar dokumentasi diteruskan ke compiler sebagai argumen makro dalam bentuk
#[doc = $documentation], bukan///
Menerapkan pengujian berbasis tabel ala Go dengan makro Rust
- Seperti pengujian berbasis tabel di Go, pendekatan yang menjalankan array kasus input dan mengeksekusi tiap kasus sebagai pengujian terpisah direplikasi dengan makro Rust
- Pengujian lexer menggunakan makro
test_group_pass_assert!dantest_group_fail!- Pengujian yang lolos memasukkan input ke
Lexerlalu membandingkan daftar tipe token dari hasilLexer.run()dengan nilai yang diharapkan - Pengujian gagal memeriksa bahwa vektor token hasil kosong dan
Lexer.errorsmemiliki setidaknya satu error - Saat
cargo testdijalankan, tiap kasus memberikan umpan balikokataufaillayaknya fungsi pengujian terpisah
- Pengujian yang lolos memasukkan input ke
- Pengujian parser mengikuti struktur yang sama, tetapi setelah lexer dijalankan,
Parserdiinisialisasi lalu hasilparse()diperiksaEXPLAIN VACUUM;danEXPLAIN QUERY PLAN VACUUM;adalah kasus yang lolosEXPLAIN;danEXPLAIN QUERY PLAN;adalah kasus yang gagal- Kasus gagal ini memverifikasi syarat dalam tata bahasa SQLite
sql-stmtbahwa setelahEXPLAINharus ada sebuah pernyataan
Hal yang kurang nyaman dari macro_rules!
- Dukungan
rust-analyzerdi dalammacro_rules!terbatas- Tidak ada IntelliSense yang sesungguhnya
- Tidak ada go to definition
- Tidak ada hover signature untuk literal dan struktur bahasa
cargo fmttidak memformat atau mengindentasi isimacro_rules!maupun lokasi pemanggilan makrotreesitterdanchromakadang kesulitan dengan syntax highlightingmacro_rules!- Dokumentasi makro juga cenderung kurang memadai
matches! dan match yang menonjol dalam pencocokan karakter
- Perbandingan karakter di lexer adalah dasar bagi pemrosesan lain, dan makro
matches!serta polamatchdi Rust membuat bagian ini ringkas - Pengenalan angka SQLite ditulis dengan
matches!+,-_.a..=f,A..=F0..=9
- Pengenalan identifier juga dinyatakan seperti
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9') - Loop utama lexer membagi karakter saat ini dengan
match- Karakter spasi dilewati
*,;,,,%dan lain-lain masing-masing menghasilkan token yang sesuai- Contoh penanganan simbol tak dikenal dihilangkan dan ditandai sebagai
panic!("whoops")
Menangani struktur tata bahasa SQL dengan pencocokan token
- Lexer mengubah aliran karakter menjadi aliran struct
Tokenyang memiliki informasi posisi dan tipe, lalu parser mengonsumsinya untuk membangun AST - Enum
TypemencakupKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eof, dan lainnya sql_stmt_prefixadalah fungsi parser yang menangani pernyataanEXPLAINdari dokumentasi SQLite- Jika token saat ini adalah
Type::Keyword(Keyword::EXPLAIN), nodeExplaindibuat danEXPLAINdikonsumsi - Jika token berikutnya adalah
QUERY, makaQUERYdanPLANdikonsumsi berurutan - Setelah itu, pernyataan SQL yang sebenarnya diparse sebagai
child - Jika bukan
EXPLAIN, pemrosesansql_stmtbiasa dipanggil
- Jika token saat ini adalah
literal_valuemembuat nodeLiteraluntuk string, angka, blob, boolean, serta literal berbasis keyword sepertiNULL,CURRENT_TIME,CURRENT_DATE,CURRENT_TIMESTAMP
Penandaan error dan pemanfaatan Option
- Lexer dan parser menampilkan error kepada pengguna untuk kasus seperti titik koma di akhir pernyataan SQL yang hilang
- Operator
?di Rust digunakan untuk penanganan dan propagasi error Option::is_some_anddipakai untuk memeriksa apakah karakter berikutnya atau karakter saat ini ada sekaligus memenuhi kondisiself.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapdimanfaatkan untuk mengubah byte berikutnya dari inputVec<u8>menjadicharOption::map_ordigunakan dengan cara hanya membandingkan tipe jika token saat ini atau token berikutnya ada, dan mengembalikanfalsejika tidak ada
Memproses angka dan blob dengan iterator
- Parsing angka SQLite mengizinkan
_, tetapi parsing angka Rust tidak mengizinkannya, sehingga lexer mengonsumsinya bersama input lalu menghapusnya sebelum parsing - Proses ini ditulis sebagai rangkaian iterator
- Mengambil byte slice
- Mengubah tiap byte menjadi
char - Memfilter hanya karakter yang bukan
_ - Mengumpulkannya menjadi
String
- Dalam situasi ini digunakan
unwrap_or_default(), tetapi string kosong tetap tidak valid sebagai angka sehingga parser tetap akan gagal - Di Go, proses yang sama mengharuskan iterasi daftar karakter, menulis byte ke
strings.Builder, lalu membuat string lagi - Blob SQLite mengizinkan data heksadesimal berbentuk
x'<hex>', sehingga tiap karakter string diperiksa denganchars().enumerate()untuk memastikanis_ascii_hexdigit()enumeratedigunakan untuk memperoleh informasi posisi karakter yang salah untuk penandaan error- Saat menemukan karakter heksadesimal yang tidak valid, error dibuat lalu pemrosesan dihentikan
1 komentar
Komentar Hacker News
Sampai dua bulan lalu pun saya mungkin berpikir sama seperti penulis, tetapi saya terus membentur batas kaku Rust, yaitu borrow checker
Tipe data aljabar, misalnya enum dan pattern matching, memang sangat bagus, tetapi karena borrow checker dan pertimbangan memori level rendah, saya jadi lebih banyak menghabiskan waktu melawan borrow checker daripada mengerjakan masalah bahasa pemrograman yang merupakan inti proyek
Jadi tokenisasi dan parsing memang baik-baik saja, tetapi interpreter dan pemeriksaan tipe menjadi menyakitkan, lalu saat mencari bahasa yang lebih cocok, setelah mempertimbangkan F#, Zig/C, dan Go, saya menemukan OCaml
Sintaksnya terasa seperti Haskell yang ramah, dan tampak seperti Rust tanpa lifetime, jadi saya tertarik; compiler Rust pertama juga ditulis dengan OCaml dan bahasa ini juga terkenal di bidang bahasa pemrograman
Saya masih dalam tahap belajar jadi sulit memberi penilaian yang adil, tetapi sejauh ini ini paling mendekati apa yang saya cari
Bahasa ini praktis dan cepat, bukan benar-benar level rendah, kompilasinya juga cepat, dan yang terpenting populer sehingga semua library sudah tersedia, jadi rasanya memang seharusnya dipakai
Tetapi saya hampir secara tidak rasional membenci bahasanya sendiri, dan rasanya semua aspeknya jelek
Ini bahasa yang dibuat orang-orang C pada 2009, dan bahkan menurut standar saat itu pun terasa seolah mereka tidak tahu hal-hal menarik dari 20 tahun terakhir dalam desain bahasa pemrograman
Bahkan PHP tahun 2009 terasa lebih modern dan lebih dirancang dengan baik daripada Go, dan saya sulit menepis kesan bahwa Go juga tidak banyak membaik setelah itu
Sebagai gantinya, lebih baik gunakan sesuatu yang memungkinkan cheap clone dan interning, seperti library string statis, dan untuk posisi teks sebaiknya cukup pakai indeks
Jika memungkinkan, sebisa mungkin hindari menyimpan referensi
Semakin banyak Anda menyimpan hal-hal yang cheap/free clone, semakin sedikit Anda harus bertarung dengan borrow checker, dan bila perlu Anda bisa beralih ke clone
Untuk sisi interpreter yang nyata, library yang membantu manajemen memori dengan cara seperti arena cukup berguna
Ini memang area yang sangat khusus, tetapi memberi performa dan kemudahan penggunaan sekaligus, dan proyek seperti Ruffle juga banyak memakai pola seperti ini
Namun OCaml dan Haskell melakukan hal-hal semacam ini secara “gratis” berkat reference counting bawaan dan garbage collection, meski begitu saya tetap menyukai gagasan melaju sangat cepat dengan Rust
Go lebih dekat ke C yang dimodernisasi, dan model yang ditawarkannya sangat sederhana
Jika datang dari C#, justru karena kesederhanaannya bahasa ini terasa lebih sulit dipelajari; kelebihannya adalah beban konsepnya rendah, dan cocok untuk aplikasi kecil yang fokus jika Anda bisa menerima kompromi berupa sifatnya yang verbose
Kalau diminta merekomendasikan, menurut saya F# bagus, atau C# modern juga oke
Memang Microsoft terlibat, tetapi jika ingin hidup di dunia yang sama sekali tidak memakai apa pun buatan korporasi besar yang jahat, itu akan sulit
Java, Go, Python, TypeScript/JavaScript, dan Swift juga semua masuk di sini, jadi pilihan yang tersisa hampir tidak ada
Saya penasaran bagaimana pendapat Anda setelah memakai OCaml sekitar setahun
Keluarga Haskell memang menarik, tetapi Haskell sendiri tidak memberi keuntungan yang sepadan dengan kurva belajarnya bagi saya, dan Rust pun mirip
Saya sudah mendalami sistem tipe C# hingga mahir, tetapi saya tidak punya waktu untuk menggali Rust sedalam itu
Tentu ini menambah sekitar 10~20MB pada biner dan penggunaan memori, tetapi menurut standar sekarang itu hampir bukan apa-apa
Misalnya, Tailscale tampaknya memakai Go sebagai lapisan WireGuard lintas platform pada aplikasi mobile dan desktop, dan kelihatannya bekerja dengan baik
Saya tidak akan membuat UI native dengan Go, tetapi untuk pekerjaan level rendah bahasa ini sangat bagus
TinyGo juga memungkinkan penulisan Go untuk mikrokontroler atau WebAssembly; memang ada banyak hal yang belum didukung, tetapi sebagian besar standard library tetap bisa dipakai
Sebagai contoh, compiler Go sendiri juga ditulis dalam Go
Berkat cross-compilation dan biner yang relatif kecil, deployment menjadi sangat mudah
Namun memang benar bahwa Go kekurangan syntactic sugar, dan tidak cocok untuk pattern matching bergaya fungsional
Cara pendekatan parsing ini terlihat agak aneh, dan saya mendapat kesan penulisnya belum terlalu akrab dengan Rust maupun konsep bahasa pemrograman yang mendasarinya
Kalau melihat beberapa hal, AST tampaknya akan jauh lebih sederhana bila didefinisikan sebagai tipe data aljabar
Rasanya kecil kemungkinan tata bahasa sqlite tiba-tiba berkembang dengan menambah banyak node baru sehingga membutuhkan encoding rumit seperti ini
Encoding saat ini terlihat seperti bentuk yang akan terpikir oleh seseorang yang terbiasa dengan pemrograman berorientasi objek tetapi belum terbiasa dengan tipe data aljabar
Pernyataan “macro bekerja berbeda di kebanyakan bahasa, tetapi alasan utamanya adalah menghilangkan duplikasi kode dan mengurangi pengulangan” sebenarnya juga bisa diterapkan pada semua mekanisme abstraksi seperti fungsi
Ciri yang mendefinisikan macro adalah bahwa ia dijalankan pada waktu kompilasi
Jika ingin melihat cara menata parser dengan rapi, riset parser combinator bisa menjadi titik awal yang baik
Judul blognya juga “Why I love ...”, dan meskipun poin yang disampaikan tampak valid, rasanya tidak perlu menyoroti kurangnya pengalaman
Bahwa seseorang menyukai pemrograman adalah hal yang baik, dan pengalaman akan mengikuti
Ini tidak bisa dilakukan dengan fungsi
Dari sudut pandang seseorang yang pernah menulis parser kecil untuk notasi catur Forsyth-Edwards [0], Haskell terasa jauh lebih unggul dalam hal kesederhanaan dan keterbacaan
Hampir terbaca seperti BNF dan nyaris tidak ada ritual teknis, jadi bisa fokus pada tata bahasa yang benar-benar ingin di-parse
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
Saya penasaran apakah ada alasan yang jelas mengapa pendekatan serupa tidak bisa dilakukan di Rust
Misalnya, winnow [1] tampaknya menyediakan gaya yang cukup deklaratif, dan Rust juga punya beberapa library parser combinator lain
[1]: https://docs.rs/winnow/latest/winnow/
Karena itu bisa diimplementasikan sebagai fungsi sederhana dengan satu loop
Beberapa hari lalu saya menulis “parser” FEN untuk implementasi quad-bitboard eksperimental, dan hampir terasa seperti menulis dirinya sendiri
Sebagai tambahan, saya adalah penulis chessIO di Hackage
Saya pernah menulis disassembler eBPF dan emulator setengah jadi dengan Rust, dan Rust terasa cukup menyenangkan untuk pekerjaan jenis parsing
Hanya saja, ketika penulis membutuhkan makro bahkan sebelum melewati 1/6 studi kasusnya, menurut saya itu sedikit melemahkan argumennya
Makro memang bukan code generation penuh, tetapi juga tidak terlalu terasa seperti bekerja secara idiomatis di dalam bahasa itu sendiri
Bukan bermaksud mencela, karena menurut saya Rust memang cukup kuat di area ini
Misalnya, aturan context-free
S ::= abc|aabbcc|aaabbbccc|...dapat secara efektif meng-parsea^Nb^Nc^N, dan ini adalah contoh grammar context-sensitiveIni memang contoh sederhana, tetapi hal serupa juga muncul di dunia nyata, misalnya ketika sebuah bahasa mengizinkan definisi operator
Bagaimana Rust menangani hal seperti ini?
Kelihatannya keren
Dari pengalaman menulis parser dan lexer dengan Ragel serta memakai Go, Java, C++, dan C, menurut saya C murni pun bisa sebagus kode Rust yang dijelaskan penulis asalkan ada generator boilerplate secukupnya
Bahkan mungkin lebih baik karena kesederhanaannya
Misalnya, sebagian besar kode yang dibutuhkan untuk parser JSON hanya sekitar ini
https://github.com/gritzko/librdx/blob/master/JSON.lex
Sebenarnya eBNF itu hanya membuat lexer, dan bagian parser-nya juga tidak terlalu mengesankan, hanya 120 baris dan cukup repetitif
https://github.com/gritzko/librdx/blob/master/JSON.c
Pada akhirnya, menurut saya infrastruktur parser akan berevolusi sampai titik di mana eBNF saja sudah cukup untuk membuat parser, dan itu adalah titik jenuhnya
Saya merasa algebraic data type di Rust membuat penanganan pohon sintaks yang dihasilkan jauh lebih mudah
Meski begitu, saya setuju bahwa sedikit code generation atau sihir makro bisa membuat C jauh lebih nyaman dipakai
Tapi bukankah kode di sini
https://github.com/gritzko/librdx/blob/master/JSON.lex
menerima
[sebagai JSON yang valid?delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;Sepertinya di JSON bisa memilih satu delimiter saja lalu memilih nol untuk semua sisanya
Biasanya saya mulai dengan melihat RFC
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
Saya juga tidak yakin apakah JSON bisa diimplementasikan dengan Ragel
Setahu saya Ragel hanya bisa menangani bahasa reguler, sedangkan JSON adalah bahasa context-free
https://en.wikipedia.org/wiki/Cloudbleed
Terkait ini, saya suka kuliah Rob Pike tentang pemindaian leksikal
Pendekatannya edukatif dan elegan
https://www.youtube.com/watch?v=HxaD_trXwRE
Sepertinya alasannya karena overhead penjadwalan goroutine atau pola alokasi memori yang tidak efisien
Pembahasan terbaik yang saya temukan adalah [1]
Kuliah bagus lainnya tentang membuat lexer dan parser yang efisien adalah “Practical Data Oriented Design” oleh Andrew Kelley [2]
Ringkasnya, kuliah itu menjelaskan berbagai strategi untuk meningkatkan throughput dengan mengurangi penggunaan memori program dan membuatnya ramah cache
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
Ada satu pengalaman yang cukup mengejutkan bagi saya
Saya bisa memakai library parser combinator yang saya gunakan untuk parser compiler tingkat tinggi apa adanya di lingkungan no-std, mengompilasinya untuk mikrokontroler, lalu mendistribusikannya sebagai parser protokol berperforma tinggi di lingkungan embedded
Benar-benar library yang sama persis
Perbedaannya hanya mengurangi penggunaan String dan lebih banyak memakai
&'static strJadi, bermain-main dengan compiler ternyata cukup nyambung dengan kemampuan membuat parser protokol embedded
Saat menulis seluruh parser AST dengan Rust, bagian yang sulit adalah merepresentasikan hierarki tipe AST konkret, termasuk upcasting dan downcasting
Saya akhirnya menemukan caranya, tetapi perlu trik tipe aneh seperti
PhantomDatadan macroSepertinya di sini juga perlu macro yang cukup berlebihan
Saya penasaran seperti apa pekerjaan terdahulu yang terkait dengan ini
Setidaknya sampai harus berurusan dengan upcasting/downcasting
Saya belum cukup berpengalaman dengan Rust untuk tahu apakah ada cara penanganan yang baik
Mungkin trait dinamis bisa dipakai
Bagaimana kode macro seperti ini biasanya di-debug, atau dipahami oleh orang yang baru masuk ke codebase
Melihat pemakaian macro
node!dan definisi macronya saja, rasanya sulit memahami kode seperti apa yang sebenarnya dihasilkanApakah biasanya cukup menjalankan contoh lalu melihat type hint yang muncul, apakah IDE menampilkan versi hasil ekspansi saat hover, atau kalau ingin benar-benar yakin harus merujuk ke kode hasil kompilasi
Saya hanya bekerja dengan JS/TS jadi tidak menyentuh macro, maka saya penasaran seperti apa alur kerja seperti ini
$ cargo expandakan memperlihatkan kode hasilnyaRust sebenarnya terasa lebih seperti beberapa bahasa sekaligus, dengan “vanilla” Rust, macro deklaratif, dan macro prosedural yang masing-masing punya kemampuan dan dialek yang sedikit berbeda
Seiring waktu Anda akan terbiasa menangani masing-masing
Unit test juga menjadi ruang eksperimen yang bagus untuk memahami dampak perubahan macro
Tidak terlalu buruk, tetapi semakin sedikit macro prosedural di codebase biasanya semakin baik
Macro deklaratif sedikit lebih mudah dipahami, dan jauh lebih mudah dipelihara serta diuji
Saya juga merasakan hal serupa terhadap code generation yang opak di bahasa lain
Parsing tata bahasa sqlite memang butuh keberuntungan
Beberapa tahun lalu, karena pekerjaan, saya harus menulis parser untuk subset sqlite yang cukup kecil
Saya sangat menyukai sqlite dan itu selalu menjadi sumber inspirasi
diagram rel sangat berguna
https://www.sqlite.org/syntaxdiagrams.html
Menurut saya parser generator lemon kurang mendapatkan apresiasi
https://sqlite.org/src/doc/trunk/doc/lemon.html
Dari sisi pemilihan bahasa, bahasa apa pun yang memiliki tipe data aljabar akan cocok
Bahkan TypeScript pun bisa sangat baik untuk tujuan ini
Dulu saya juga pernah menulis pengantar kecil tentang menulis parser manual dengan Rust
https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...