summaryrefslogtreecommitdiff
path: root/lisp/progmodes/etags-regen.el
blob: 6cd78d3577af543331ea81195f345b4391fa130f (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
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
;;; etags-regen.el --- Auto-(re)regenerating tags  -*- lexical-binding: t -*-

;; Copyright (C) 2021-2023 Free Software Foundation, Inc.

;; Author: Dmitry Gutov <dmitry@gutov.dev>
;; Keywords: tools

;; This file is part of GNU Emacs.

;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Simple automatic tags generation with updates on save.
;;
;; This mode provides automatic indexing for Emacs "go to definition"
;; feature, the `xref-go-forward' command (bound to `M-.' by default).
;;
;; At the moment reindexing works off before/after-save-hook, but to
;; handle more complex changes (for example, the user switching to
;; another branch from the terminal) we can look into plugging into
;; something like `filenotify'.
;;
;; Note that this feature disables itself if the user has some tags
;; table already visited (with `M-x visit-tags-table', or through an
;; explicit prompt triggered by some feature that requires tags).

;;; Code:

(require 'cl-lib)

(defgroup etags-regen nil
  "Auto-(re)generating tags."
  :group 'tools)

(defvar etags-regen--tags-file nil)
(defvar etags-regen--tags-root nil)
(defvar etags-regen--new-file nil)

(declare-function project-root "project")
(declare-function project-files "project")
(declare-function dired-glob-regexp "dired")

(defcustom etags-regen-program (executable-find "etags")
  "Name of the etags program used by `etags-regen-mode'.

If you only have `ctags' installed, you can also set this to
\"ctags -e\".  Some features might not be supported this way."
  ;; Always having our 'etags' here would be easier, but we can't
  ;; always rely on it being installed.  So it might be ctags's etags.
  :type 'file
  :version "30.1")

(defcustom etags-regen-tags-file "TAGS"
  "Name of the tags file to create inside the project by `etags-regen-mode'.

The value should either be a simple file name (no directory
specified), or a function that accepts the project root directory
and returns a distinct absolute file name for its tags file.  The
latter possibility is useful when you prefer to store the tag
files somewhere else, for example in `temporary-file-directory'."
  :type '(choice (string :tag "File name")
                 (function :tag "Function that returns file name"))
  :version "30.1")

(defcustom etags-regen-program-options nil
  "List of additional options for etags program invoked by `etags-regen-mode'."
  :type '(repeat string)
  :version "30.1")

(defcustom etags-regen-regexp-alist nil
  "Mapping of languages to etags regexps for `etags-regen-mode'.

These regexps are used in addition to the tags made with the
standard parsing based on the language.

The value must be a list where each element has the
form (LANGUAGES . TAG-REGEXPS) where both LANGUAGES and
TAG-REGEXPS are lists of strings.

Each language should be one of the recognized by etags, see
`etags --help'.  Each tag regexp should be a string in the format
documented for the `--regex' arguments (without `{language}').

We currently support only Emacs's etags program with this option."
  :type '(repeat
          (cons
           :tag "Languages group"
           (repeat (string :tag "Language name"))
           (repeat (string :tag "Tag Regexp"))))
  :version "30.1")

;;;###autoload
(put 'etags-regen-regexp-alist 'safe-local-variable
     (lambda (value)
       (and (listp value)
            (seq-every-p
             (lambda (group)
               (and (consp group)
                    (listp (car group))
                    (listp (cdr group))
                    (seq-every-p #'stringp (car group))
                    (seq-every-p #'stringp (cdr group))))
             value))))

;; We have to list all extensions: etags falls back to Fortran
;; when it cannot determine the type of the file.
;; http://lists.gnu.org/archive/html/emacs-devel/2018-01/msg00323.html
(defcustom etags-regen-file-extensions
  '("rb" "js" "py" "pl" "el" "c" "cpp" "cc" "h" "hh" "hpp"
    "java" "go" "cl" "lisp" "prolog" "php" "erl" "hrl"
    "F" "f" "f90" "for" "cs" "a" "asm" "ads" "adb" "ada")
  "Code file extensions for `etags-regen-mode'.

File extensions to generate the tags for."
  :type '(repeat (string :tag "File extension"))
  :version "30.1")

;;;###autoload
(put 'etags-regen-file-extensions 'safe-local-variable
     (lambda (value) (and (listp value) (seq-every-p #'stringp value))))

;; FIXME: We don't support root anchoring yet.
(defcustom etags-regen-ignores nil
  "Additional ignore rules, in the format of `project-ignores'."
  :type '(repeat
          (string :tag "Glob to ignore"))
  :version "30.1")

;;;###autoload
(put 'etags-regen-ignores 'safe-local-variable
     (lambda (value) (and (listp value) (seq-every-p #'stringp value))))

(defvar etags-regen--errors-buffer-name "*etags-regen-tags-errors*")

(defvar etags-regen--rescan-files-limit 100)

(defun etags-regen--all-mtimes (proj)
  (let ((files (etags-regen--all-files proj))
        (mtimes (make-hash-table :test 'equal))
        file-name-handler-alist)
    (dolist (f files)
      (condition-case nil
          (puthash f
                   (file-attribute-modification-time
                    (file-attributes f))
                   mtimes)
        (file-missing nil)))
    mtimes))

(defun etags-regen--choose-tags-file (proj)
  (if (functionp etags-regen-tags-file)
      (funcall etags-regen-tags-file (project-root proj))
    (expand-file-name etags-regen-tags-file (project-root proj))))

(defun etags-regen--refresh (proj)
  (save-excursion
    (let* ((tags-file (etags-regen--choose-tags-file proj))
           (tags-mtime (file-attribute-modification-time
                        (file-attributes tags-file)))
           (all-mtimes (etags-regen--all-mtimes proj))
           added-files
           changed-files
           removed-files)
      (etags-regen--visit-table tags-file (project-root proj))
      (set-buffer (get-file-buffer tags-file))
      (dolist (file (tags-table-files))
        (let ((mtime (gethash file all-mtimes)))
          (cond
           ((null mtime)
            (push file removed-files))
           ((time-less-p tags-mtime mtime)
            (push file changed-files)
            (remhash file all-mtimes))
           (t
            (remhash file all-mtimes)))))
      (maphash
       (lambda (key _value)
         (push key added-files))
       all-mtimes)
      (if (> (+ (length added-files)
                (length changed-files)
                (length removed-files))
             etags-regen--rescan-files-limit)
          (progn
            (message "etags-regen: Too many changes, falling back to full rescan")
            (etags-regen--tags-cleanup))
        (dolist (file (nconc removed-files changed-files))
          (etags-regen--remove-tag file))
        (when (or changed-files added-files)
          (apply #'etags-regen--append-tags
                 (nconc changed-files added-files)))
        (when (or changed-files added-files removed-files)
          (let ((save-silently t)
                (message-log-max nil))
            (save-buffer 0)))))))

(defun etags-regen--maybe-generate ()
  (let (proj)
    (when (and etags-regen--tags-root
               (not (file-in-directory-p default-directory
                                         etags-regen--tags-root)))
      (etags-regen--tags-cleanup))
    (when (and (not etags-regen--tags-root)
               ;; If existing table is visited that's not generated by
               ;; this mode, skip all functionality.
               (not (or tags-file-name
                        tags-table-list))
               (file-exists-p (etags-regen--choose-tags-file
                               (setq proj (project-current)))))
      (message "Found existing tags table, refreshing...")
      (etags-regen--refresh proj))
    (when (and (not (or tags-file-name
                        tags-table-list))
               (setq proj (or proj (project-current))))
      (message "Generating new tags table...")
      (let ((start (time-to-seconds)))
        (etags-regen--tags-generate proj)
        (message "...done (%.2f s)" (- (time-to-seconds) start))))))

(defun etags-regen--all-files (proj)
  (let* ((root (project-root proj))
         (default-directory root)
         ;; TODO: Make the scanning more efficient, e.g. move the
         ;; filtering by glob to project (project-files-filtered...).
         (files (project-files proj))
         (match-re (concat
                    "\\."
                    (regexp-opt etags-regen-file-extensions)
                    "\\'"))
         (ir-start (1- (length root)))
         (ignores-regexps
          (mapcar #'etags-regen--ignore-regexp
                  etags-regen-ignores)))
    (cl-delete-if
     (lambda (f) (or (not (string-match-p match-re f))
                (string-match-p "/\\.#" f) ;Backup files.
                (cl-some (lambda (ignore) (string-match ignore f ir-start))
                         ignores-regexps)))
     files)))

(defun etags-regen--ignore-regexp (ignore)
  (require 'dired)
  ;; It's somewhat brittle to rely on Dired.
  (let ((re (dired-glob-regexp ignore)))
    ;; We could implement root anchoring here, but \\= doesn't work in
    ;; string-match :-(.
    (concat (unless (eq ?/ (aref re 3)) "/")
            ;; Cutting off the anchors added by `dired-glob-regexp'.
            (substring re 2 (- (length re) 2))
            ;; This way we allow a glob to match against a directory
            ;; name, or a file name.  And when it ends with / already,
            ;; no need to add the anchoring.
            (unless (eq ?/ (aref re (- (length re) 3)))
              ;; Either match a full name segment, or eos.
              "\\(?:/\\|\\'\\)"))))

(defun etags-regen--tags-generate (proj)
  (let* ((root (project-root proj))
         (default-directory root)
         (files (etags-regen--all-files proj))
         (tags-file (etags-regen--choose-tags-file proj))
         (ctags-p (etags-regen--ctags-p))
         (command (format "%s %s %s - -o %s"
                          etags-regen-program
                          (mapconcat #'identity
                                     (etags-regen--build-program-options ctags-p)
                                     " ")
                          ;; ctags's etags requires '-L' for stdin input.
                          (if ctags-p "-L" "")
                          tags-file)))
    (with-temp-buffer
      (mapc (lambda (f)
              (insert f "\n"))
            files)
      (shell-command-on-region (point-min) (point-max) command
                               nil nil etags-regen--errors-buffer-name t))
    (etags-regen--visit-table tags-file root)))

(defun etags-regen--visit-table (tags-file root)
  ;; Invalidate the scanned tags after any change is written to disk.
  (add-hook 'after-save-hook #'etags-regen--update-file)
  (add-hook 'before-save-hook #'etags-regen--mark-as-new)
  (setq etags-regen--tags-file tags-file
        etags-regen--tags-root root)
  (visit-tags-table etags-regen--tags-file))

(defun etags-regen--ctags-p ()
  (string-search "Ctags"
                 (shell-command-to-string
                  (format "%s --version" etags-regen-program))))

(defun etags-regen--build-program-options (ctags-p)
  (when (and etags-regen-regexp-alist ctags-p)
    (user-error "etags-regen-regexp-alist is not supported with Ctags"))
  (nconc
   (mapcan
    (lambda (group)
      (mapcan
       (lambda (lang)
         (mapcar (lambda (regexp)
                   (concat "--regex="
                           (shell-quote-argument
                            (format "{%s}%s" lang regexp))))
                 (cdr group)))
       (car group)))
    etags-regen-regexp-alist)
   (mapcar #'shell-quote-argument
           etags-regen-program-options)))

(defun etags-regen--update-file ()
  ;; TODO: Maybe only do this when Emacs is idle for a bit.  Or defer
  ;; the updates and do them later in bursts when the table is used.
  (let* ((file-name buffer-file-name)
         (tags-file-buf (and etags-regen--tags-root
                             (get-file-buffer etags-regen--tags-file)))
         (relname (concat "/" (file-relative-name file-name
                                                  etags-regen--tags-root)))
         (ignores etags-regen-ignores)
         pr should-scan)
    (save-excursion
      (when tags-file-buf
        (cond
         ((and etags-regen--new-file
               (kill-local-variable 'etags-regen--new-file)
               (setq pr (project-current))
               (equal (project-root pr) etags-regen--tags-root)
               (member file-name (project-files pr)))
          (set-buffer tags-file-buf)
          (setq should-scan t))
         ((progn (set-buffer tags-file-buf)
                 (etags-regen--remove-tag file-name))
          (setq should-scan t))))
      (when (and should-scan
                 (not (cl-some
                       (lambda (ignore)
                         (string-match-p
                          (etags-regen--ignore-regexp ignore)
                          relname))
                       ignores)))
        (etags-regen--append-tags file-name)
        (let ((save-silently t)
              (message-log-max nil))
          (save-buffer 0))))))

(defun etags-regen--remove-tag (file-name)
  (goto-char (point-min))
  (when (search-forward (format "\f\n%s," file-name) nil t)
    (let ((start (match-beginning 0)))
      (search-forward "\f\n" nil 'move)
      (let ((inhibit-read-only t))
        (delete-region start
                       (if (eobp)
                           (point)
                         (- (point) 2)))))
    t))

(defun etags-regen--append-tags (&rest file-names)
  (goto-char (point-max))
  (let ((options (etags-regen--build-program-options (etags-regen--ctags-p)))
        (inhibit-read-only t))
    ;; XXX: call-process is significantly faster, though.
    ;; Like 10ms vs 20ms here.  But `shell-command' makes it easy to
    ;; direct stderr to a separate buffer.
    (shell-command
     (format "%s %s %s -o -"
             etags-regen-program (mapconcat #'identity options " ")
             (mapconcat #'identity file-names " "))
     t etags-regen--errors-buffer-name))
  ;; FIXME: Is there a better way to do this?
  ;; Completion table is the only remaining place where the
  ;; update is not incremental.
  (setq-default tags-completion-table nil))

(defun etags-regen--mark-as-new ()
  (when (and etags-regen--tags-root
             (not buffer-file-number))
    (setq-local etags-regen--new-file t)))

(defun etags-regen--tags-cleanup ()
  (when etags-regen--tags-file
    (let ((buffer (get-file-buffer etags-regen--tags-file)))
      (and buffer
           (kill-buffer buffer)))
    (tags-reset-tags-tables)
    (setq tags-file-name nil
          tags-table-list nil
          etags-regen--tags-file nil
          etags-regen--tags-root nil))
  (remove-hook 'after-save-hook #'etags-regen--update-file)
  (remove-hook 'before-save-hook #'etags-regen--mark-as-new))

(defvar etags-regen-mode-map (make-sparse-keymap))

;;;###autoload
(define-minor-mode etags-regen-mode
  "Minor mode to automatically generate and update tags tables.

This minor mode generates the tags table automatically based on
the current project configuration, and later updates it as you
edit the files and save the changes.

If you select a tags table manually (for example, using
\\[visit-tags-table]), then this mode will be effectively
disabled for the entire session.  Use \\[tags-reset-tags-tables]
to countermand the effect of a previous \\[visit-tags-table]."
  :global t
  (if etags-regen-mode
      (progn
        (advice-add 'etags--xref-backend :before
                    #'etags-regen--maybe-generate)
        (advice-add 'tags-completion-at-point-function :before
                    #'etags-regen--maybe-generate))
    (advice-remove 'etags--xref-backend #'etags-regen--maybe-generate)
    (advice-remove 'tags-completion-at-point-function #'etags-regen--maybe-generate)
    (etags-regen--tags-cleanup)))

(provide 'etags-regen)

;;; etags-regen.el ends here