aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 12:30:30 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 12:30:30 -0500
commit3367f238927a9c17c6429025bc913e913efb60ce (patch)
treeaf2db140f3de11ea4bbec9e0c336023f09f6e024
parent4c623eff69aca86026a4985f0ebf004989ab0d2d (diff)
downloaddotemacs-3367f238927a9c17c6429025bc913e913efb60ce.tar.gz
dotemacs-3367f238927a9c17c6429025bc913e913efb60ce.zip
feat(face-diagnostic): Phase 3 per-face provenance trace
Add group 5 to the diagnostic core: per-face provenance. cj/--face-diag-provenance reports, for each named face in the stack, which themes set it (theme-face), whether config saved or customized it (saved-face / customized-face), its :inherit chain, and the attributes still unspecified after inherit-following (the ones that fall through to the default -- the direct read on the all-white-elfeed class of bug). The version-sensitive theme-face / saved-face internals sit behind small accessors that treat missing properties as absent rather than erroring. 30 ERT tests, byte-compile clean.
-rw-r--r--modules/face-diagnostic.el78
-rw-r--r--tests/test-face-diagnostic.el53
-rw-r--r--todo.org4
3 files changed, 129 insertions, 6 deletions
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
index 1b7ef10d4..22f80cd98 100644
--- a/modules/face-diagnostic.el
+++ b/modules/face-diagnostic.el
@@ -208,18 +208,88 @@ the real family differs from the merged :family."
"unknown")
:family (ignore-errors (font-get font :family)))))))
+;; ------------------------------ Provenance -----------------------------------
+;; Where a named face's attributes come from: which themes set it, whether
+;; config saved/customized it, its :inherit chain, and which attributes stay
+;; unspecified so they fall through to the default. The theme-face and
+;; saved-face properties are version-sensitive internals, read behind small
+;; accessors and treated as absent rather than erroring when missing.
+
+(defun cj/--face-diag-face-themes (face)
+ "Return the themes that set FACE, newest first, from its `theme-face' property."
+ (when (symbolp face)
+ (mapcar #'car (get face 'theme-face))))
+
+(defun cj/--face-diag-config-source (face)
+ "Return how config set FACE: `saved', `customized', or nil.
+`saved' is a persisted customize (saved-face); `customized' is an unsaved
+customize this session. A plain `set-face-attribute' leaves no marker and so
+reads as nil."
+ (cond
+ ((get face 'saved-face) 'saved)
+ ((get face 'customized-face) 'customized)
+ (t nil)))
+
+(defun cj/--face-diag-inherit-chain (face)
+ "Return FACE's :inherit chain as a list of faces, nearest first.
+Follows single-symbol :inherit links, guarding against cycles; a list-valued
+:inherit is recorded and the walk stops there."
+ (let ((chain '()) (cur face) (seen '()))
+ (while (and cur (symbolp cur) (facep cur) (not (memq cur seen)))
+ (push cur seen)
+ (let ((inh (face-attribute cur :inherit nil)))
+ (cond
+ ((or (null inh) (eq inh 'unspecified)) (setq cur nil))
+ ((symbolp inh) (setq chain (append chain (list inh))) (setq cur inh))
+ ((listp inh) (setq chain (append chain inh)) (setq cur nil))
+ (t (setq cur nil)))))
+ chain))
+
+(defun cj/--face-diag-unspecified-attrs (face)
+ "Return attributes still unspecified on FACE after inherit-following.
+These fall through to the default face -- the direct read on an
+\"attribute never set\" bug like the all-white elfeed case."
+ (when (facep face)
+ (seq-filter (lambda (attr)
+ (eq (face-attribute face attr nil t) 'unspecified))
+ cj/--face-diag-attributes)))
+
+(defun cj/--face-diag-face-provenance (face)
+ "Return the provenance plist for the named FACE.
+Keys: :face, :themes (list), :config (`saved'/`customized'/nil),
+:inherit-chain (list of faces), :unspecified (attributes falling to default)."
+ (list :face face
+ :themes (cj/--face-diag-face-themes face)
+ :config (cj/--face-diag-config-source face)
+ :inherit-chain (cj/--face-diag-inherit-chain face)
+ :unspecified (cj/--face-diag-unspecified-attrs face)))
+
+(defun cj/--face-diag-provenance (pos &optional buffer)
+ "Return per-face provenance for the named faces in the stack at POS in BUFFER.
+A list of provenance plists for the distinct real faces contributing at POS:
+text-property and overlay face symbols, then the default."
+ (let* ((tp (seq-filter #'symbolp (cj/--face-diag-text-property-faces pos buffer)))
+ (ov (delq nil (mapcar (lambda (e)
+ (let ((f (plist-get e :face)))
+ (and (symbolp f) f)))
+ (cj/--face-diag-overlay-faces pos buffer))))
+ (faces (seq-filter #'facep (seq-uniq (append ov tp '(default))))))
+ (mapcar #'cj/--face-diag-face-provenance faces)))
+
;; ------------------------------- Assembled core ------------------------------
(defun cj/--face-diagnosis-at (pos &optional buffer)
- "Return the face-diagnosis plist for POS in BUFFER (groups 0-4).
+ "Return the face-diagnosis plist for POS in BUFFER (groups 0-5).
Keys: :classification (symbol), :char (plist or nil at end-of-buffer), :stack
-\(plist), :attributes (computed merged plist), :font (real-font plist). Pure:
-no prompts, no display, no buffer or frame mutation."
+\(plist), :attributes (computed merged plist), :font (real-font plist),
+:provenance (per-face list). Pure: no prompts, no display, no buffer or frame
+mutation."
(list :classification (cj/--face-diag-classify-buffer buffer)
:char (cj/--face-diag-char-context pos buffer)
:stack (cj/--face-diag-stack pos buffer)
:attributes (cj/--face-diag-merged-attributes pos buffer)
- :font (cj/--face-diag-real-font pos buffer)))
+ :font (cj/--face-diag-real-font pos buffer)
+ :provenance (cj/--face-diag-provenance pos buffer)))
(provide 'face-diagnostic)
;;; face-diagnostic.el ends here
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
index 0a62f308d..874893fb6 100644
--- a/tests/test-face-diagnostic.el
+++ b/tests/test-face-diagnostic.el
@@ -218,5 +218,58 @@
(should (equal (plist-get (plist-get diag :attributes) :foreground) "#abcdef"))
(should (equal (plist-get (plist-get diag :font) :font) "unavailable")))))
+;;; provenance accessors
+
+(ert-deftest test-face-diag-face-themes ()
+ "Normal: theme names come from the face's theme-face property, newest first."
+ (make-face 'fd-test-themed)
+ (put 'fd-test-themed 'theme-face '((user spec1) (dupre spec2)))
+ (should (equal (cj/--face-diag-face-themes 'fd-test-themed) '(user dupre))))
+
+(ert-deftest test-face-diag-config-source ()
+ "Normal/Boundary: saved-face -> saved, customized-face -> customized, else nil."
+ (make-face 'fd-test-saved)
+ (put 'fd-test-saved 'saved-face '(spec))
+ (make-face 'fd-test-cust)
+ (put 'fd-test-cust 'customized-face '(spec))
+ (make-face 'fd-test-plain)
+ (should (eq (cj/--face-diag-config-source 'fd-test-saved) 'saved))
+ (should (eq (cj/--face-diag-config-source 'fd-test-cust) 'customized))
+ (should-not (cj/--face-diag-config-source 'fd-test-plain)))
+
+(ert-deftest test-face-diag-inherit-chain ()
+ "Normal: a single-symbol :inherit produces a nearest-first chain."
+ (make-face 'fd-test-parent)
+ (make-face 'fd-test-child)
+ (set-face-attribute 'fd-test-child nil :inherit 'fd-test-parent)
+ (should (equal (cj/--face-diag-inherit-chain 'fd-test-child) '(fd-test-parent))))
+
+(ert-deftest test-face-diag-inherit-chain-none ()
+ "Boundary: a face with no :inherit has an empty chain."
+ (make-face 'fd-test-noinherit)
+ (should-not (cj/--face-diag-inherit-chain 'fd-test-noinherit)))
+
+(ert-deftest test-face-diag-unspecified-attrs ()
+ "Normal: a bare face leaves attributes unspecified, so they fall to default."
+ (make-face 'fd-test-bare)
+ (should (memq :foreground (cj/--face-diag-unspecified-attrs 'fd-test-bare))))
+
+(ert-deftest test-face-diag-provenance-covers-stack-and-default ()
+ "Normal: provenance covers the stack's named faces and always the default."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (let ((faces (mapcar (lambda (p) (plist-get p :face))
+ (cj/--face-diag-provenance (point-min)))))
+ (should (memq 'bold faces))
+ (should (memq 'default faces)))))
+
+(ert-deftest test-face-diagnosis-at-includes-provenance ()
+ "Normal: the assembled core carries the provenance group for stack faces."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "x" 'face 'bold))
+ (let ((prov (plist-get (cj/--face-diagnosis-at (point-min)) :provenance)))
+ (should (cl-some (lambda (p) (eq (plist-get p :face) 'bold)) prov)))))
+
(provide 'test-face-diagnostic)
;;; test-face-diagnostic.el ends here
diff --git a/todo.org b/todo.org
index e7ecd1438..01f450951 100644
--- a/todo.org
+++ b/todo.org
@@ -50,8 +50,8 @@ Read-only popup diagnosing why text at point paints as it does (face stack by so
modules/face-diagnostic.el: cj/--face-diagnosis-at returns groups 0-2 (buffer classification, character context, face stack by source) via small pure helpers. 17 ERT tests (tests/test-face-diagnostic.el), byte-compile clean. Not yet wired into init.el; the interactive command and keybinding land in Phase 4.
*** 2026-06-15 Mon @ 12:26:52 -0500 Phase 2 — merged attributes + real font landed
cj/--face-diag-merged-attributes folds the ordered, remap-expanded spec stack ("computed"); cj/--face-diag-real-font reports font-at or "unavailable" under batch. Settles spec decision #7 (hand-fold, tested on overlay-over-text-prop, default-remap, and face-symbol fixtures). 23 ERT tests total, byte-compile clean.
-*** TODO Phase 3 — provenance trace
-Add group 5: per-face theme/config/inherit provenance and the unspecified->fallback resolution, behind small accessors isolating the theme-face / saved-face internals. Fixtures: a face set via a loaded theme, via set-face-attribute, and one attribute left unspecified.
+*** 2026-06-15 Mon @ 12:30:30 -0500 Phase 3 — provenance trace landed
+cj/--face-diag-provenance returns per-face provenance: themes from theme-face, config from saved/customized-face, the :inherit chain, and the attributes still unspecified that fall to the default. Version-sensitive internals sit behind small tolerant accessors. 30 ERT tests total, byte-compile clean.
*** TODO Phase 4 — render + popup wiring
cj/describe-face-at-point, the read-only mode with face buttons, region-scan mode, and placement/dismissal via the unified-popup rules. Settle the command name and keybinding here. Render function tested on a captured plist; live smoke test.
** TODO [#D] Face diagnostic popup — theme-studio bridge (vNext) :feature: