Fixing typescript-ts-mode in Emacs 30.2

Published:
Keywords: emacs

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-rules is the main call path for font-lock rules and covers nearly all modes.
  • treesit-range-rules is used by js-ts-mode (and others) to embed a JSDoc parser inside comment nodes.
  • treesit-query-compile catches modes like c-ts-mode that 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:

  1. 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.
  2. An outer group wraps an inner scratch capture, a pattern used by ruby-ts-mode where the face lives on the outer group and the predicate tests a scratch capture inside. Flattened and then handled as case 1.
  3. Predicate targets a non-face capture. The predicate is silently stripped, which means the fontifier will over-match. elixir-ts-mode uses this pattern heavily. In practice the visual regression is minor, but if it bothers you, set my-ts-rw-verbose to t to 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.