-
Notifications
You must be signed in to change notification settings - Fork 48
/
magit-todos.el
1582 lines (1395 loc) · 75.2 KB
/
magit-todos.el
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
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; magit-todos.el --- Show source file TODOs in Magit -*- lexical-binding: t; -*-
;; Copyright (C) 2018 Adam Porter
;; Author: Adam Porter <[email protected]>
;; URL: http://github.com/alphapapa/magit-todos
;; Version: 1.9-pre
;; Package-Requires: ((emacs "26.1") (async "1.9.2") (dash "2.13.0") (f "0.17.2") (hl-todo "1.9.0") (magit "2.13.0") (pcre2el "1.8") (s "1.12.0") (transient "0.2.0"))
;; Keywords: magit, vc
;;; Commentary:
;; This package displays keyword entries from source code comments and Org files
;; in the Magit status buffer. Activating an item jumps to it in its file. By
;; default, it uses keywords from `hl-todo', minus a few (like "NOTE").
;;;; Usage:
;; Run `magit-todos-mode', then open a Magit status buffer.
;;;; Tips:
;; + You can customize settings in the `magit-todos' group.
;;; Installation:
;;;; MELPA
;; If you installed from MELPA, you're done.
;;;; Manual
;; Install these required packages:
;; async
;; dash
;; f
;; hl-todo
;; magit
;; pcre2el
;; s
;; Then put this file in your load-path, and put this in your init file:
;; (require 'magit-todos)
;;; License:
;; This program 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.
;; This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'grep)
(require 'seq)
(require 'async)
(require 'dash)
(require 'f)
(require 'hl-todo)
(require 'magit)
(require 'transient)
(require 'pcre2el)
(require 's)
;;;; Structs
(cl-defstruct magit-todos-item
filename org-level line column position keyword suffix description)
;;;; Variables
(defvar magit-todos-keywords-list nil
"List of to-do keywords.
Set automatically by `magit-todos-keywords' customization.")
(defvar magit-todos-grep-result-regexp nil
"Regular expression for grep results.
This should be set automatically by customizing
`magit-todos-keywords'.")
(defvar magit-todos-ag-result-regexp nil
"Regular expression for ag results.
This should be set automatically by customizing
`magit-todos-keywords'.")
(defvar magit-todos-rg-result-regexp nil
"Regular expression for rg results.
This should be set automatically by customizing
`magit-todos-keywords'.")
(defvar magit-todos-git-grep-result-regexp nil
"Regular expression for git-grep results.
This should be set automatically by customizing
`magit-todos-keywords'.")
(defvar magit-todos-search-regexp nil
"Regular expression to match keyword items with rg, ag, and git-grep.
This should be set automatically by customizing
`magit-todos-keywords'.")
(defvar-local magit-todos-active-scan nil
"The current scan's process.
Used to avoid running multiple simultaneous scans for a
`magit-status' buffer.")
(defvar magit-todos-section-map
(let ((map (make-sparse-keymap)))
(define-key map "b" #'magit-todos-branch-list-toggle)
(define-key map "B" #'magit-todos-branch-list-set-commit)
(define-key map [remap magit-visit-thing] #'magit-todos-list)
map)
"Keymap for `magit-todos' top-level section.")
(defvar magit-todos-item-section-map
(let ((map (copy-keymap magit-todos-section-map)))
(define-key map [remap magit-visit-thing] #'magit-todos-jump-to-item)
(define-key map [remap magit-diff-show-or-scroll-up] #'magit-todos-peek-at-item)
map)
"Keymap for `magit-todos' individual to-do item sections.
See https://magit.vc/manual/magit/Creating-Sections.html for more
details about how section maps work.")
(defvar-local magit-todos-show-filenames nil
"Whether to show filenames next to to-do items.
Set automatically depending on grouping.")
(defvar-local magit-todos-updating nil
"Whether items are being updated now.")
(defvar-local magit-todos-last-update-time nil
"When the items were last updated.
A time value as returned by `current-time'.")
(defvar-local magit-todos-item-cache nil
"Items found by most recent scan.")
(defvar-local magit-todos-branch-item-cache nil
"Items found by most recent branch scan.")
(defvar magit-todos-scanners nil
"Scanners defined by `magit-todos-defscanner'.")
(defvar magit-todos-section-heading "TODOs"
"Allows overriding of section heading.")
(defvar org-odd-levels-only)
;;;; Customization
(defgroup magit-todos nil
"Show TODO items in source code comments in repos' files."
:group 'magit)
(defcustom magit-todos-scanner nil
"File scanning method.
\"Automatic\" will attempt to use rg, ag, git-grep, and
find-grep, in that order."
:type '(choice (const :tag "Automatic" nil)
(function :tag "Custom function"))
:set (lambda (option value)
(when magit-todos-scanners
;; Only try to set when scanners are defined.
(unless value
;; Choosing automatically
(setq value (or (magit-todos--choose-scanner)
(progn
(display-warning 'magit-todos
"`magit-todos' was unable to find a suitable scanner. Please install \"rg\", or a PCRE-compatible version of \"git\" or \"grep\". Disabling `magit-todos-mode'."
:error)
(magit-todos-mode -1)
nil))))
(set-default option value))))
(defcustom magit-todos-nice t
"Run scanner with \"nice\"."
:type 'boolean)
(defcustom magit-todos-ignore-case nil
"Upcase keywords found in files.
If nil, a keyword like \"Todo:\" will not be shown. `upcase' can
be a relatively expensive function, so this can be disabled if
necessary."
:type 'boolean)
(defcustom magit-todos-update t
"When or how often to scan for to-dos.
When set to manual updates, the list can be updated with the
command `magit-todos-update'. When caching is enabled, scan for
items whenever the Magit status buffer is refreshed and at least
N seconds have passed since the last scan; otherwise, use cached
items."
:type '(choice (const :tag "Automatically, when the Magit status buffer is refreshed" t)
(integer :tag "Automatically, but cache items for N seconds")
(const :tag "Manually" nil)))
(defcustom magit-todos-update-remote nil
"Automatically scan in remote repositories.
If nil, remote repos are only scanned when a manual scan is
initiated (see command `magit-todos-update'). Remote repos
include ones accessed via TRAMP (i.e. any path for which
`file-remote-p' returns non-nil)."
:type 'boolean)
(defcustom magit-todos-fontify-keyword-headers t
"Apply keyword faces to group keyword headers."
:type 'boolean)
(defcustom magit-todos-keyword-suffix (rx (optional (or "(" "[") (1+ (not (any ")" "]"))) (or ")" "]")) ":")
"Regular expression matching suffixes after keywords.
e.g. to match a keyword like \"TODO(user):\", use \"([^)]+):\".
If the suffix should be optional, the entire regexp should be
made explicitly optional. However, it is not necessary to
account for optional whitespace after the suffix, as this is done
automatically.
Note: the suffix applies only to non-Org files."
:type `(choice (const :tag "Optional suffix in parens, then required colon (matching e.g. \"TODO:\" or \"TODO(foo):\")"
,(rx (optional "(" (1+ (not (any ")"))) ")") ":"))
(const :tag "Optional suffix in parens or brackets, then required colon (matching e.g. \"TODO:\" or \"TODO(foo):\" or \"TODO[foo]:\")"
,(rx (optional (or "(" "[") (1+ (not (any ")" "]"))) (or ")" "]")) ":"))
(const :tag "Required colon (matching e.g. \"TODO:\"" ":")
(string :tag "Custom regexp"))
:package-version '(magit-todos . "1.2"))
(defcustom magit-todos-ignored-keywords '("NOTE" "DONE")
"Ignored keywords. Automatically removed from `magit-todos-keywords'."
:type '(repeat string)
:set (lambda (option value)
(set-default option value)
(when (boundp 'magit-todos-keywords)
;; Avoid setting `magit-todos-keywords' before it's defined.
;; HACK: Testing with `fboundp' is the only way I have been able to find that fixes this
;; problem. I tried using ":set-after '(magit-todos-ignored-keywords)" on
;; `magit-todos-keywords', but it had no effect. I looked in the manual, which seems to
;; suggest that using ":initialize 'custom-initialize-safe-set" might fix it--but that
;; function is no longer to be found in the Emacs source tree. It was committed in 2005,
;; and now it's gone, but the manual still mentions it. ???
(custom-reevaluate-setting 'magit-todos-keywords))))
(defcustom magit-todos-keywords 'hl-todo-keyword-faces
"To-do keywords to display in Magit status buffer.
If set to a list variable, may be a plain list or an alist in
which the keys are the keywords.
When set, sets `magit-todos-search-regexp' to the appropriate
regular expression."
:type '(choice (repeat :tag "Custom list" string)
(const :tag "Keywords from `hl-todo'"
:doc "Note that the keywords in `hl-todo-keyword-faces' are treated by it as regexps, while this package treats them as strings. If this doesn't meet your needs, please use another option. See <https://github.com/alphapapa/magit-todos/issues/101>."
hl-todo-keyword-faces)
(variable :tag "List variable"))
:set (lambda (option value)
(set-default option value)
(let ((keywords (cl-typecase value
(null (user-error "Please add some keywords"))
(symbol (if (and (consp (symbol-value value))
(consp (car (symbol-value value))))
(mapcar #'car (symbol-value value))
(symbol-value value)))
(list value))))
(setq magit-todos-keywords-list (seq-difference keywords magit-todos-ignored-keywords)))))
(defcustom magit-todos-max-items 10
"Automatically collapse the section if there are more than this many items."
:type 'integer)
(defcustom magit-todos-auto-group-items 20
"Whether or when to automatically group items."
:type '(choice (integer :tag "When there are more than this many items")
(const :tag "Always" always)
(const :tag "Never" never)))
(defcustom magit-todos-buffer-item-factor 10
"Adjustment to item grouping in dedicated `magit-todos' buffers.
Multiplies `magit-todos-auto-group-items' and
`magit-todos-max-items' by this factor."
:type 'integer)
(defcustom magit-todos-group-by '(magit-todos-item-keyword magit-todos-item-filename)
"How to group items.
One or more attributes may be chosen, and they will be grouped in
order."
:type '(repeat (choice (const :tag "By filename" magit-todos-item-filename)
(const :tag "By keyword" magit-todos-item-keyword)
(const :tag "By suffix" magit-todos-item-suffix)
(const :tag "By first path component" magit-todos-item-first-path-component))))
(defcustom magit-todos-fontify-org t
"Fontify items from Org files as Org headings."
:type 'boolean)
(defcustom magit-todos-sort-order '(magit-todos--sort-by-keyword
magit-todos--sort-by-filename
magit-todos--sort-by-position)
"Order in which to sort items."
:type '(repeat (choice (const :tag "Keyword" magit-todos--sort-by-keyword)
(const :tag "Suffix" magit-todos--sort-by-suffix)
(const :tag "Filename" magit-todos--sort-by-filename)
(const :tag "Buffer position" magit-todos--sort-by-position)
(function :tag "Custom function"))))
(defcustom magit-todos-depth nil
"Maximum depth of files in repo working tree to scan for to-dos.
A value of 0 means to search only the current directory, while a
value of 1 means to search directories one level deeper, etc.
Deeper scans can be slow in large projects. You may wish to set
this in a directory-local variable for certain projects."
:type '(choice (const :tag "Unlimited" nil)
(const :tag "Repo root directory only" 0)
(integer :tag "N levels below the repo root")))
(defcustom magit-todos-insert-after '(bottom)
"Where to insert the TODOs section in the Magit status buffer.
The TODOs section is inserted after the first of these sections
found, or at the bottom if none exist. Specific sections may be
chosen, using the first symbol returned by evaluating
\"(magit-section-ident (magit-current-section))\" in the status
buffer with point on the desired section, e.g. `recent' for the
\"Recent commits\" section. Note that this may not work exactly
as desired when the built-in scanner is used."
:type '(repeat
(choice (const :tag "Top" top)
(const :tag "Bottom" bottom)
(const :tag "Recent commits" unpushed)
(const :tag "Untracked files" untracked)
(const :tag "Unstaged files" unstaged)
(const :tag "Staged files" staged)
(const :tag "Stashes" stashes)
(const :tag "Pull requests (Forge)" pullreqs)
(const :tag "Issues (Forge)" issues)
(symbol :tag "Specified section"))))
(defcustom magit-todos-exclude-globs '(".git/")
"Glob patterns to exclude from searches."
:type '(repeat string))
(defcustom magit-todos-branch-list 'branch
"Show branch diff to-do list.
This can be toggled locally in Magit buffers with command
`magit-todos-branch-list-toggle'."
:type '(choice (const :tag "Never" nil)
(const :tag "In non-master branches" branch)
(const :tag "Always" t)))
(defcustom magit-todos-branch-list-merge-base-ref nil
"Commit ref passed to command \"git merge-base HEAD\".
Determines the ancestor commit from which the current branch's
todos should be searched for. May be overridden in the case that
a branch is branched off another branch rather than master. For
example, consider the following commit graph:
---A1---A2---A3 (master)
\
B1---B2---B3 (topic)
\
C1---C2---C3 (topic2, HEAD)
By default, the branch todo list would show todos from both the
\"topic\" branch and the \"topic2\" branch. To show only todos
from the \"topic2\" branch, this option could be set to
\"topic\"."
:type '(choice (const :tag "Automatic" :doc "Value returned by `magit-main-branch'" nil)
(string :tag "Specified branch name")))
(defcustom magit-todos-submodule-list nil
"Show submodule to-do list."
:type 'boolean)
(defcustom magit-todos-filename-filter nil
"Filter applied to filenames."
:type '(choice (const :tag "None (show filename relative to repo)" nil)
(function-item :tag "Basename" file-name-nondirectory)
(function :tag "Custom function")))
;;;; Commands
;;;###autoload
(define-minor-mode magit-todos-mode
"Show list of to-do items in Magit status buffer for tracked files in repo."
:require 'magit-todos
:group 'magit-todos
:global t
(if magit-todos-mode
(progn
(transient-append-suffix #'magit-status-jump
'(0 -1) '[("T" "Todos" magit-todos-jump-to-todos)
("l" "List todos" magit-todos-list)])
(magit-add-section-hook 'magit-status-sections-hook
#'magit-todos--insert-todos
nil
'append)
(add-hook 'magit-status-mode-hook #'magit-todos--add-to-status-buffer-kill-hook 'append))
;; Disable mode
(transient-remove-suffix #'magit-status-jump '(0 -1))
(remove-hook 'magit-status-sections-hook #'magit-todos--insert-todos)
(remove-hook 'magit-status-mode-hook #'magit-todos--add-to-status-buffer-kill-hook)))
(defun magit-todos-update ()
"Update the to-do list manually.
Only necessary when option `magit-todos-update' is nil."
(interactive)
(unless magit-todos-mode
(user-error "Please activate `magit-todos-mode'"))
(let ((inhibit-read-only t))
;; Delete twice since there might also be the branch-local section.
(magit-todos--delete-section [* todos])
(magit-todos--delete-section [* todos])
;; HACK: See other note on `magit-todos-updating'.
(setq magit-todos-updating t)
(magit-todos--insert-todos)))
(defun magit-todos-branch-list-toggle ()
"Toggle branch diff to-do list in current Magit buffer."
(interactive)
;; FIXME: If `magit-todos-branch-list' is not just nil/t, the value is lost.
(setq-local magit-todos-branch-list (not magit-todos-branch-list))
(magit-todos-update))
(defun magit-todos-branch-list-set-commit (ref)
"Set commit REF used in branch to-do list."
(interactive (list (completing-read "Refname: " (magit-list-refnames))))
(setq-local magit-todos-branch-list-merge-base-ref ref)
(magit-todos-update))
(cl-defun magit-todos-jump-to-item (&key peek (item (oref (magit-current-section) value)))
"Show current ITEM.
If PEEK is non-nil, keep focus in status buffer window."
(interactive)
(let* ((status-window (selected-window))
(buffer (magit-todos--item-buffer item)))
(pop-to-buffer buffer)
(magit-todos--goto-item item)
(when (derived-mode-p 'org-mode)
;; Because `org-show-entry' was renamed and moved in Org 9.6, we
;; have to silence warnings about it. If Org is loaded, the
;; function will be.
(cond ((version<= "9.6" (org-version))
(with-no-warnings
(org-fold-show-entry)))
(t (with-no-warnings
(org-show-entry)))))
(when peek
(select-window status-window))))
(defun magit-todos-peek-at-item ()
"Peek at current item."
(interactive)
(magit-todos-jump-to-item :peek t))
;;;;; Jump to section
(magit-define-section-jumper magit-jump-to-todos "TODOs" todos)
(defun magit-todos-jump-to-todos ()
"Jump to TODOs section, and update it if empty."
(interactive)
(let ((already-in-section-p (magit-section-match [* todos])))
(magit-jump-to-todos)
(when (and (or (integerp magit-todos-update)
(not magit-todos-update))
(or already-in-section-p
(= 0 (length (oref (magit-current-section) children)))))
(magit-todos-update))))
;;;; Dedicated buffer
;;;###autoload
(defun magit-todos-list (&optional directory)
"Show to-do list of the current Git repository in a buffer.
With prefix, prompt for repository. Use repository in DIRECTORY,
or `default-directory' if nil."
;; Mostly copied from `magit-status'
(interactive
(let ((magit--refresh-cache (list (cons 0 0))))
(list (and (or current-prefix-arg (not (magit-toplevel)))
(magit-read-repository)))))
(condition-case nil
(let ((magit--refresh-cache (list (cons 0 0))))
(setq directory (if directory
(file-name-as-directory (expand-file-name directory))
default-directory))
(magit-todos-list-internal directory))
(magit-outside-git-repo
(cl-letf (((symbol-function 'magit-toplevel) (lambda (&rest _) default-directory)))
(call-interactively #'magit-todos-list)))))
(put 'magit-todos-list 'interactive-only 'magit-todos-list-internal)
;;;###autoload
(defun magit-todos-list-internal (directory)
"Open buffer showing to-do list of repository at DIRECTORY."
(if (fboundp 'magit--tramp-asserts)
(magit--tramp-asserts directory)
(when (file-remote-p directory)
(magit-git-version-assert)))
(let ((default-directory directory))
(magit-setup-buffer #'magit-todos-list-mode)))
(define-derived-mode magit-todos-list-mode magit-status-mode "Magit"
"Mode for looking at repository to-do list.
\\<magit-todos-mode-map>\
Type \\[magit-refresh] to refresh the list.
Type \\[magit-section-toggle] to expand or hide the section at point.
Type \\[magit-visit-thing] to visit the item at point.
Type \\[magit-diff-show-or-scroll-up] to peek at the item at point."
:group 'magit-todos)
(defun magit-todos-list-refresh-buffer ()
"Refresh the current `magit-todos-list-mode' buffer."
(setq-local magit-todos-max-items (* magit-todos-max-items magit-todos-buffer-item-factor))
(when (numberp magit-todos-auto-group-items)
(setq-local magit-todos-auto-group-items (* magit-todos-auto-group-items magit-todos-buffer-item-factor)))
(magit-section-show (magit-insert-section (type magit-root-section)
(magit-insert-status-headers)
(magit-todos--insert-todos))))
;;;; Functions
(defun magit-todos--section-end (condition)
"Return end position of section matching CONDITION, or nil.
CONDITION may be one accepted by `magit-section-match', or `top'
or `bottom', which are handled specially."
(cl-labels ((find-section (condition)
(save-excursion
(goto-char (point-min))
(ignore-errors
(cl-loop until (magit-section-match condition)
do (magit-section-forward)
finally return (magit-current-section))))))
(save-excursion
(goto-char (point-min))
(pcase condition
('top (when-let ((section (or (find-section 'tags)
(find-section 'tag)
(find-section 'branch))))
;; Add 1 to leave blank line after top sections.
(1+ (oref section end))))
('bottom (oref (-last-item (oref magit-root-section children)) end))
(_ (when-let ((section (find-section condition)))
(oref section end)))))))
(defun magit-todos--coalesce-groups (groups)
"Return GROUPS, coalescing any groups with `equal' keys.
GROUPS should be an alist. Assumes that each group contains
unique items. Intended for post-processing the result of
`-group-by'."
(cl-loop with keys = (-uniq (-map #'car groups))
for key in keys
for matching-groups = (--select (equal key (car it)) groups)
collect (cons key (apply #'append (-map #'cdr matching-groups)))))
(defun magit-todos--add-to-status-buffer-kill-hook ()
"Add `magit-todos--kill-active-scan' to `kill-buffer-hook' locally."
(add-hook 'kill-buffer-hook #'magit-todos--kill-active-scan 'append 'local))
(defun magit-todos--kill-active-scan ()
"Kill `magit-todos-active-scan'.
To be called in status buffers' `kill-buffer-hook'."
(when (and magit-todos-active-scan
(process-live-p magit-todos-active-scan))
(kill-process magit-todos-active-scan)
(when-let* ((buffer (process-buffer magit-todos-active-scan))
(alive (buffer-live-p buffer)))
(kill-buffer buffer))))
(defun magit-todos--add-to-custom-type (symbol value)
"Add VALUE to the end of SYMBOL's `custom-type' property."
(declare (indent defun))
(pcase-let* ((`(,type . ,choices) (get symbol 'custom-type))
(choices (append (list value) choices)))
(put symbol 'custom-type
(append (list type) choices))))
(defun magit-todos--choose-scanner ()
"Return function to call to scan for items with.
Chooses automatically in order defined in `magit-todos-scanners'."
(cl-loop for scanner in magit-todos-scanners
for availablep = (alist-get 'availablep scanner)
when (and availablep (funcall availablep))
return (alist-get 'function scanner)))
(cl-defun magit-todos--scan-callback (&key callback magit-status-buffer results-regexp process &allow-other-keys)
"Call CALLBACK with arguments MAGIT-STATUS-BUFFER and match items.
Match items are a list of `magit-todos-item' found in PROCESS's
buffer for RESULTS-REGEXP."
(funcall callback magit-status-buffer
(with-current-buffer (process-buffer process)
(magit-todos--buffer-items results-regexp))))
(defun magit-todos--buffer-items (results-regexp)
"Return list of `magit-todos-item' found in current buffer for RESULTS-REGEXP."
(let ((items))
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(--when-let (magit-todos--line-item results-regexp)
(push it items))
(forward-line 1)))
(nreverse items)))
(cl-defun magit-todos--git-diff-callback (&key magit-status-buffer results-regexp search-regexp-elisp process heading
exclude-globs &allow-other-keys)
"Callback for git diff scanner output.
Insert into MAGIT-STATUS-BUFFER. RESULTS-REGEXP matches a result
on each line. SEARCH-REGEXP-ELISP finds the next hunk of
results. PROCESS is the \"git diff\" process object.
`magit-todos-section-heading' is bound to HEADING when inserting
items. EXCLUDE-GLOBS is a list of glob patterns matching
filenames to be excluded."
;; NOTE: Doesn't handle newlines in filenames or diff.mnemonicPrefix.
(cl-macrolet ((next-diff () `(re-search-forward (rx bol "diff --git ") nil t))
(next-filename () `(when (re-search-forward (rx bol "+++ b/" (group (1+ nonl))) nil t)
(match-string 1)))
(next-hunk-line-number () `(when (re-search-forward (rx bol "@@ -"
(1+ digit) (optional "," (1+ digit)) (1+ space)
"+" (group (1+ digit)) (optional "," (1+ digit)))
file-end t)
(string-to-number (match-string 1))))
(file-end () `(or (save-excursion
(when (re-search-forward (rx bol "diff --git ")
nil t)
(match-beginning 0)))
(point-max)))
(hunk-end () `(or (save-excursion
(when (re-search-forward (rx (or (seq bol "diff --git ")
(seq bol "@@ ")))
nil t)
(match-beginning 0)))
(point-max))))
(with-current-buffer (process-buffer process)
(goto-char (point-min))
(let ((glob-regexps (mapcar #'wildcard-to-regexp exclude-globs))
items filename file-end hunk-end line-number)
(while (next-diff)
(while (setf filename (next-filename))
(unless (--some? (string-match it filename) glob-regexps)
(setf file-end (file-end))
(while (setf line-number (next-hunk-line-number))
(setf hunk-end (hunk-end))
(while (re-search-forward (rx bol "+") hunk-end t)
;; Since "git diff-index" doesn't accept PCREs to its "-G" option, we have to test the search regexp ourselves.
(when (re-search-forward search-regexp-elisp (line-end-position) t)
(when-let* ((line (buffer-substring (line-beginning-position) (line-end-position)))
(item (with-temp-buffer
;; NOTE: We fake grep output by inserting the filename, line number, position, etc.
;; This lets us use the same results regexp that's used for grep-like output.
(save-excursion
(insert filename ":" (number-to-string line-number) ":0: " line))
(magit-todos--line-item results-regexp filename))))
(push item items)))
(cl-incf line-number))))))
(let ((magit-todos-section-heading heading))
(setf (buffer-local-value 'magit-todos-branch-item-cache magit-status-buffer) items)
(magit-todos--insert-items magit-status-buffer items :branch-p t))))))
(defun magit-todos--delete-section (condition)
"Delete the section specified by CONDITION from the Magit status buffer.
See `magit-section-match'. Also delete it from root section's children."
(save-excursion
(goto-char (point-min))
(when-let ((section (cl-loop until (magit-section-match condition)
;; Use `forward-line' instead of `magit-section-forward' because
;; sometimes it skips our section.
do (forward-line 1)
when (eobp)
return nil
finally return (magit-current-section))))
;; Delete the section from root section's children. This makes the section-jumper command
;; work when a replacement section is inserted after deleting this section.
(object-remove-from-list magit-root-section 'children section)
(with-slots (start end) section
;; NOTE: We delete 1 past the end because we insert a newline after the section. I'm not
;; sure if this would generalize to all Magit sections. But if the end is the same as
;; `point-max', which may be the case if todo items have not yet been inserted, we only
;; delete up to `point-max'.
(delete-region start (if (= end (point-max))
end
(1+ end)))))))
(defun magit-todos--item-buffer (item)
"Return buffer visiting ITEM."
(or (find-buffer-visiting (magit-todos-item-filename item))
(find-file-noselect (magit-todos-item-filename item))))
(defun magit-todos--goto-item (item)
"Move point to ITEM.
Assumes current buffer is ITEM's buffer."
(pcase-let* (((cl-struct magit-todos-item position line column keyword) item))
(if position
(goto-char position)
(goto-char (point-min))
(forward-line (1- line))
(if column
(forward-char column)
(when (re-search-forward (regexp-quote keyword) (line-end-position) t)
(goto-char (match-beginning 0)))))))
(defun magit-todos--insert-todos ()
"Insert to-do items into current buffer.
This function should be called from inside a ‘magit-status’ buffer."
(declare (indent defun))
(when (or magit-todos-update-remote
magit-todos-updating
(not (file-remote-p default-directory)))
(when magit-todos-active-scan
;; Avoid running multiple scans for a single magit-status buffer.
(let ((buffer (process-buffer magit-todos-active-scan)))
(when (process-live-p magit-todos-active-scan)
(delete-process magit-todos-active-scan))
(when (buffer-live-p buffer)
(kill-buffer buffer)))
(setq magit-todos-active-scan nil))
(pcase magit-todos-update
((or 't ; Automatic
;; Manual and updating now
(and 'nil (guard magit-todos-updating))
;; Caching and cache expired
(and (pred integerp) (guard (or magit-todos-updating ; Forced update
(>= (float-time
(time-subtract (current-time)
magit-todos-last-update-time))
magit-todos-update)
(null magit-todos-last-update-time)))))
;; Scan and insert.
;; HACK: I don't like setting a special var here, because it seems like lexically binding a
;; special var should follow down the chain, but it isn't working, so we'll do this.
(setq magit-todos-updating t)
(setq magit-todos-active-scan (funcall magit-todos-scanner
:callback #'magit-todos--insert-items
:magit-status-buffer (current-buffer)
:directory default-directory
:depth magit-todos-depth))
(magit-todos--maybe-insert-branch-todos 'rescan))
(_ ; Caching and cache not expired, or not automatic and not manually updating now
(magit-todos--insert-items (current-buffer) magit-todos-item-cache)
(magit-todos--maybe-insert-branch-todos 'cached)))))
(defun magit-todos--maybe-insert-branch-todos (&optional type)
"Insert branch todos when appropriate.
If TYPE is `rescan', rescan for items; if `cached', only insert
cached items, if any; otherwise, use cached items if any, or
rescan."
(when (or (eq magit-todos-branch-list t)
(and (eq magit-todos-branch-list 'branch)
(not (equal (or magit-todos-branch-list-merge-base-ref (magit-main-branch))
(magit-get-current-branch)))))
(if (or (eq 'rescan type)
(and (not (eq 'cached type))
(null magit-todos-branch-item-cache)))
;; TODO: Refactor to just return items and then insert separately.
(magit-todos--scan-with-git-diff
:magit-status-buffer (current-buffer)
:directory default-directory
:depth magit-todos-depth
:heading (format "TODOs (branched from %s)"
(or magit-todos-branch-list-merge-base-ref (magit-main-branch))))
;; Just insert cached items, if any.
(let ((magit-todos-section-heading
(format "TODOs (branched from %s)"
(or magit-todos-branch-list-merge-base-ref (magit-main-branch)))))
(magit-todos--insert-items (current-buffer) magit-todos-branch-item-cache :branch-p t)))))
(cl-defun magit-todos--insert-items (magit-status-buffer items &key branch-p)
"Insert to-do ITEMS into MAGIT-STATUS-BUFFER.
If BRANCH-P is non-nil, do not update `magit-todos-item-cache',
`magit-todos-last-update-time', and `magit-todos-updating'."
(declare (indent defun))
;; NOTE: This could be factored out into some kind of `magit-insert-section-async' macro if necessary.
;; MAYBE: Use `magit-insert-section-body'.
(when (not (buffer-live-p magit-status-buffer))
(message "`magit-todos--insert-items-callback': Callback called for deleted buffer"))
(let* ((items (magit-todos--sort items))
(num-items (length items))
(magit-section-show-child-count t)
;; HACK: "For internal use only." But this makes collapsing the new section work!
(magit-insert-section--parent magit-root-section)
(inhibit-read-only t))
(when (buffer-live-p magit-status-buffer)
;; Don't try to select a killed status buffer
(with-current-buffer magit-status-buffer
(unless branch-p
;; Don't do any of this for the branch-diff scanner.
(when magit-todos-updating
(when (or (null magit-todos-update) ; Manual updates
(integerp magit-todos-update)) ; Caching
(setq magit-todos-item-cache items)
(setq magit-todos-last-update-time (current-time)))
;; HACK: I don't like setting this special var, but it works. See other comment where
;; it's set t.
(setq magit-todos-updating nil)))
(save-excursion
;; Insert items
(goto-char (point-min))
;; Go to insertion position
(goto-char (or (cl-loop for section in magit-todos-insert-after
for pos = (magit-todos--section-end section)
when pos return pos)
(magit-todos--section-end 'bottom)))
;; Insert section
(let* ((group-fns (pcase magit-todos-auto-group-items
('never nil)
('always magit-todos-group-by)
((pred integerp) (when (> num-items magit-todos-auto-group-items)
magit-todos-group-by))
(_ (error "Invalid value for magit-todos-auto-group-items"))))
(magit-todos-show-filenames (not (member 'magit-todos-item-filename group-fns)))
(reminder (if magit-todos-update
"" ; Automatic updates: no reminder
;; Manual updates: remind user
" (update manually)")))
(if (not items)
(unless magit-todos-update
;; Manual updates: Insert section to remind user
(let ((magit-insert-section--parent magit-root-section))
(magit-insert-section (todos)
(magit-insert-heading (concat (propertize magit-todos-section-heading 'face 'magit-section-heading)
" (0)" reminder "\n")))))
(let ((section (magit-todos--insert-groups :type 'todos
:heading (format "%s (%s)%s"
(propertize magit-todos-section-heading 'face 'magit-section-heading)
num-items reminder)
:group-fns group-fns
:items items
:depth 0)))
(magit-todos--set-visibility :section section :num-items num-items)))))))))
(cl-defun magit-todos--insert-groups (&key depth group-fns heading type items)
"Insert ITEMS into grouped Magit section and return the section.
DEPTH sets indentation and should be 0 for a top-level group. It
is automatically incremented when this function calls itself.
GROUP-FNS may be a list of functions to which ITEMS are applied
with `-group-by' to group them. Items are grouped
hierarchically, i.e. when GROUP-FNS has more than one function,
items are first grouped by the first function, then subgroups are
created which group items by subsequent functions.
HEADING is a string which is the group's heading. The count of
items in each group is automatically appended.
TYPE is a symbol which is used by Magit internally to identify
sections."
;; NOTE: `magit-insert-section' seems to bind `magit-section-visibility-cache' to nil, so setting
;; visibility within calls to it probably won't work as intended.
(declare (indent defun))
(let* ((indent (propertize (s-repeat (* 2 depth) " ") 'face nil))
(heading (concat indent heading))
(magit-insert-section--parent (if (= 0 depth)
magit-root-section
magit-insert-section--parent)))
(if (and (consp group-fns)
(> (length group-fns) 0))
;; Insert more groups
(let* ((groups (--> (-group-by (car group-fns) items)
(cl-loop for group in-ref it
;; HACK: Set ":" keys to nil so they'll be grouped together.
do (pcase (car group)
(":" (setf (car group) nil)))
finally return it)
(magit-todos--coalesce-groups it)))
(section (magit-insert-section ((eval type))
(magit-insert-heading heading)
(cl-loop for (group-type . items) in groups
for group-name = (pcase group-type
;; Use "[Other]" instead of empty group name.
;; HACK: ":" is hard-coded, even though the
;; suffix regexp could differ. If users change
;; the suffix so this doesn't apply, it
;; shouldn't cause any problems, it just won't
;; look as pretty.
((or "" ":" 'nil) "[Other]")
(_ (s-chop-suffix ":" group-type)))
do (magit-todos--insert-groups
:depth (1+ depth) :group-fns (cdr group-fns)
:type (intern group-name) :items items
:heading (concat
(if (and magit-todos-fontify-keyword-headers
(member group-name magit-todos-keywords-list))
(propertize group-name 'face (magit-todos--keyword-face group-name))
group-name)
;; Item count
(if (= 1 (length group-fns))
":" ; Let Magit add the count.
;; Add count ourselves.
(concat " " (format "(%s)" (length items)))))))
(when (= 0 depth)
;; Insert a blank line only in the body of the top-level section, so it
;; will appear only when the section is expanded, matching other sections.
(insert "\n")))))
(magit-todos--set-visibility :depth depth :num-items (length items) :section section)
;; Add top-level section to root section's children
(when (= 0 depth)
(push section (oref magit-root-section children)))
;; Don't forget to return the section!
section)
;; Insert individual to-do items
(magit-todos--insert-group :depth (1+ depth) :type type :items items :heading heading))))
(cl-defun magit-todos--insert-group (&key depth heading type items)
"Insert ITEMS into Magit section and return the section.
DEPTH sets indentation and should be 0 for a top-level group.
HEADING is a string which is the group's heading. The count of
items in each group is automatically appended.
TYPE is a symbol which is used by Magit internally to identify
sections."
;; NOTE: `magit-insert-section' seems to bind `magit-section-visibility-cache' to nil, so setting
;; visibility within calls to it probably won't work as intended.
(declare (indent defun))
(let* ((indent (propertize (s-repeat (* 2 depth) " ") 'face nil))
(magit-insert-section--parent (if (= 0 depth)
magit-root-section
magit-insert-section--parent))
(width (- (window-text-width) depth))
(section (magit-insert-section ((eval type))
(magit-insert-heading heading)
(dolist (item items)
(let* ((filename (propertize (magit-todos-item-filename item) 'face 'magit-filename))
(string (--> (concat indent
(when magit-todos-show-filenames
(when magit-todos-filename-filter
(setf filename (funcall magit-todos-filename-filter filename)))
(concat filename " "))
(funcall (if (s-suffix? ".org" filename)
#'magit-todos--format-org
#'magit-todos--format-plain)
item))
(truncate-string-to-width it width))))
(magit-insert-section (todos-item item)
(insert string "\n")))))))
(magit-todos--set-visibility :depth depth :num-items (length items) :section section)
;; Don't forget to return the section!
section))
(cl-defun magit-todos--set-visibility (&key section num-items depth)
"Set the visibility of SECTION.
If the section's visibility is cached by Magit, the cached
setting is applied. Otherwise, visibility is set according to
whether NUM-ITEMS is greater than `magit-todos-max-items'.
When DEPTH is greater than 0, NUM-ITEMS is compared to
`magit-todos-max-items' divided by DEPTH multiplied by 2,
i.e. the max number of items which cause sections to be
automatically hidden halves at each deeper level."
(declare (indent defun))
(pcase (magit-section-cached-visibility section)
('hide (magit-section-hide section))
('show (magit-section-show section))
(_ (if (> num-items (pcase depth
(0 magit-todos-max-items)
(_ (/ magit-todos-max-items (* depth 2)))))
;; HACK: We have to do this manually because the set-visibility-hook doesn't work.
(magit-section-hide section)
;; Not hidden: show section manually (necessary for some reason)
(magit-section-show section)))))
(cl-defun magit-todos--line-item (regexp &optional filename)
"Return item on current line, parsing current buffer with REGEXP.
FILENAME is added to the item as its filename. Sets match data.
This should be called in a process's output buffer from one of
the async callback functions. The calling function should
advance to the next line."
(let ((case-fold-search magit-todos-ignore-case))
(when (condition-case err
(re-search-forward regexp (line-end-position) t)
(error (if (string-match-p "Stack overflow in regexp matcher" (error-message-string err))
;; FIXME: This may raise multiple warnings per file.
(let ((filename (buffer-substring (point) (1- (re-search-forward ":")))))
(message "magit-todos: File has lines too long for Emacs to search. Consider excluding it from scans: %s" filename)
nil)
(signal (car err) (cdr err)))))
(make-magit-todos-item :filename (or filename
(match-string 8))
:line (--when-let (match-string 2)
(string-to-number it))
:column (--when-let (match-string 3)
(string-to-number it))