;;; duet-complexity.el --- McCabe complexity scanner for duet -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Craig Jennings ;; Author: Craig Jennings ;; 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 . ;;; 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