This blogpost is written based on a presentation I gave, of which the slides can be found here.I use helix as my editor of choice. It uses tree-sitter as the mechanism for highlighting text. I really like Haskell. Unfortunately, the tree-sitter grammar for .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
The first nice thing about tree-sitter parsers is that they’re total. They don’t fail if there were parsing errors. Instead it decorates the parse tree with ERROR or MISSING nodes.library (library
build-depends base >= 4.9 ~> type: (section_type))
^ no colon (ERROR (field_name) (identifier) ...)
The second thing that’s quite nice is how easy it is to attach semantics onto these parse trees. This is done through tree-sitter queries that pattern match against the parse tree to annotate and capture specific nodes. These captures can then be used by downstream consumers to fuel some feature, like isolating all symbols in a file for go-to-symbol-like functionality.match :: Query -> Tree -> [Match]
This matching of queries and trees form a relation. One pattern can capture in many places, and one node can be involved in different query captures. The . 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.
The pipeline.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.
The solution space.I can phrase this as a search. Given constraints C1,…,Cn, each a fold from Tree into some monoid Mi, and expected values ei over a fixture set X, find g such thatCi(parsegx)=ei(x)∀i,∀x∈X.The free variable g is the pair (grammar, queries), since their definitions are tiedThe expected values ei 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.
The feedback loop.This still requires intervention. There are an infinite number of valid parsers that pass all of the tests, yet isn’t the parser or query setup I’m looking for. The ideal setup is subjectiveThough 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 is tree-sitter-haskell-contrib, which contains grammars for .cabal, cabal.project, and GHC’s Core, STG, and Cmm dumps.Discussion links: Reddit, Lobste.rs