diff options
Diffstat (limited to 'scripts/duet-complexity.el')
| -rw-r--r-- | scripts/duet-complexity.el | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/scripts/duet-complexity.el b/scripts/duet-complexity.el new file mode 100644 index 0000000..63d59cf --- /dev/null +++ b/scripts/duet-complexity.el @@ -0,0 +1,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 |
