Fixing typescript-ts-mode in Emacs 30.2
Contents
The Symptom
After a recent Arch update, my Emacs 30.2 + typescript-ts-mode combination
started dying the first time I opened a .ts or .tsx file:
Error: treesit-query-error ("Invalid predicate" "match")
The file would still display, but without any syntax highlighting.
python-ts-mode exhibited the same failure. js-ts-mode and c-ts-mode worked
in the main buffer but had their own breakages around JSDoc ranges and C’s
emacs-specific range queries.
The Root Cause
This is Emacs bug#79687↗, an interaction between how Emacs 30.2 serializes tree-sitter query predicates and what libtree-sitter 0.26 (the version shipped by Arch) accepts.
Tree-sitter queries can embed predicates like (:match "^foo" @name) to filter
captures at query-evaluation time. Emacs 30.2 serializes these s-expression
predicates to strings that look like #match (no trailing ?), but
libtree-sitter 0.26 became strict about predicate naming and rejects unknown
names at query-parse time. The fix on Emacs master (commit b0143530)
switches serialization to #match?, which libtree-sitter accepts. That fix has
not been backported to the emacs-30 branch as of 30.2.
Rewriting the strings yourself doesn’t help either, because Emacs 30.2’s own
predicate dispatcher hardcodes bare match/equal/pred and rejects
match?/equal?/pred? at evaluation time. So any rewrite that satisfies
libtree-sitter breaks Emacs, and vice versa.
The Approach
Since neither side accepts a string-level rewrite, I work at a higher level instead: strip the predicates entirely from queries, and move the predicate logic into capture-name-is-a-function fontifiers.
A tree-sitter font-lock rule like:
((identifier) @font-lock-keyword-face
(:match "\\`\\(break\\|continue\\)\\'" @font-lock-keyword-face))
gets rewritten to:
((identifier) @my-ts-rw--fn-font-lock-keyword-face-abc12345)
where the auto-generated function my-ts-rw--fn-font-lock-keyword-face-abc12345
applies font-lock-keyword-face to the node only if the node’s text matches the
original regex. The resulting query contains no predicates, so libtree-sitter is
happy; the fontifier applies the face only when the original predicate would
have matched, so the semantics are preserved.
The rewrite happens via :filter-args advice on three Emacs functions:
treesit-font-lock-rulesis the main call path for font-lock rules and covers nearly all modes.treesit-range-rulesis used byjs-ts-mode(and others) to embed a JSDoc parser inside comment nodes.treesit-query-compilecatches modes likec-ts-modethat compile queries directly with an s-expression containing:match.
How to Use It
The workaround lives in a single file in my emacs-shared↗ repo: init/treesit-predicate-rewrite.el↗.
Drop the file somewhere on your load path and load it early, before any tree-sitter mode runs its font-lock setup:
(load "/path/to/treesit-predicate-rewrite" nil nil nil t)
It self-activates via define-advice, so there’s no setup call to make. The
advice is a no-op on queries that don’t contain predicates, so it’s safe to
leave on even after the bug is fixed upstream.
Caveats
The rewriter handles three cases:
- Predicate targets a face capture. Rewrites into a fontifier as shown
above. This applies to the vast majority of uses in
typescript-ts-mode,python-ts-mode, and friends. - An outer group wraps an inner scratch capture, a pattern used by
ruby-ts-modewhere the face lives on the outer group and the predicate tests a scratch capture inside. Flattened and then handled as case 1. - Predicate targets a non-face capture. The predicate is silently stripped,
which means the fontifier will over-match.
elixir-ts-modeuses this pattern heavily. In practice the visual regression is minor, but if it bothers you, setmy-ts-rw-verbosetotto log strips.
:equal predicates are handled for cases 1 and 2. :pred falls back to strip
(case 3) since replicating an arbitrary user function inside a fontifier is more
trouble than it’s worth.
I’ve verified the fix on typescript-ts-mode, tsx-ts-mode, python-ts-mode,
js-ts-mode, c-ts-mode, rust-ts-mode, java-ts-mode, go-ts-mode, and
lua-ts-mode. All load and fontify without errors.
Removal Plan
Once I upgrade to an Emacs that carries the bug#79687 fix (Emacs 31, or a
backport into a future 30.x), I’ll delete the file and the load line. Until
then, it’s one file and one load line, so the maintenance cost is low.