diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-15 12:30:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-15 12:30:30 -0500 |
| commit | 3367f238927a9c17c6429025bc913e913efb60ce (patch) | |
| tree | af2db140f3de11ea4bbec9e0c336023f09f6e024 | |
| parent | 4c623eff69aca86026a4985f0ebf004989ab0d2d (diff) | |
| download | dotemacs-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.el | 78 | ||||
| -rw-r--r-- | tests/test-face-diagnostic.el | 53 | ||||
| -rw-r--r-- | todo.org | 4 |
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 @@ -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: |
