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, since it’s useful
knowledge for anyone writing a mode.
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 internalizing, whether or not you touch OCaml:
- Tree-sitter and the syntax table are different layers. The parser handles font-lock and structural queries; 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-propertizewhen a character’s role is context-dependent. Raw strings, here-docs, regex literals, character literals, JSX – anything a single syntax class can’t capture. This is exactly whatrust-ts-modeandc-ts-modeuse it for, too. - The syntax-table text properties don’t affect tree-sitter font-lock. They’re 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; 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.