summaryrefslogtreecommitdiff
path: root/emacs/scripts/treesit-predicate-rewrite.el
blob: 0840f62d79d766090229bf929591eb5b59f0e0e3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
;;; treesit-predicate-rewrite.el --- -*- lexical-binding: t -*-
;;
;; Description: Generic workaround for Emacs bug#79687 -- strip
;;  tree-sitter query predicates at `treesit-font-lock-rules' call
;;  time, replacing them with capture-name-is-a-function fontifiers.
;; Author: Michael Olson
;;
;; The contents of this file may be used, distributed, and modified
;; without restriction.

;;; Commentary:

;; Emacs 30.2 serialises `:match' / `:equal' / `:pred' s-expr
;; predicates to `#match' / `#equal' / `#pred' (no trailing `?'), and
;; libtree-sitter >=0.26 rejects those at query-parse time. The
;; upstream fix (commit b0143530 on master) is slated for Emacs 31 and
;; has not been backported to emacs-30 as of 30.2. On Arch we ship
;; libtree-sitter.so.0.26, so every mode whose `treesit-font-lock-rules'
;; uses `:match' fails the first time it hits tree-sitter. `#match?'
;; doesn't work either: Emacs 30.2's own predicate dispatcher hard-codes
;; bare `match'/`equal'/`pred' and rejects `match?'/`equal?'/`pred?' at
;; eval time, so no string-level rewrite can satisfy both sides.
;;
;; Instead, intercept `treesit-font-lock-rules' via `:filter-args'
;; advice and rewrite its argument plist, replacing predicate-gated
;; patterns with capture-name-is-a-function fontifiers. The resulting
;; query contains NO predicates, so libtree-sitter is happy; the
;; fontifier applies the face only when the node satisfies the
;; original predicate, so the semantics are preserved.
;;
;; `treesit-range-rules' (used by e.g. js.el for JSDoc parser embed)
;; gets the same advice, but range captures are scratch names rather
;; than faces, so predicates there always strip cleanly (Case C). The
;; downside is slight over-matching (e.g. JSDoc embed attaches to all
;; comments, not just `/**' ones); benign in practice.
;;
;; Handled cases (in order of detection):
;;
;;   A. ((NODE) @FACE (:match REGEX @FACE))
;;      -> ((NODE) @my-ts-rw--fn-FACE-HASH), and the fontifier applies
;;      FACE when the node text matches REGEX. Also handles nested
;;      captures like ((X Y: (Z) @FACE) (:match ... @FACE)).
;;
;;   B. (((NODE) @scratch (:match REGEX @scratch)) @FACE)
;;      Outer-group-wraps-inner-scratch form (used by ruby-ts-mode
;;      builtins). Semantically equivalent to Case A after flattening,
;;      handled the same way.
;;
;;   C. Predicate where target capture is not the face capture, e.g.,
;;      elixir-ts-mode's (call (identifier) @_fn (:match "assert" @_fn)
;;      arguments: ...). Cannot be flattened into a single fontifier
;;      safely; the predicate is silently stripped (set
;;      `my-ts-rw-verbose' non-nil to log strips). This will
;;      over-fontify, which is a minor visual regression. elixir-ts-mode
;;      uses this pattern heavily and is best left alone -- callers
;;      should avoid loading it with this advice active, OR accept
;;      the over-fontification.
;;
;; :equal (@cap "literal") is handled for Case A/B; :pred is not
;; (would require re-invoking the user's fn) and is treated as Case C.
;;
;; Removal plan: delete this whole file and its `load' in
;; `shared-init.el' once we upgrade to an Emacs that carries the
;; bug#79687 fix (Emacs 31+, or a backport landing in a future 30.x
;; point release).

;;; Code:

(require 'cl-lib)
(require 'seq)

(defvar my-ts-rw--fontifiers (make-hash-table :test 'equal)
  "Cache: (KIND FACE . DATA) -> interned fontifier symbol.
KIND is `regex' or `equal'. DATA is the regex string or literal.
Prevents symbol churn when the same rewrite is computed repeatedly
across reloads.")

(defvar my-ts-rw-verbose nil
  "When non-nil, log each predicate rewrite or strip via `message'.
Off by default so Case C over-fontification notes don't spam the
echo area at mode-load time.")

;;;; Entry points: :filter-args advice on query-building functions

(define-advice treesit-font-lock-rules
    (:filter-args (args) my-ts-rw-rewrite)
  "Rewrite tree-sitter predicates into fontifier captures.
Workaround for Emacs bug#79687 / libtree-sitter 0.26 strict predicates."
  (condition-case err
      (my-ts-rw--rewrite-args args)
    (error
     (message "treesit-predicate-rewrite: rewrite failed, passing through: %S" err)
     args)))

(define-advice treesit-range-rules
    (:filter-args (args) my-ts-rw-rewrite)
  "Strip tree-sitter predicates from range rules.
Range-rule captures are scratch names, not faces, so predicates
always fall into Case C (strip). The net effect for e.g. js.el's
JSDoc-begin predicate is that the embedded parser attaches to all
comments rather than just `/**' comments, which is harmless but
may cost a little extra parser work on non-doc comments."
  (condition-case err
      (my-ts-rw--rewrite-args args)
    (error
     (message "treesit-predicate-rewrite: range-rules rewrite failed, passing through: %S" err)
     args)))

(define-advice treesit-query-compile
    (:filter-args (args) my-ts-rw-rewrite)
  "Strip/rewrite predicates from queries compiled directly.
Catches call sites that bypass `treesit-font-lock-rules' and
`treesit-range-rules' -- e.g. c-ts-mode's `c-ts-mode--emacs-c-range-query'
defvar which calls `treesit-query-compile' with an s-expression
containing `:match'. Predicates on face targets become fontifiers;
others are stripped."
  (condition-case err
      (pcase args
        (`(,lang ,query . ,rest)
         (cons lang
               (cons (if (listp query)
                         (my-ts-rw--rewrite-query-list query)
                       query)
                     rest)))
        (_ args))
    (error
     (message "treesit-predicate-rewrite: query-compile rewrite failed, passing through: %S" err)
     args)))

;;;; Arg-plist walker

(defun my-ts-rw--rewrite-args (args)
  "Walk ARGS (the plist-style argument list of `treesit-font-lock-rules').
Rewrite every query-list value; leave :KEYWORD/VALUE pairs as-is."
  (let ((result nil)
        (tail args))
    (while tail
      (let ((item (car tail)))
        (cond
         ((keywordp item)
          (push item result)
          (push (cadr tail) result)
          (setq tail (cddr tail)))
         (t
          (push (my-ts-rw--rewrite-query-list item) result)
          (setq tail (cdr tail))))))
    (nreverse result)))

(defun my-ts-rw--rewrite-query-list (queries)
  "Rewrite each top-level query in QUERIES.
QUERIES is a list of queries, or an already-compiled query object /
string; non-list inputs are returned unchanged."
  (if (and (listp queries) (not (null queries)))
      (mapcar #'my-ts-rw--walk queries)
    queries))

;;;; Per-query recursive walker

(defun my-ts-rw--walk (form)
  "Rewrite predicates within FORM recursively."
  (cond
   ((not (consp form)) form)
   ;; Don't descend into a predicate form itself -- its children
   ;; shouldn't be interpreted as predicate-containing queries.
   ((my-ts-rw--predicate-p form) form)
   (t
    ;; Case B must be detected at this level BEFORE recursing into
    ;; children; otherwise the bottom-up recursion would strip the
    ;; inner predicate under Case C and we'd over-fontify.
    (let ((b (my-ts-rw--try-case-b form)))
      (if b
          (my-ts-rw--walk b)
        ;; Bottom-up: recurse children first, then handle Case A at
        ;; this level so inner predicates are resolved before outer.
        (let ((children (mapcar #'my-ts-rw--walk form)))
          (my-ts-rw--rewrite-case-a children)))))))

;;;; Case B: (((NODE) @scratch (:match REGEX @scratch)) @FACE)

(defun my-ts-rw--try-case-b (form)
  "If FORM matches Case B, return the pre-rewrite flattened form.
Otherwise return nil. The returned form still has the inner
predicate in place; Case A handling will lift it."
  (when (and (consp form)
             (= (length form) 2)
             (consp (car form))
             (my-ts-rw--face-symbol-p (cadr form)))
    (let* ((group (car form))
           (outer-face-sym (cadr form))
           (predicates (seq-filter #'my-ts-rw--predicate-p group)))
      (when (= (length predicates) 1)
        (let* ((pred (car predicates))
               (target (my-ts-rw--predicate-target pred)))
          (when (and target
                     (my-ts-rw--cap-symbol-p target)
                     ;; Case B is only safe if the inner scratch capture
                     ;; tags the same node the outer face wraps -- if
                     ;; target appears as a direct capture marker inside
                     ;; group (not nested inside a named-child pattern),
                     ;; flattening is semantically sound.
                     (my-ts-rw--cap-directly-on-root-of-p group target)
                     ;; Avoid double-rewriting if target already is a face
                     ;; (that's Case A in disguise, handled below).
                     (not (my-ts-rw--face-symbol-p target)))
            ;; Substitute the inner scratch capture with the outer
            ;; face symbol and drop the outer wrapping. Case A will
            ;; then recognise the predicate + face pairing.
            (my-ts-rw--replace-capture-in-tree
             group target outer-face-sym)))))))

(defun my-ts-rw--cap-directly-on-root-of-p (group cap-sym)
  "Non-nil if CAP-SYM appears as a top-level capture marker in GROUP.
Specifically, CAP-SYM is a direct element of GROUP that follows the
node pattern (i.e., `(NODE CAP-SYM ...)'). This is the signature of
the inner scratch in Case B."
  (and (consp group)
       (memq cap-sym group)
       t))

;;;; Case A: ((...@FACE...) (:match REGEX @FACE))

(defun my-ts-rw--rewrite-case-a (children)
  "Process top-level predicates among CHILDREN.
Returns the rewritten list with predicates stripped and capture
symbols rewritten to fontifier symbols where applicable."
  (if (not (cl-some #'my-ts-rw--predicate-p children))
      children
    (let ((result children))
      (dolist (pred (seq-filter #'my-ts-rw--predicate-p children))
        (let ((target (my-ts-rw--predicate-target pred)))
          (cond
           ((and target (my-ts-rw--face-symbol-p target))
            (let* ((face (my-ts-rw--cap-to-face target))
                   (fontifier (my-ts-rw--try-make-fontifier pred face)))
              (if fontifier
                  (setq result
                        (my-ts-rw--replace-capture-in-tree
                         result target (my-ts-rw--face-to-cap fontifier)))
                (when my-ts-rw-verbose
                  (message "treesit-predicate-rewrite: unsupported predicate %S (face target %s); stripping"
                           (car pred) target)))))
           (t
            (when my-ts-rw-verbose
              (message "treesit-predicate-rewrite: stripping non-face-target predicate %S"
                       pred))))))
      (seq-remove #'my-ts-rw--predicate-p result))))

;;;; Fontifier factory

(defun my-ts-rw--try-make-fontifier (predicate face)
  "Build a fontifier applying FACE when PREDICATE is satisfied.
Return the fontifier symbol, or nil if PREDICATE isn't supported."
  (pcase predicate
    (`(:match ,(and regex (pred stringp)) ,(pred my-ts-rw--cap-symbol-p))
     (my-ts-rw--intern-fontifier
      (list 'regex face regex)
      (lambda (node override start end &rest _)
        (when (string-match-p regex (treesit-node-text node t))
          (treesit-fontify-with-override
           (treesit-node-start node) (treesit-node-end node)
           face override start end)))))
    (`(:equal ,a ,b)
     (let ((literal (cond ((and (stringp a) (my-ts-rw--cap-symbol-p b)) a)
                          ((and (my-ts-rw--cap-symbol-p a) (stringp b)) b))))
       (when literal
         (my-ts-rw--intern-fontifier
          (list 'equal face literal)
          (lambda (node override start end &rest _)
            (when (equal (treesit-node-text node t) literal)
              (treesit-fontify-with-override
               (treesit-node-start node) (treesit-node-end node)
               face override start end)))))))
    (_ nil)))

(defun my-ts-rw--intern-fontifier (key fn)
  "Return a fontifier symbol for KEY, defining it to FN on first sight.
Subsequent calls with an `equal' KEY return the cached symbol and do
not redefine it, so reloading this file does not leak symbols."
  (or (gethash key my-ts-rw--fontifiers)
      (let* ((face (cadr key))
             (sym (intern (format "my-ts-rw--fn-%s-%08x"
                                  face (abs (sxhash-equal key))))))
        (defalias sym fn)
        (puthash key sym my-ts-rw--fontifiers)
        sym)))

;;;; Predicate / capture helpers

(defun my-ts-rw--predicate-p (form)
  "Non-nil if FORM is a tree-sitter predicate form."
  (and (consp form)
       (memq (car form) '(:match :equal :pred))))

(defun my-ts-rw--predicate-target (predicate)
  "Return the capture symbol PREDICATE tests, or nil."
  (pcase (car predicate)
    (:match
     (let ((tail (car (last predicate))))
       (and (my-ts-rw--cap-symbol-p tail) tail)))
    (:equal
     (cl-loop for arg in (cdr predicate)
              when (my-ts-rw--cap-symbol-p arg) return arg))
    (:pred
     (cl-loop for arg in (cddr predicate)
              when (my-ts-rw--cap-symbol-p arg) return arg))))

(defun my-ts-rw--cap-symbol-p (x)
  "Non-nil if X is a tree-sitter capture symbol (starts with `@')."
  (and (symbolp x)
       (let ((name (symbol-name x)))
         (and (> (length name) 1) (eq (aref name 0) ?@)))))

(defun my-ts-rw--face-symbol-p (cap-sym)
  "Non-nil if CAP-SYM names a font-lock face.
Accepts any symbol starting with `@' whose trailing name is either
a defined face or begins with `font-lock-'."
  (and (my-ts-rw--cap-symbol-p cap-sym)
       (let ((stripped (substring (symbol-name cap-sym) 1)))
         (or (facep (intern-soft stripped))
             (string-prefix-p "font-lock-" stripped)))))

(defun my-ts-rw--cap-to-face (cap-sym)
  "Return the face symbol underlying capture symbol CAP-SYM."
  (intern (substring (symbol-name cap-sym) 1)))

(defun my-ts-rw--face-to-cap (face-sym)
  "Return the capture symbol (`@face-sym') for FACE-SYM."
  (intern (format "@%s" face-sym)))

;;;; Tree substitution

(defun my-ts-rw--replace-capture-in-tree (tree from to)
  "Recursively replace occurrences of FROM with TO in TREE."
  (cond
   ((eq tree from) to)
   ((consp tree)
    (cons (my-ts-rw--replace-capture-in-tree (car tree) from to)
          (my-ts-rw--replace-capture-in-tree (cdr tree) from to)))
   (t tree)))

(provide 'treesit-predicate-rewrite)
;;; treesit-predicate-rewrite.el ends here