Writing parsers is tricky and cumbersome, and that's even with provided grammars. This post is about approaching parsing from a different angle.
.cabal files, got reverted from the Helix repository as it was still quite a bit WIPThere was a pre-existing grammar by Magnus Therning, and a PR by Ananda Umamil fixing the segfaults, this is what I used for quite some time. Then I wanted cabal.project too, which was enough motivation to do a revamp.
How to sit in trees
Tree-sitter is a parser-generator DSL. You write a grammar in JavaScript, it generates an incremental parser in C, and out comes a concrete syntax tree.readFields :: ByteString -> Either ParseError [Field] -- Cabal parse :: Text -> Tree -- tree-sitter
ERROR or MISSING nodes.library (library
build-depends base >= 4.9 ~> type: (section_type))
^ no colon (ERROR (field_name) (identifier) ...)
match :: Query -> Tree -> [Match]
. in hs-source-dirs: ., for example, can match both a path capture and a plain string capture in a single token.So the whole tree-sitter pipeline decomposes into a total parse, a relational match, and a fold that turns captures into arbitrary behavior, encompassing what users do.Grammar and query, sitting in a tree
A grammar and its corresponding queries, is a point in a solution space of possible useful parsers. On one side we have a parser that parses the empty language, and on the other, all possible strings. The cabal parser we want is somewhere in between those two ends.Tree into some monoid , and expected values over a fixture set , find such thatThe free variable is the pair (grammar, queries), since their definitions are tiedThe expected values are technically also shifting with changing constraints. I can manually keep that in check though via diffs.
. Every time one of the grammar, or query, changes and all constraints are satisfied, we have a valid parser in our hands from the solution space. The next step is choosing the constraints carefully so they precisely correspond to what features we want the parser to have.Picking my tree
check-queries. The first constraint (constraint and tests are interchangeable from here on) checks whether the patterns in every query are compatible with the generated parser. So something like (librari ...) is an invalid node type, as it should be library.parse-corpus. While tree-sitter doesn’t fail on invalid parses, we do know for sure that the parse trees from our tests should contain no error nodes. This forms the second constraint, asserting that no tree has these ERROR or MISSING nodes, across a large corpus.The corpus, I harvested from the cabal and haskell-language-server repositories, which between them carry a rich regression suite of tests including files that should parse successfully. This yielded 968 files, with a median length of 14 lines.extract-golden. What exactly gets captured is quite blackboxey. A golden testsuite pins it down, enforcing the presence of certain captures along with their location and contents, given a small testset. The size here is deliberately small to ensure this is easy to review.highlight-golden. Highlighting was my original use-case, so it’s only appropriate to also capture it as a test. We can enforce that symbols we expect to be highlighted, indeed are. Only one capture wins per token, as ultimately only a single color can be shown. As the query file here is the one that’ll do the actual highlighting job, it’s a better representation than the one used in extract-golden.Searching through the forest
The above constraints make it so any changes to the grammar or queries, can be rendered and diffed. This makes it easy to build into a feedback loop usable by a coding agentCoding agent output is bound to be wrong, some of it is useful though.
. The agent proposes a grammar or query edit, checks all four constraints. Green means done. Red forks and hints at the agent where a regression occurred.Though it is possible to imagine phrasing this as a metric, so it can be made objective. But then again, the choice of metric is probably still going to be subjective.
. That human step, reading the diff and testing out the grammar on a real file, is still necessary. Luckily, diffs are a bit better to review than complex regexes.The result of this experiment istree-sitter-haskell-contrib, which contains grammars for .cabal, cabal.project, and GHC’s Core, STG, and Cmm dumps.Discussion links: Reddit, Lobste.rs