summaryrefslogtreecommitdiff
path: root/lisp/citre-backend.el
blob: 9169d3123495396324a2ac356823a8a4e150319c (plain) (blame)
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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
;;; citre-backend.el --- Live and persistent tags through Citre -*- lexical-binding: t; -*-

(require 'citre)
(require 'citre-tags)
(require 'citre-index)
(require 'subr-x)

(defcustom my/citre-completion-deduplication 'name
  "How Citre completion candidates are deduplicated.
The value `name' keeps one ranked candidate for each insertion text.  Nil keeps every tag returned by Citre."
  :type '(choice (const :tag "One candidate per name" name)
                 (const :tag "Keep every tag" nil))
  :group 'my/citre)

(defcustom my/citre-completion-context-filter 'safe
  "How receiver context affects Citre completions.
The value `safe' filters by an explicitly declared receiver type only when at least one returned tag matches it.  Nil only uses context for ranking."
  :type '(choice (const :tag "Filter only verified receiver types" safe)
                 (const :tag "Only rank by context" nil))
  :group 'my/citre)

(defcustom my/citre-completion-source-order
  '(current-buffer live-buffer project dependency tags-file)
  "Preferred source order for Citre completion candidates."
  :type '(repeat (choice (const current-buffer) (const live-buffer)
                         (const project) (const dependency) (const tags-file)))
  :group 'my/citre)

(defcustom my/citre-completion-annotation '(scope source count)
  "Components shown after a Citre completion candidate."
  :type '(set (const scope) (const source) (const count))
  :group 'my/citre)

(defcustom my/citre-reference-program "rg"
  "Program used to search references.
It must accept ripgrep's options and honour the project's ignore files."
  :type 'string
  :group 'my/citre)

(defcustom my/citre-completion-type-resolvers
  '(my/citre-backend--resolve-go-type
    my/citre-backend--resolve-rust-or-python-type
    my/citre-backend--resolve-c-type)
  "Functions used in order to resolve an explicitly declared receiver type.
Each function receives a receiver name and the beginning of its member expression and returns a type name or nil."
  :type 'hook
  :group 'my/citre)

(defvar company--capf-cache)
(defvar-local my/citre-backend--completion-cache nil)

;; Retire the popup restart timer when reloading an earlier version of this module.
(when (boundp 'my/citre-backend--company-timer)
  (my/citre-index--cancel-timer (symbol-value 'my/citre-backend--company-timer))
  (set 'my/citre-backend--company-timer nil))

(defun my/citre-backend--sources ()
  "Return tag source plists in priority order."
  (if-let* ((state my/citre-index--state))
      (let* ((entries (my/citre-index-live-entries state))
             (current (my/citre-index--path))
             (live (sort entries (lambda (a b) (and (equal (car a) current) (not (equal (car b) current)))))))
        (append
         (mapcar (lambda (entry)
                   (list :file (plist-get (cdr entry) :file)
                         :live-source (car entry)
                         :source (if (equal (car entry) current) 'current-buffer 'live-buffer)))
                 live)
         (let ((project (expand-file-name "project.tags" (my/citre-index-state-cache state))))
           (if (file-readable-p project)
               (list (list :file project :source 'project))
             (when-let* ((legacy (citre-tags-file-path)))
               (list (list :file legacy :source 'tags-file)))))
         (let ((dependencies (expand-file-name "dependencies.tags" (my/citre-index-state-cache state))))
           (when (file-readable-p dependencies)
             (list (list :file dependencies :source 'dependency))))))
    (when-let* ((file (citre-tags-file-path)))
      (list (list :file file :source 'tags-file)))))

(defun my/citre-backend--source-rank (source)
  "Return the configured rank of SOURCE."
  (or (seq-position my/citre-completion-source-order source) most-positive-fixnum))

(defun my/citre-backend--query (operation &optional identifier)
  "Merge tags for OPERATION, optionally using IDENTIFIER, while masking superseded files."
  (let* ((sources (my/citre-backend--sources))
         (masked (delq nil (mapcar (lambda (source) (plist-get source :live-source)) sources)))
         (seen (make-hash-table :test #'equal))
         result)
    (dolist (source sources)
      (let* ((file (plist-get source :file))
             (symbol (or (and identifier
                              (citre-put-property (copy-sequence identifier)
                                                  'file-path buffer-file-name 'tags-file file))
                         (and (not (memq operation '(identifiers imenu)))
                              (citre-tags-get-symbol file))))
             (filter
              (pcase operation
                ('completion (when symbol (or (citre-tags--get-value-in-language-alist :completion-filter symbol)
                                              (citre-tags-completion-default-filter symbol))))
                ('definition (when symbol (or (citre-tags--get-value-in-language-alist :definition-filter symbol)
                                              (citre-tags-definition-default-filter symbol))))
                ('imenu (list 'and (citre-readtags-filter-input (my/citre-index--path) file)
                              (list 'not citre-tags-filter-file-tags)))
                (_ (list 'not citre-tags-filter-file-tags))))
             (sorter
              (pcase operation
                ('completion (when symbol (or (citre-tags--get-value-in-language-alist :completion-sorter symbol)
                                              citre-tags-completion-default-sorter)))
                ('imenu (citre-readtags-sorter 'line))
                (_ citre-tags-definition-default-sorter)))
             (tags (when (or symbol (memq operation '(identifiers imenu)))
                     (citre-tags-get-tags
                      file symbol (if (eq operation 'completion) (if citre-tags-substr-completion 'substr 'prefix) 'exact)
                      :filter filter :sorter sorter
                      :require '(name ext-abspath)
                      :optional '(pattern line end ext-kind-full signature scope typeref extras language)))))
        (dolist (tag tags)
          (let* ((path (citre-get-tag-field 'ext-abspath tag))
                 (key (mapcar (lambda (field) (citre-get-tag-field field tag))
                              '(name ext-abspath line scope ext-kind-full))))
            (unless (or (and (not (plist-get source :live-source)) (member path masked))
                        (gethash key seen))
              (puthash key t seen)
              (when (eq operation 'completion)
                (setq tag (copy-hash-table tag))
                (citre-set-tag-field 'my-source (plist-get source :source) tag)
                (citre-set-tag-field 'my-order (length result) tag))
              (push tag result))))))
    (nreverse result)))

(defun my/citre-backend--member-context (beginning)
  "Return the member-access context immediately before BEGINNING."
  (save-excursion
    (goto-char beginning)
    (skip-chars-backward " \t")
    (let ((end (point)))
      (when (and (re-search-backward
                  "\\_<\\([[:alpha:]_][[:alnum:]_]*\\)\\_>[ \t]*\\(\\.\\|->\\|::\\)[ \t]*"
                  (line-beginning-position) t)
                 (= (match-end 0) end))
        (let* ((receiver (match-string-no-properties 1))
               (operator (match-string-no-properties 2))
               (type (run-hook-with-args-until-success
                      'my/citre-completion-type-resolvers receiver beginning)))
          (list :receiver receiver :operator operator :type type))))))

(defun my/citre-backend--defun-start ()
  "Return the beginning of the enclosing defun without moving point."
  (save-excursion
    (condition-case nil
        (progn (beginning-of-defun) (point))
      (error (point-min)))))

(defun my/citre-backend--unique-match (regexp text)
  "Return the sole normalized type captured by REGEXP in TEXT."
  (let (matches)
    (with-temp-buffer
      (insert text)
      (goto-char (point-min))
      (while (re-search-forward regexp nil t)
        (push (my/citre-backend--normalize-type (match-string-no-properties 1)) matches)))
    (car (when (= (length (delete-dups (delq nil matches))) 1) matches))))

(defun my/citre-backend--normalize-type (type)
  "Return a comparable nominal name for TYPE."
  (when type
    (let ((value (string-trim type)))
      (setq value (replace-regexp-in-string "\\_<\\(?:const\\|volatile\\|mut\\|struct\\|class\\|enum\\|dyn\\)\\_>" "" value)
            value (replace-regexp-in-string "[&*[:space:]]" "" value)
            value (replace-regexp-in-string "<.*>\\'" "" value)
            value (replace-regexp-in-string "::" "." value))
      (unless (string-empty-p value) value))))

(defun my/citre-backend--context-text (beginning)
  "Return text from the enclosing defun through BEGINNING."
  (buffer-substring-no-properties (my/citre-backend--defun-start) beginning))

(defun my/citre-backend--resolve-go-type (receiver beginning)
  "Resolve RECEIVER from an explicit Go declaration before BEGINNING."
  (when (derived-mode-p 'go-mode 'go-ts-mode)
    (let* ((text (my/citre-backend--context-text beginning))
           (name (regexp-quote receiver))
           (regexp (format "\\(?:\\_<var[ \t]+%s\\_>\\|[,( \t\n]%s\\_>\\)[ \t]+\\(\\*?[[:alpha:]_][[:alnum:]_./]*\\)" name name)))
      (my/citre-backend--unique-match regexp text))))

(defun my/citre-backend--resolve-rust-or-python-type (receiver beginning)
  "Resolve an explicitly annotated Rust or Python RECEIVER before BEGINNING."
  (when (or (derived-mode-p 'rust-mode 'rust-ts-mode)
            (derived-mode-p 'python-mode 'python-ts-mode))
    (let* ((text (my/citre-backend--context-text beginning))
           (regexp (format "\\_<%s\\_>[ \t]*:[ \t]*\\(?:&[ \t]*\\)?\\(?:mut[ \t]+\\)?\\([[:alpha:]_][[:alnum:]_.:]*\\(?:<[^>\n]+>\\)?\\)"
                           (regexp-quote receiver))))
      (my/citre-backend--unique-match regexp text))))

(defun my/citre-backend--resolve-c-type (receiver beginning)
  "Resolve RECEIVER from a simple explicit C or C++ declaration."
  (when (derived-mode-p 'c-mode 'c-ts-mode 'c++-mode 'c++-ts-mode)
    (let* ((text (my/citre-backend--context-text beginning))
           (regexp (format "\\(?:[(,;{]\\|^\\)[ \t\n]*\\(?:const[ \t]+\\)?\\(?:struct[ \t]+\\)?\\([[:alpha:]_][[:alnum:]_:]*\\(?:[ \t]*<[^>;\n]+>\\)?\\)[ \t*&]+%s\\_>"
                           (regexp-quote receiver))))
      (my/citre-backend--unique-match regexp text))))

(defun my/citre-backend--tag-scope (tag)
  "Return TAG's compact normalized scope."
  (my/citre-backend--normalize-type (citre-get-tag-field 'scope tag t)))

(defun my/citre-backend--scope-match-p (type tag)
  "Whether the nominal TYPE matches TAG's scope."
  (when-let* ((type (my/citre-backend--normalize-type type))
              (scope (my/citre-backend--tag-scope tag)))
    (or (equal scope type)
        (string-suffix-p (concat "." type) scope)
        (and (not (string-match-p "\\." type))
             (equal type (car (last (split-string scope "\\." t))))))))

(defun my/citre-backend--same-directory-p (tag)
  "Whether TAG belongs to the current file's directory."
  (when-let* ((current buffer-file-name)
              (path (citre-get-tag-field 'ext-abspath tag)))
    (equal (file-name-directory (file-truename current))
           (file-name-directory (file-truename path)))))

(defun my/citre-backend--current-scope ()
  "Return the current defun name when Emacs can determine it cheaply."
  (when (fboundp 'add-log-current-defun)
    (ignore-errors (add-log-current-defun))))

(defun my/citre-backend--tag-rank (tag context current-scope)
  "Return a lexicographically comparable rank for TAG in CONTEXT."
  (let* ((path (citre-get-tag-field 'ext-abspath tag))
         (scope (my/citre-backend--tag-scope tag))
         (type (plist-get context :type)))
    (list (if (my/citre-backend--scope-match-p type tag) 0 1)
          (if (and path buffer-file-name (equal (file-truename path) (file-truename buffer-file-name))) 0 1)
          (if (and current-scope scope (string-match-p (regexp-quote current-scope) scope)) 0 1)
          (if (my/citre-backend--same-directory-p tag) 0 1)
          (my/citre-backend--source-rank (citre-get-tag-field 'my-source tag))
          (or (citre-get-tag-field 'my-order tag) most-positive-fixnum))))

(defun my/citre-backend--rank-less-p (left right context current-scope)
  "Whether LEFT should sort before RIGHT in CONTEXT."
  (let ((a (my/citre-backend--tag-rank left context current-scope))
        (b (my/citre-backend--tag-rank right context current-scope)))
    (catch 'result
      (while a
        (unless (= (car a) (car b))
          (throw 'result (< (car a) (car b))))
        (setq a (cdr a) b (cdr b)))
      nil)))

(defun my/citre-backend--prepare-completions (tags context)
  "Context-filter, rank and deduplicate completion TAGS."
  (let* ((type (plist-get context :type))
         (matching (and type (seq-filter (lambda (tag) (my/citre-backend--scope-match-p type tag)) tags)))
         (visible (if (and (eq my/citre-completion-context-filter 'safe) matching) matching tags))
         (current-scope (my/citre-backend--current-scope)))
    (setq visible (sort visible (lambda (a b) (my/citre-backend--rank-less-p a b context current-scope))))
    (if (not (eq my/citre-completion-deduplication 'name))
        visible
      (let ((groups (make-hash-table :test #'equal))
            (seen (make-hash-table :test #'equal)) result)
        (dolist (tag tags)
          (push tag (gethash (citre-get-tag-field 'name tag) groups)))
        (dolist (tag visible (nreverse result))
          (let ((name (citre-get-tag-field 'name tag)))
            (unless (gethash name seen)
              (puthash name t seen)
              (citre-set-tag-field 'my-tags (nreverse (gethash name groups)) tag)
              (citre-set-tag-field 'my-count (length (citre-get-tag-field 'my-tags tag)) tag)
              (push tag result))))))))

(defun my/citre-backend--source-label (source)
  "Return a compact display label for SOURCE."
  (alist-get source '((current-buffer . "buf") (live-buffer . "live")
                      (project . "proj") (dependency . "dep") (tags-file . "tags"))))

(defun my/citre-backend--annotation (tag)
  "Return the compact completion annotation for TAG."
  (let ((count (or (citre-get-tag-field 'my-count tag) 1)) parts)
    (when (memq 'scope my/citre-completion-annotation)
      (when-let* ((scope (my/citre-backend--tag-scope tag))) (push scope parts)))
    (when (memq 'source my/citre-completion-annotation)
      (when-let* ((source (my/citre-backend--source-label (citre-get-tag-field 'my-source tag))))
        (push source parts)))
    (when (and (memq 'count my/citre-completion-annotation) (> count 1))
      (push (format "+%d" (1- count)) parts))
    (when parts (concat " " (string-join (nreverse parts) " · ")))))

(defun my/citre-backend--make-candidate (tag)
  "Make one Company/CAPF candidate from TAG."
  (citre-put-property
   (citre-make-tag-str tag nil '(name))
   'annotation (my/citre-backend--annotation tag)
   'signature (or (citre-get-tag-field 'signature tag)
                  (when-let* ((str (citre-get-tag-field 'extra-matched-str tag))) (string-trim str)))
   'kind (when-let* ((kind (citre-get-tag-field 'ext-kind-full tag))) (intern kind))
   'tags (citre-get-tag-field 'my-tags tag)))

(defun my/citre-backend--make-collection (tags start end)
  "Return a completion-at-point value for TAGS between START and END."
  (when-let* ((candidates (mapcar #'my/citre-backend--make-candidate tags)))
    (let ((collection (lambda (string predicate action)
                        (if (eq action 'metadata)
                            '(metadata (category . citre-completion)
                                       (cycle-sort-function . identity)
                                       (display-sort-function . identity))
                          (complete-with-action action candidates string predicate)))))
      (list start end collection
            :annotation-function (lambda (candidate) (citre-get-property 'annotation candidate))
            :company-docsig (lambda (candidate) (citre-get-property 'signature candidate))
            :company-kind (lambda (candidate) (citre-get-property 'kind candidate))
            :exclusive 'no))))

(defun my/citre-backend-completions ()
  "Return current Citre completion bounds and tags, reusing a narrowing prefix."
  (when-let* ((source (car (my/citre-backend--sources)))
              (symbol (citre-tags-get-symbol (plist-get source :file)))
              (bounds (citre-get-property 'bounds symbol)))
    (let* ((revision (and my/citre-index--state (my/citre-index-state-revision my/citre-index--state)))
           (member-context (my/citre-backend--member-context (car bounds)))
           (context (list revision (car bounds) major-mode citre-tags-substr-completion
                          (buffer-substring-no-properties (line-beginning-position) (car bounds))
                          member-context))
           (cache my/citre-backend--completion-cache)
           (tags (if (and revision (equal context (plist-get cache :context))
                          (string-prefix-p (plist-get cache :prefix) symbol))
                     (plist-get cache :tags)
                   (let ((tags (my/citre-backend--prepare-completions
                                (my/citre-backend--query 'completion) member-context)))
                     (setq my/citre-backend--completion-cache
                           (list :context context :prefix (substring-no-properties symbol)
                                 :tags tags))
                     tags))))
      (when tags (list (car bounds) (cdr bounds) tags)))))

(defun my/citre-completion-at-point ()
  "Complete with ranked, deduplicated Citre candidates."
  (when-let* ((result (if citre-capf-optimize-for-popup
                          (pcase (while-no-input (citre-get-completions)) ('t nil) (value value))
                        (citre-get-completions)))
              (beginning (nth 0 result))
              (end (nth 1 result))
              (tags (nth 2 result)))
    (if citre-capf-optimize-for-popup
        (pcase (while-no-input (my/citre-backend--make-collection tags beginning end))
          ('t nil) (value value))
      (my/citre-backend--make-collection tags beginning end))))

(defun my/citre-backend-definitions ()
  "Find definitions in the current live and persistent indexes."
  (my/citre-backend--query 'definition))

(defun my/citre-backend-definitions-of-id (identifier)
  "Find definitions of IDENTIFIER in the current project."
  (my/citre-backend--query 'definition identifier))

(defun my/citre-backend-identifiers ()
  "Return all current identifiers for Citre's query UI."
  (delete-dups (mapcar (lambda (tag) (citre-get-tag-field 'name tag))
                      (my/citre-backend--query 'identifiers))))

(defun my/citre-backend-imenu ()
  "Return the current file's symbols for Imenu, including unsaved changes."
  (my/citre-backend--query 'imenu))

(defun my/citre-backend--reference-root ()
  "Return the directory searched for references."
  (or (and my/citre-index--state (my/citre-index-state-root my/citre-index--state))
      (when-let* ((project (project-current nil))) (project-root project))
      default-directory))

(defun my/citre-backend-references-of-id (identifier)
  "Return textual references to IDENTIFIER below the project root.
Universal Ctags records definitions only, so references come from a
whole-word search that honours the project's ignore files."
  (when-let* ((program (executable-find my/citre-reference-program))
              (name (substring-no-properties identifier))
              (root (my/citre-backend--reference-root)))
    (unless (string-empty-p name)
      (let ((default-directory root) tags)
        (with-temp-buffer
          (process-file program nil t nil
                        "--no-heading" "--line-number" "--color=never"
                        "--word-regexp" "--fixed-strings" "--" name ".")
          (goto-char (point-min))
          (while (not (eobp))
            ;; Citre renders the matched line from the file itself, so only the
            ;; location is recorded here.
            (when (looking-at "\\([^:\n]+\\):\\([0-9]+\\):")
              (push (citre-make-tag
                     'name name
                     'ext-abspath (expand-file-name (match-string-no-properties 1) root)
                     'line (match-string-no-properties 2)
                     'extras "reference")
                    tags))
            (forward-line 1)))
        (nreverse tags)))))

(defun my/citre-backend-references ()
  "Return textual references to the symbol at point."
  (when-let* ((symbol (citre-tags-symbol-at-point)))
    (my/citre-backend-references-of-id symbol)))

(defun my/citre-backend-usable-p ()
  "Whether the current buffer has an automatic or conventional tags index."
  (or my/citre-index--state (citre-tags-usable-p)))

(defun my/citre-backend--updated (state)
  "Invalidate data for STATE without restarting or rearranging an open Company menu."
  (dolist (buffer (my/citre-index--buffers state))
    (with-current-buffer buffer
      (setq my/citre-backend--completion-cache nil)
      (when (boundp 'imenu--index-alist) (setq imenu--index-alist nil))
      ;; Fresh data is consumed on the next edit or completion request. Navigation keeps its current candidates and selection.
      (when (and (boundp 'company--capf-cache)
                 (eq (car company--capf-cache) buffer))
        (setq company--capf-cache nil)))))

(citre-register-backend
 'my-index
 (citre-backend-create :usable-probe #'my/citre-backend-usable-p
                      :symbol-at-point-fn #'citre-tags-symbol-at-point
                      :completions-fn #'my/citre-backend-completions
                      :defs-fn #'my/citre-backend-definitions
                      :defs-of-id-fn #'my/citre-backend-definitions-of-id
                      :refs-fn #'my/citre-backend-references
                      :refs-of-id-fn #'my/citre-backend-references-of-id
                      :id-list-fn #'my/citre-backend-identifiers
                      :tags-in-buffer-fn #'my/citre-backend-imenu
                      :after-jump-fn #'my/citre-index-attach))

(add-hook 'my/citre-index-updated-hook #'my/citre-backend--updated)

(provide 'citre-backend)
;;; citre-backend.el ends here