2 điểm bởi GN⁺ 2024-11-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • sqleibniz là một công cụ phân tích tĩnh nhằm kiểm tra cú pháp SQL theo phương ngữ SQLite, sự tồn tại của bảng/cột/hàm và các điều kiện runtime; vì vậy token hóa và parsing là các bước cốt lõi
  • macro_rules! của Rust giúp tạo cấu trúc node AST, phần triển khai trait Nodetest dựa trên bảng kiểu Go mà không cần lặp lại
  • Dùng các mẫu matches!match có thể chuyển các nhánh ngữ pháp như literal số của SQLite, identifier, symbol, EXPLAIN QUERY PLAN sang mã gần như trực tiếp
  • is_some_and, map, map_or của Option và toán tử ? giúp xử lý input/luồng token một cách gọn gàng: kiểm tra sự tồn tại của giá trị, biến đổi, giá trị mặc định và lan truyền lỗi
  • Iterator của Rust được dùng để loại bỏ _ trong literal số, kiểm tra ký tự hex bên trong blob và tính vị trí lỗi, qua đó giúp mã token hóa/parsing dễ đọc hơn

Luồng phân tích của sqleibniz

  • sqleibniz là một công cụ phân tích SQL đang được viết cho phương ngữ SQLite
  • Với input SQL, công cụ nhắm tới việc thực hiện kiểm tra cú pháp, xác minh sự tồn tại của bảng/cột/hàm và kiểm tra điều kiện kết hợp với runtime SQLite tích hợp
  • Thông báo lỗi hướng tới việc cung cấp ngữ cảnh và mô tả, đồng thời cho phép bỏ qua một số chẩn đoán cụ thể
  • Luồng phân tích bắt đầu từ phân tích từ vựng/token hóa, sau đó parsing SQL theo tài liệu SQLite và phân tích cấu trúc kết quả
  • Sau khi hoàn thành phần phân tích tĩnh, tác giả cũng dự định viết LSP server cho SQL

Loại bỏ lặp lại cho node AST bằng macro

  • Node AST của sqleibniz là các struct chứa Token, và mọi node đều phải triển khai trait Node
  • Trait Node dùng std::fmt::Debug làm supertrait, nên chỉ các kiểu thỏa mãn Debug mới có thể triển khai Node
  • Để tránh lặp lại định nghĩa struct và phần triển khai fn token(&self) -> &Token cho từng node, macro node! sẽ tạo chúng
    • Tên node được nhận bằng metavariable ident
    • Chuỗi tài liệu được nhận bằng metavariable literal
    • Các trường bổ sung được xử lý lặp lại dưới dạng $($field_name:ident:$field_type:ty),*
  • Node Literal chỉ có trường token, còn node Explain có thêm trường child: Option<Box<dyn Node>>
  • Chú thích tài liệu được truyền cho compiler dưới dạng #[doc = $documentation] qua đối số macro, thay vì dùng ///

Triển khai test dựa trên bảng kiểu Go bằng macro Rust

  • Giống test dựa trên bảng của Go, cách chạy qua mảng các case đầu vào và thực thi từng case như một test độc lập được tái hiện bằng macro Rust
  • Test lexer dùng các macro test_group_pass_assert!test_group_fail!
    • Test pass đưa input vào Lexer rồi so sánh danh sách kiểu token từ kết quả Lexer.run() với giá trị kỳ vọng
    • Test fail kiểm tra vector token kết quả rỗng và Lexer.errors có ít nhất một lỗi
    • Khi chạy cargo test, mỗi case đưa ra phản hồi ok hoặc fail như một hàm test riêng
  • Test parser cũng theo cùng cấu trúc, nhưng sau khi chạy lexer thì khởi tạo Parser và kiểm tra kết quả parse()
    • EXPLAIN VACUUM;EXPLAIN QUERY PLAN VACUUM; là các case pass
    • EXPLAIN;EXPLAIN QUERY PLAN; là các case fail
    • Case fail kiểm tra điều kiện trong ngữ pháp sql-stmt của SQLite rằng sau EXPLAIN cần có một câu lệnh

Những điểm bất tiện của macro_rules!

  • Bên trong macro_rules!, hỗ trợ của rust-analyzer bị hạn chế
    • Không có IntelliSense thực sự
    • Không có điều hướng tới định nghĩa
    • Không có hover cho literal và chữ ký của cấu trúc ngôn ngữ
  • cargo fmt không format hay thụt lề bên trong macro_rules! và tại nơi gọi macro
  • treesitterchroma thỉnh thoảng gặp khó khăn với tô sáng cú pháp macro_rules!
  • Tài liệu về macro cũng tương đối thiếu

matches!match tỏa sáng trong việc khớp ký tự

  • So sánh ký tự của lexer là nền tảng cho các xử lý khác, và macro matches! cùng mẫu match của Rust làm phần này trở nên gọn gàng
  • Nhận diện số của SQLite được viết bằng matches!
    • +, -
    • _
    • .
    • a..=f, A..=F
    • 0..=9
  • Nhận diện identifier cũng được biểu diễn như matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9')
  • Vòng lặp chính của lexer chia ký tự hiện tại bằng match
    • Ký tự whitespace được bỏ qua
    • *, ;, ,, % v.v. lần lượt tạo token tương ứng
    • Ví dụ xử lý symbol không xác định được lược bỏ và biểu thị bằng panic!("whoops")

Xử lý cấu trúc ngữ pháp SQL bằng khớp token

  • Lexer chuyển luồng ký tự thành luồng struct Token có thông tin vị trí và kiểu, còn parser tiêu thụ luồng đó để tạo AST
  • Enum Type gồm Keyword, Ident, Number, String, Blob, Boolean, ParamName, Param, Dot, Asteriks, Semicolon, Percent, Comma, Eof v.v.
  • sql_stmt_prefix là hàm parser xử lý câu lệnh EXPLAIN trong tài liệu SQLite
    • Nếu token hiện tại là Type::Keyword(Keyword::EXPLAIN), tạo node Explain và tiêu thụ EXPLAIN
    • Nếu token tiếp theo là QUERY, tiêu thụ liên tiếp QUERYPLAN
    • Sau đó parse câu lệnh SQL thực tế vào child
    • Nếu không phải EXPLAIN, gọi xử lý sql_stmt thông thường
  • literal_value tạo node Literal cho chuỗi, số, blob, boolean, cùng các keyword literal như NULL, CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP

Hiển thị lỗi và sử dụng Option

  • Lexer và parser hiển thị lỗi cho người dùng trong các trường hợp như thiếu dấu chấm phẩy ở cuối câu lệnh SQL
  • Toán tử ? của Rust được dùng để xử lý và lan truyền lỗi
  • Option::is_some_and được dùng khi kiểm tra xem ký tự tiếp theo hoặc ký tự hiện tại có tồn tại và thỏa mãn điều kiện hay không
    • self.source.get(self.pos + 1).is_some_and(...)
    • self.source.get(self.pos).is_some_and(...)
  • Option::map được dùng để chuyển byte tiếp theo từ input Vec<u8> thành char
  • Option::map_or được dùng theo cách chỉ so sánh kiểu khi có token hiện tại hoặc token tiếp theo, còn nếu không có thì trả về false

Xử lý số và blob bằng iterator

  • Parsing số của SQLite cho phép _, nhưng parsing số của Rust không cho phép _, nên lexer tiêu thụ cả _ rồi loại bỏ trước khi parse
  • Xử lý này được viết bằng chuỗi iterator
    • Lấy slice byte
    • Chuyển từng byte thành char
    • Chỉ lọc các ký tự không phải _
    • Thu thập thành String
  • Trong tình huống này, unwrap_or_default() được dùng, nhưng chuỗi rỗng không hợp lệ làm số nên parser dù sao cũng sẽ thất bại
  • Trong Go, để xử lý tương tự, cần duyệt danh sách ký tự, ghi byte vào strings.Builder, rồi tạo lại chuỗi
  • Blob của SQLite cho phép dữ liệu hex dạng x'<hex>', nên lexer duyệt từng ký tự của chuỗi bằng chars().enumerate() và kiểm tra is_ascii_hexdigit()
    • enumerate được dùng để lấy thông tin vị trí của ký tự sai nhằm hiển thị lỗi
    • Khi gặp ký tự hex không hợp lệ, lexer tạo lỗi rồi dừng xử lý

1 bình luận

 
GN⁺ 2024-11-09
Các ý kiến trên Hacker News
  • Chỉ hai tháng trước thôi có lẽ tôi cũng nghĩ giống tác giả, nhưng tôi liên tục va vào ranh giới cứng nhắc của Rust là borrow checker
    Các kiểu dữ liệu đại số, chẳng hạn Enum và pattern matching, thì thật sự rất hay, nhưng vì borrow checker và các cân nhắc bộ nhớ cấp thấp, tôi lại mất nhiều thời gian vật lộn với borrow checker hơn là với vấn đề ngôn ngữ lập trình vốn là cốt lõi của dự án
    Vì vậy tokenization và parsing thì ổn, nhưng interpreter và type checking trở thành cực hình; trong lúc tìm một ngôn ngữ phù hợp hơn, sau khi xem xét F#, Zig/C và Go, tôi đã tìm ra OCaml
    Cú pháp của nó giống một Haskell thân thiện, trông như Rust không có lifetime, nên tôi bị thuyết phục; trình biên dịch Rust đầu tiên cũng được viết bằng OCaml và nó rất nổi tiếng trong lĩnh vực ngôn ngữ lập trình
    Tôi vẫn đang học nên khó đánh giá công bằng, nhưng cho đến giờ nó gần đúng với thứ tôi đang tìm

    • Không hiểu sao cứ nhắc đến Go là tôi lại bực
      Nó thực dụng, nhanh, không hẳn là cấp thấp thật sự, biên dịch cũng nhanh, và hơn hết là rất phổ biến nên thư viện gì cũng có, vì thế có vẻ như mình nên dùng
      Nhưng bản thân ngôn ngữ này khiến tôi ghét gần như phi lý, và tôi cảm thấy mọi khía cạnh của nó đều xấu xí
      Đây là ngôn ngữ do những người phía C tạo ra vào năm 2009, nhưng ngay cả theo tiêu chuẩn lúc đó, họ dường như không biết đến những điều thú vị trong thiết kế ngôn ngữ lập trình suốt 20 năm trước đó
      Ngay cả PHP của năm 2009 cũng là một ngôn ngữ hiện đại hơn và được thiết kế tốt hơn Go, và tôi không thể gạt bỏ cảm giác rằng Go về sau cũng không tiến bộ đáng kể
    • Khi xử lý cây cú pháp trừu tượng trong Rust, tôi nghĩ điểm mấu chốt là không lưu những thứ như chuỗi vào cây
      Thay vào đó nên dùng thứ có thể cheap clone và interning như thư viện chuỗi tĩnh, còn vị trí văn bản thì chỉ nên dùng chỉ số
      Nếu có thể, tuyệt đối nên tránh lưu reference
      Càng giữ nhiều thứ có thể cheap/free clone, bạn càng ít phải vật lộn với borrow checker, và khi cần thì có thể chuyển sang clone
      Ở phần interpreter thực tế, các thư viện giúp quản lý bộ nhớ theo kiểu arena khá hữu ích
      Đây là một lĩnh vực rất chuyên biệt, nhưng nó đem lại cả hiệu năng lẫn tính dễ dùng, và các dự án như Ruffle cũng dùng nhiều pattern kiểu này
      Tuy nhiên OCaml và Haskell làm những việc này “miễn phí” nhờ reference counting và garbage collection tích hợp, dù vậy tôi vẫn thích ý tưởng làm mọi thứ cực nhanh bằng Rust
    • Trong năm qua tôi đã dùng Go khá nhiều, nhưng có lẽ sẽ không dùng nó để viết parser
      Go gần với một C được hiện đại hóa, và mô hình mà nó cung cấp rất đơn giản
      Nếu đến từ C#, chính sự đơn giản đó lại khiến nó khó học hơn; ưu điểm là gánh nặng khái niệm thấp, và nó phù hợp với các ứng dụng nhỏ, tập trung, nơi bạn chấp nhận đánh đổi bằng sự dài dòng
      Nếu phải khuyên, tôi nghĩ F#, hoặc C# hiện đại, cũng ổn
      Dù Microsoft có liên quan, nhưng nếu muốn sống trong một thế giới không dùng bất cứ thứ gì do các tập đoàn lớn xấu xa tạo ra thì sẽ rất khó
      Java, Go, Python, TypeScript/JavaScript, Swift cũng đều vướng vào đây, và khi đó gần như chẳng còn lựa chọn nào
      Tôi tò mò sau khoảng một năm dùng OCaml bạn sẽ nghĩ gì
      Các ngôn ngữ kiểu Haskell thì thú vị, nhưng bản thân Haskell đối với tôi không đem lại lợi ích xứng với đường cong học tập, và Rust cũng tương tự
      Với C# tôi đã đào rất sâu vào hệ thống kiểu và thành thạo nó, nhưng tôi không có thời gian để đào sâu đến mức đó với Rust
    • Go cũng được dùng nhiều ở phía client, và trên mobile cũng được hỗ trợ khá tốt nhờ go-mobile
      Dĩ nhiên nó thêm khoảng 10–20MB vào kích thước binary và mức dùng bộ nhớ, nhưng theo tiêu chuẩn ngày nay thì gần như không đáng kể
      Ví dụ, Tailscale có vẻ dùng Go làm lớp WireGuard đa nền tảng trong ứng dụng mobile và desktop, và có vẻ hoạt động tốt
      Tôi sẽ không tạo UI native bằng Go, nhưng nó rất tuyệt cho các tác vụ cấp thấp
      TinyGo cũng cho phép viết Go cho vi điều khiển hoặc WebAssembly; nhiều thứ chưa được hỗ trợ, nhưng vẫn dùng được phần đáng kể của thư viện chuẩn
    • Tôi sẽ không gọi Go là ngôn ngữ phía server
      Ví dụ trình biên dịch Go cũng được viết bằng Go
      Nhờ cross-compile và binary tương đối nhỏ, việc triển khai rất dễ
      Tuy nhiên đúng là nó thiếu cú pháp đường, và không hợp lắm với pattern matching theo phong cách hàm
  • Cách tiếp cận parsing này trông hơi lạ, và tôi có ấn tượng rằng tác giả tương đối chưa quen với Rust cũng như các khái niệm ngôn ngữ lập trình làm nền tảng cho nó
    Xem vài điểm thì, AST có lẽ sẽ đơn giản hơn nhiều nếu được định nghĩa bằng kiểu dữ liệu đại số
    Tôi không nghĩ cú pháp sqlite sẽ bỗng mở rộng theo kiểu sinh ra hàng loạt node mới đến mức cần một encoding phức tạp
    Encoding hiện tại trông giống thứ mà một người quen với hướng đối tượng nhưng không quen với kiểu dữ liệu đại số sẽ nghĩ ra
    Câu “macro hoạt động khác nhau trong hầu hết ngôn ngữ, nhưng lý do chính là loại bỏ trùng lặp mã và giảm lặp lại” cũng có thể nói về mọi cơ chế trừu tượng hóa như hàm
    Đặc điểm định nghĩa macro là nó được thực thi ở thời gian biên dịch
    Nếu muốn xem cách cấu trúc parser cho gọn gàng, nghiên cứu về parser combinator có thể là điểm khởi đầu tốt

    • Tác giả chưa từng tuyên bố mình là lập trình viên nhiều kinh nghiệm
      Tiêu đề blog cũng là “Why I love ...”, và bản thân góp ý có vẻ đúng, nhưng chỉ ra sự thiếu kinh nghiệm thì có vẻ không thật sự cần thiết
      Việc ai đó yêu thích lập trình là điều tốt, rồi kinh nghiệm sẽ đến
    • Trong ngữ cảnh bài blog, tác giả muốn tạo ra các định nghĩa struct
      Việc này không thể làm bằng hàm
  • Từ góc nhìn của người từng viết một parser nhỏ [0] cho ký pháp cờ vua Forsyth-Edwards, mình thấy Haskell vượt trội về tính đơn giản và dễ đọc
    Nó đọc gần như BNF và hầu như không có các thủ tục mang tính nghi thức kỹ thuật, nên có thể tập trung vào ngữ pháp thực sự cần parse
    [0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
    [1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...

    • Haskell chắc chắn là tốt nhất ở khía cạnh tận dụng parser combinator, nhưng để xử lý kết quả thì vẫn phải gắn với Haskell
    • Đây chẳng phải là dùng thư viện parser combinator chứ không chỉ Haskell thuần sao
      Mình tò mò liệu có lý do rõ ràng nào khiến Rust không thể dùng cách tiếp cận tương tự không
      Ví dụ winnow [1] có vẻ cung cấp phong cách đủ khai báo, và Rust cũng có nhiều thư viện parser combinator khác
      [1]: https://docs.rs/winnow/latest/winnow/
    • Mình không xem FEN là ví dụ parse hay
      Vì nó có thể được triển khai bằng một hàm đơn giản với một vòng lặp duy nhất
      Vài ngày trước mình đã viết một “parser” FEN cho một triển khai quad-bitboard thử nghiệm, và nó gần như tự viết ra
      Nói thêm, mình là tác giả của chessIO trên Hackage
  • Mình đã viết một disassembler eBPF và một emulator làm dở bằng Rust, và Rust là ngôn ngữ khá dễ chịu cho các công việc kiểu parsing
    Tuy nhiên, việc tác giả cần đến macro khi còn chưa đi qua 1/6 case study dường như làm lập luận của chính họ yếu đi
    Macro không hẳn là sinh mã hoàn chỉnh, nhưng cũng không tạo cảm giác quá mạnh rằng ta đang làm việc theo lối idiomatic trong ngôn ngữ
    Không phải để chê trách; mình nghĩ Rust thực sự khá mạnh ở mảng này

    • Trong Rust có thể định nghĩa ngữ pháp vô hạn như thế nào
      Ví dụ quy tắc phi ngữ cảnh S ::= abc|aabbcc|aaabbbccc|... có thể parse hiệu quả a^Nb^Nc^N, và đây là một ví dụ về ngữ pháp phụ thuộc ngữ cảnh
      Đây là ví dụ đơn giản, nhưng thực tế cũng có những thứ tương tự; một trường hợp là khi ngôn ngữ cho phép định nghĩa toán tử
      Rust xử lý những thứ như vậy ra sao
    • Nếu được thì mong bạn chia sẻ liên kết tới disassembler eBPF
      Nghe có vẻ rất hay
  • Theo kinh nghiệm viết parser và lexer bằng Ragel, rồi dùng Go, Java, C++, C, chỉ cần có một trình tạo mã mẫu ở mức nào đó thì C thuần cũng tốt ngang đoạn mã Rust mà tác giả mô tả
    Thậm chí có thể tốt hơn nhờ tính đơn giản
    Ví dụ phần lớn mã cần cho parser JSON chỉ ở mức này
    https://github.com/gritzko/librdx/blob/master/JSON.lex
    Thực ra eBNF đó chỉ tạo lexer, phần parser cũng không ấn tượng lắm, dài 120 dòng và khá lặp lại
    https://github.com/gritzko/librdx/blob/master/JSON.c
    Cuối cùng, hạ tầng parser sẽ tiến hóa đến điểm chỉ cần eBNF là tạo được parser, và mình nghĩ đó là điểm bão hòa

    • Sự lặp lại đó có thể được xem là nhược điểm chứ không phải ưu điểm
      Mình cảm thấy kiểu dữ liệu đại số của Rust khiến việc xử lý cây cú pháp được sinh ra dễ hơn nhiều
      Tuy vậy, mình đồng ý rằng một chút sinh mã hoặc phép màu macro có thể khiến C dễ dùng hơn đáng kể
    • Mình rất thích Ragel
      Nhưng đoạn mã ở đây
      https://github.com/gritzko/librdx/blob/master/JSON.lex
      chẳng phải sẽ chấp nhận [ là JSON hợp lệ sao
      delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;
      primitive = Number | String | Literal;
      JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;
      Root = JSON;
      Có vẻ như trong JSON có thể chọn chỉ một delimiter, còn mọi thứ khác đều có thể chọn 0 lần
      Thường thì mình bắt đầu bằng cách xem RFC trước
      https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
      Mình cũng không chắc có thể triển khai JSON bằng Ragel hay không
      Theo mình biết, Ragel chỉ xử lý ngôn ngữ chính quy, còn JSON là ngôn ngữ phi ngữ cảnh
    • Nguyên nhân của Cloudbleed là lỗi C/Ragel, và đó cũng là lý do Cloudflare chuyển sang Rust
      https://en.wikipedia.org/wiki/Cloudbleed
  • Liên quan đến chủ đề này, tôi thích bài nói của Rob Pike về quét từ vựng trong Go
    Một cách tiếp cận mang tính giáo dục và tao nhã
    https://www.youtube.com/watch?v=HxaD_trXwRE

    • Bài nói đó rất hay, nhưng tôi nhớ sau này có thấy thảo luận rằng Go thực ra không dùng kỹ thuật đó
      Hình như lý do là overhead lập lịch goroutine hoặc mẫu cấp phát bộ nhớ kém hiệu quả
      Thảo luận hay nhất tôi tìm được là [1]
      Một bài nói xuất sắc khác về việc tạo lexer và parser hiệu quả là “Practical Data Oriented Design” của Andrew Kelley [2]
      Tóm lại, bài này giải thích nhiều chiến lược để tăng throughput bằng cách giảm lượng bộ nhớ chương trình dùng và làm cho nó thân thiện với cache hơn
      1: https://news.ycombinator.com/item?id=31649617
      2: https://www.youtube.com/watch?v=IroPQ150F6c
    • Bài nói đó có vẻ liên quan nhiều hơn đến cách biểu diễn tính đồng thời trong một bài toán nơi tính đồng thời có thể được nghĩ đến một cách tự nhiên, hơn là bản thân việc lexing
  • Với tôi từng có một trải nghiệm đáng ngạc nhiên
    Tôi có thể dùng nguyên thư viện parser combinator vốn dùng cho parser trình biên dịch cấp cao trong môi trường no-std, biên dịch cho vi điều khiển, rồi triển khai thành parser giao thức hiệu năng cao trong môi trường embedded
    Vẫn là dùng nguyên cùng một thư viện
    Khác biệt chỉ ở mức giảm dùng String và dùng &'static str nhiều hơn
    Vì vậy việc nghịch với trình biên dịch chuyển hóa khá tốt thành năng lực xây dựng parser giao thức embedded

  • Điều khó khi viết toàn bộ parser AST bằng Rust là biểu diễn hệ phân cấp của các kiểu AST cụ thể, bao gồm cả upcasting và downcasting
    Tôi đã tìm được cách, nhưng cần những trò kiểu kỳ lạ như PhantomData và macro
    Ở đây hình như cũng cần macro khá quá mức
    Tôi tò mò các công trình trước đó liên quan trông như thế nào

    • Kiểu dữ liệu đại số và cú pháp match của Rust có vẻ sẽ tốt
      Cho đến khi đụng đến upcasting/downcasting
      Tôi chưa đủ kinh nghiệm với Rust nên không biết có cách xử lý tốt hay không
      Dynamic trait có thể khả thi
    • Nếu là mã nguồn mở thì tôi tò mò có repository công khai không
  • Những đoạn mã macro như thế này thì debug ra sao, hoặc người mới vào codebase sẽ hiểu bằng cách nào
    Nhìn vào nơi dùng macro node! và định nghĩa macro, có vẻ khó nắm được thực tế mã nào được sinh ra
    Tôi tò mò liệu có phải chạy ví dụ rồi xem các type hint hiện ra, hover trong IDE có xem được phiên bản đã expand không, hay muốn chắc chắn thì phải tham chiếu đến mã đã biên dịch
    Vì tôi chỉ làm với JS/TS và không đụng đến macro, nên tôi tò mò về workflow kiểu này

    • Chạy $ cargo expand sẽ xem được mã kết quả
      Rust thực ra gần như là nhiều ngôn ngữ: Rust “vanilla”, macro khai báo và macro thủ tục, mỗi thứ có năng lực và phương ngữ hơi khác nhau
      Theo thời gian sẽ quen với cách xử lý từng loại
      Unit test cũng là một không gian thử nghiệm tốt để hiểu tác động của việc sửa macro
    • rust-analyzer, Rust LSP dùng trong VSCode và các môi trường khác, có thể expand đệ quy macro khai báo và macro thủ tục
      Không quá tệ, nhưng trong codebase thì càng ít macro thủ tục càng tốt
      Macro khai báo dễ hiểu hơn một chút, còn bảo trì và kiểm thử thì dễ hơn nhiều
      Tôi cũng có cảm giác tương tự với việc sinh mã thiếu minh bạch trong các ngôn ngữ khác
  • Chúc may mắn với việc parse cú pháp sqlite
    Vài năm trước, trong công việc tôi phải viết parser cho một tập con khá nhỏ của sqlite
    Tôi rất thích sqlite và nó luôn là nguồn cảm hứng
    Sơ đồ đường sắt cực kỳ hữu ích
    https://www.sqlite.org/syntaxdiagrams.html
    Tôi nghĩ trình sinh parser lemon chưa được ghi nhận đúng mức
    https://sqlite.org/src/doc/trunk/doc/lemon.html
    Xét về lựa chọn ngôn ngữ, bất cứ ngôn ngữ nào có kiểu dữ liệu đại số đều rất phù hợp
    Ngay cả TypeScript cũng có thể rất tuyệt cho mục đích này
    Trước đây tôi cũng từng viết một bài nhập môn nhỏ về việc tự viết parser bằng Rust
    https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...