Tree-sitter Modes Still Need a Syntax Table
When you write a tree-sitter major mode, it’s tempting to think the
parser handles everything. It doesn’t. Tree-sitter drives font-lock,
indentation, and structural navigation, but a whole layer of everyday
Emacs behavior still runs on the humble syntax table: forward-sexp
and friends, electric-pair-mode, delete-pair, forward-word, and
anything built on syntax-ppss. (I wrote about that structural layer in
Essential Structured Navigation and Editing Commands.)
I got a sharp reminder of this while building
neocaml, my tree-sitter mode for
OCaml. The grammar parsed everything beautifully, yet delete-pair and
sexp motion kept misbehaving around a couple of OCaml constructs. The
fix turned out to be a classic tool that long predates tree-sitter:
syntax-propertize-function. Here’s the story - it tripped me up for a
good while, so maybe it’ll spare you some head-scratching.
The problem: characters a static syntax table can’t classify
A syntax table assigns each character a single class: this one opens a string, that one is a comment starter, this is a word constituent. That works until a character means different things in different places. OCaml has two such troublemakers.
Character literals. 'a' is a character, and the single quotes
delimit it. But the same ' also starts a type variable ('a in
'a list), and can appear inside identifiers as a prime (x'). So you
can’t just declare ' a string delimiter. And if you leave it as a
symbol constituent (the usual choice), a character literal whose contents
happen to be a delimiter wreaks havoc:
let x = '"' (* the " opens a string, as far as the syntax table knows *)
let y = '(' (* the ( is an unbalanced open paren *)
Quoted string literals. OCaml’s {|...|} (and tagged {foo|...|foo})
raw strings can contain anything - including ", (*, and friends -
which the syntax table reads as real string/comment delimiters:
let z = {|a "b" (* not a comment *)|}
You can see the damage with syntax-ppss. With nothing but a static
table, point at the end of let x = '"' reports that you’re inside a
string:
(nth 3 (syntax-ppss)) ;; => 34 (i.e. inside a string opened by ")
And every command that consults the syntax table inherits the confusion:
C-M-f walks off into nonsense, delete-pair grabs the wrong delimiter,
electric-pair-mode autopairs incorrectly.
Note that font-lock looks fine here - tree-sitter fontifies these constructs correctly from the parse tree. That’s exactly what makes the bug sneaky: the buffer looks right, but the syntactic layer underneath is lying.
The fix: context-sensitive syntax with syntax-propertize
The escape hatch is
syntax-propertize-function:
a buffer-local function that runs lazily over regions of the buffer and
applies syntax-table text properties to override the static table
where context demands it. Because the properties are attached to specific
positions, ' can be a string delimiter in 'a' and an ordinary symbol
character in 'a list - in the same buffer.
The usual way to write one is with syntax-propertize-rules, which maps
regexps to the syntax classes to apply to their capture groups. Here’s
the heart of neocaml’s:
(defun neocaml--syntax-propertize (start end)
(goto-char start)
(funcall
(syntax-propertize-rules
;; Character literals: 'a', '\n', '"', '(', ...
;; Mark both quotes as string delimiters so the contents are inert.
;; A closing quote is required, so type variables ('a) are untouched.
("\\_<\\('\\)\\(?:[^'\\\n]\\|\\\\.[^\\'\n \")]*\\)\\('\\)"
(1 "\"") (2 "\""))
;; Quoted strings {tag|...|tag}: fence both ends so the body is inert.
("\\({\\)\\([[:lower:]_]*\\)|"
(1 (let* ((tag (match-string 2))
(close (save-excursion
(when (search-forward (concat "|" tag "}") end t)
(point)))))
(when close
(put-text-property (1- close) close
'syntax-table (string-to-syntax "|")))
(string-to-syntax "|")))))
(point) end))
and you install it in the mode body:
(setq-local syntax-propertize-function #'neocaml--syntax-propertize)
Two kinds of rules are at work:
- The character literal rule matches a complete
'...'and gives both quotes string-quote syntax ("). The contents then sit inside a string as far as the scanner is concerned, so an inner"or(is inert. Crucially, the regexp requires a closing quote, so a bare'atype variable never matches and keeps its symbol syntax. - The quoted string rule matches the opening
{tag|, searches for the matching|tag}, and marks both the opening and closing delimiters with generic string fence syntax (|). Everything between two fences is a string, so embedded quotes and comment starters are neutralized.
With that in place, syntax-ppss tells the truth again, and C-M-f,
delete-pair, and electric-pair-mode all behave - including for the
delete-pair corner case I wrote about earlier.
Lessons for mode writers
A few things worth keeping in mind, whether or not you touch OCaml:
- Tree-sitter and the syntax table are different layers. The parser handles font-lock and structural queries, while the syntax table handles character-level motion and pairing. A great grammar doesn’t save you from getting the syntax table right. (See also Customizing Font-Lock in the Age of Tree-sitter.)
- Reach for
syntax-propertizewhenever a character’s role depends on context - raw strings, here-docs, regex literals, character literals, JSX, anything a single syntax class can’t capture. It’s exactly whatrust-ts-modeandc-ts-modereach for, too. - The syntax-table text properties don’t affect tree-sitter font-lock. They live on a separate channel, so you can fix the syntactic layer without disturbing your carefully tuned highlighting.
- Don’t call
syntax-ppssfrom inside yoursyntax-propertize-function. It’s re-entrant (syntax-propertizeis itself driven bysyntax-ppss) and a fragile source of subtle bugs. Match constructs whole from their opening delimiter instead, as the quoted-string rule above does, rather than asking “am I currently inside a string?”. - Know your string classes. Class
"(string quote) is delimited by the same character, while class|(generic string fence) pairs up independently of the specific character. The latter is handy for multi-character or asymmetric delimiters like{tag|…|tag}.
None of this is new - syntax-propertize has been the right answer since
Emacs 24. But it’s easy to forget it exists when you’re deep in
tree-sitter land, and the symptoms (movement and pairing going subtly
wrong while the colors look perfect) are puzzling until you remember which
layer owns what.
Have you run into this while writing your own tree-sitter modes? I’d love to hear how you tamed the syntax table in the comments.
That’s all I have for you today. Keep those parens balanced!