aboutsummaryrefslogtreecommitdiff
path: root/scripts/duet-complexity.el
blob: 63d59cf5070da7f43a20dc081ee92680b6846c24 (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
;;; duet-complexity.el --- McCabe complexity scanner for duet -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Craig Jennings

;; Author: Craig Jennings <c@cjennings.net>

;; 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/>.

;;; Commentary:

;; A small, dependency-free cyclomatic-complexity scanner behind
;; `make complexity'.  No off-the-shelf tool measures Emacs Lisp (lizard does
;; not support it; codemetrics is an interactive tree-sitter overlay, a poor
;; fit for a headless gate), so DUET owns this one.
;;
;; The metric is McCabe's: a function's complexity is 1 plus the number of
;; decision points in its body.  A decision point is a branch-introducing form:
;;
;;   - `if' / `when' / `unless'                     +1 each
;;   - `while' / `dolist' / `dotimes' / `cl-loop'   +1 each (and cl- variants)
;;   - `cond'                                       +1 per clause
;;   - `pcase' / `pcase-exhaustive'                 +1 per clause
;;   - `cl-case' / `case' / `cl-typecase' (+ e-)    +1 per clause
;;   - `condition-case'                             +1 per handler
;;   - `and' / `or'                                 +1 per operand beyond the first
;;
;; The counting is pure — it operates on already-read forms — so it is fully
;; unit-testable without I/O.  Only `duet-complexity-scan-file' touches disk.
;;
;; Known limitations (acceptable for an advisory soft-budget gate): an inline
;; lambda's complexity is attributed to its enclosing function rather than
;; counted separately, and backquoted templates are walked as ordinary code.
;; The budget is soft (the spec allows a written justification past ~8-10), so
;; the bias toward flagging is deliberate.

;;; Code:

(require 'seq)

(defconst duet-complexity-default-threshold 10
  "Default soft cyclomatic-complexity budget.
A function above this is split or carries a written justification (see the
design spec's \"Complexity and refactoring controls\").")

(defconst duet-complexity-defun-heads
  '(defun defmacro defsubst cl-defun cl-defmacro cl-defsubst)
  "Top-level forms the scanner measures as functions.")

(defun duet-complexity--clause-rest (clauses)
  "Return every CLAUSE's `cdr' appended, skipping each clause head.
Used where a clause head is data, not executable code: `pcase' patterns,
`cl-case' key lists, and `condition-case' handler condition names.  Skipping
the head avoids miscounting a `pcase' `or'/`and' pattern as a boolean form."
  (apply #'append
         (mapcar (lambda (c) (and (consp c) (copy-sequence (cdr c)))) clauses)))

(defun duet-complexity--clause-all (clauses)
  "Return every element of each CLAUSE in CLAUSES appended.
Used for `cond', where a clause's condition is itself executable code that may
contain nested decisions."
  (apply #'append
         (mapcar (lambda (c) (and (consp c) (copy-sequence c))) clauses)))

(defun duet-complexity--sum (forms)
  "Return the total decision points across FORMS."
  (apply #'+ (mapcar #'duet-complexity--decision-points forms)))

(defun duet-complexity--decision-points (form)
  "Return the number of McCabe decision points in FORM, recursively."
  (if (not (consp form))
      0
    (let ((head (car form)))
      (cond
       ((eq head 'quote) 0)
       ((memq head '(if when unless while dolist dotimes
                        cl-dolist cl-dotimes cl-loop))
        (+ 1 (duet-complexity--sum (cdr form))))
       ((memq head '(and or))
        (+ (max 0 (1- (length (cdr form))))
           (duet-complexity--sum (cdr form))))
       ((eq head 'cond)
        (+ (length (cdr form))
           (duet-complexity--sum (duet-complexity--clause-all (cdr form)))))
       ((memq head '(pcase pcase-exhaustive
                           cl-case case cl-ecase cl-typecase cl-etypecase))
        (+ (length (cddr form))
           (duet-complexity--decision-points (cadr form))
           (duet-complexity--sum (duet-complexity--clause-rest (cddr form)))))
       ((eq head 'condition-case)
        (+ (length (cdddr form))
           (duet-complexity--decision-points (caddr form))
           (duet-complexity--sum (duet-complexity--clause-rest (cdddr form)))))
       (t
        (duet-complexity--sum (cdr form)))))))

(defun duet-complexity-of-form (form)
  "Return the cyclomatic complexity of defun-like FORM.
FORM is a `(HEAD NAME ARGLIST . BODY)' list; the result is 1 plus the decision
points in its body."
  (1+ (duet-complexity--sum (nthcdr 3 form))))

(defun duet-complexity--read-forms (file)
  "Return the list of top-level forms read from FILE."
  (with-temp-buffer
    (insert-file-contents file)
    (goto-char (point-min))
    (let (forms)
      (condition-case nil
          (while t (push (read (current-buffer)) forms))
        (end-of-file (nreverse forms))))))

(defun duet-complexity-scan-file (file)
  "Return `(NAME . COMPLEXITY)' pairs for each defun-like form in FILE."
  (let (results)
    (dolist (form (duet-complexity--read-forms file) (nreverse results))
      (when (and (consp form)
                 (memq (car form) duet-complexity-defun-heads)
                 (symbolp (nth 1 form)))
        (push (cons (nth 1 form) (duet-complexity-of-form form)) results)))))

(defun duet-complexity-over-threshold (results threshold)
  "Return RESULTS entries whose complexity exceeds THRESHOLD."
  (seq-filter (lambda (r) (> (cdr r) threshold)) results))

(defun duet-complexity-batch (files threshold)
  "Scan FILES and print per-function complexity to standard output.
Exit non-zero when any function's complexity exceeds THRESHOLD.  Entry point
for `make complexity'."
  (let ((offenders nil)
        (scanned 0))
    (dolist (file files)
      (let ((results (duet-complexity-scan-file file)))
        (princ (format "\n%s\n" file))
        (dolist (r (sort (copy-sequence results)
                         (lambda (a b) (> (cdr a) (cdr b)))))
          (setq scanned (1+ scanned))
          (princ (format "  %3d  %s%s\n"
                         (cdr r) (car r)
                         (if (> (cdr r) threshold) "  <-- over budget" ""))))
        (setq offenders
              (append offenders
                      (mapcar (lambda (r) (cons file r))
                              (duet-complexity-over-threshold results threshold))))))
    (princ (format "\nScanned %d function(s); budget = %d.\n" scanned threshold))
    (if offenders
        (progn
          (princ (format "%d function(s) over budget:\n" (length offenders)))
          (dolist (o offenders)
            (princ (format "  %s: %s (%d)\n" (car o) (cadr o) (cddr o))))
          (kill-emacs 1))
      (princ "All functions within budget.\n"))))

(provide 'duet-complexity)
;;; duet-complexity.el ends here