aboutsummaryrefslogtreecommitdiff
path: root/modules/face-diagnostic.el
diff options
context:
space:
mode:
Diffstat (limited to 'modules/face-diagnostic.el')
-rw-r--r--modules/face-diagnostic.el456
1 files changed, 456 insertions, 0 deletions
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
new file mode 100644
index 000000000..6b1b547f1
--- /dev/null
+++ b/modules/face-diagnostic.el
@@ -0,0 +1,456 @@
+;;; face-diagnostic.el --- Diagnose the face and font at point -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Layer: 4 (Added features).
+;; Category: O (optional command).
+;; Load shape: eager.
+;; Eager reason: none; a diagnostic command, a command-loaded deferral candidate.
+;; Top-level side effects: defines cj/face-diagnostic-mode and the
+;; cj/describe-face-at-point command; binds it to C-h F in help-map.
+;; Runtime requires: seq.
+;; Direct test load: yes (the pure core is tested by requiring this module).
+;;
+;; A read-only diagnostic for "why does the character at point paint this way?"
+;; It separates the face stack by source (text properties, overlays, active
+;; remaps, the default) and -- in later phases -- the merged attributes, the
+;; real font versus the declared family, and per-face theme/config/inherit
+;; provenance. See docs/specs/face-font-diagnostic-popup-spec-implemented.org.
+;;
+;; This file is Phase 1: the pure read model. `cj/--face-diagnosis-at' returns
+;; a plist with the buffer classification, the character context, and the face
+;; stack by source. No prompts, no display -- the interactive command and the
+;; rendering land in a later phase.
+
+;;; Code:
+
+(require 'seq)
+
+;; ------------------------------ Buffer classify ------------------------------
+
+(defun cj/--face-diag-classify-buffer (&optional buffer)
+ "Classify BUFFER (default current) for face-diagnosis scope.
+Return one of `theme-faced', `terminal-ansi', `document-shr', or
+`image-no-text', from the major mode. Out-of-scope buckets get a banner and a
+best-effort dump rather than a full provenance trace."
+ (with-current-buffer (or buffer (current-buffer))
+ (cond
+ ((derived-mode-p 'term-mode 'comint-mode 'eshell-mode 'ghostel-mode)
+ 'terminal-ansi)
+ ((derived-mode-p 'eww-mode 'nov-mode 'elfeed-show-mode 'mu4e-view-mode)
+ 'document-shr)
+ ((derived-mode-p 'image-mode 'doc-view-mode 'pdf-view-mode)
+ 'image-no-text)
+ (t 'theme-faced))))
+
+;; ----------------------------- Character context -----------------------------
+
+(defun cj/--face-diag-char-context (pos &optional buffer)
+ "Return a plist for the character at POS in BUFFER, or nil when there is none.
+Keys: :char (the character), :codepoint (its integer value), :name (the Unicode
+name string or nil), :script (the script symbol or nil)."
+ (with-current-buffer (or buffer (current-buffer))
+ (let ((ch (char-after pos)))
+ (when ch
+ (list :char ch
+ :codepoint ch
+ :name (get-char-code-property ch 'name)
+ :script (aref char-script-table ch))))))
+
+;; ------------------------------- Face stack ----------------------------------
+
+(defun cj/--face-diag-normalize-faces (val)
+ "Normalize a `face'-style property VAL into a list of faces or specs.
+A face symbol or an anonymous (:attr val ...) plist becomes a one-element list;
+a list of faces is returned as-is; nil becomes nil."
+ (cond
+ ((null val) nil)
+ ((symbolp val) (list val))
+ ((keywordp (car-safe val)) (list val)) ; anonymous spec, e.g. (:foreground "red")
+ ((listp val) val)
+ (t (list val))))
+
+(defun cj/--face-diag-text-property-faces (pos &optional buffer)
+ "Return the faces from the `face' and `font-lock-face' props at POS in BUFFER.
+The two properties are concatenated in that order, each normalized to a list."
+ (with-current-buffer (or buffer (current-buffer))
+ (let ((result '()))
+ (dolist (prop '(face font-lock-face))
+ (setq result (append result
+ (cj/--face-diag-normalize-faces
+ (get-text-property pos prop)))))
+ result)))
+
+(defun cj/--face-diag-overlay-faces (pos &optional buffer)
+ "Return overlay face entries covering POS in BUFFER, highest priority first.
+Each entry is a plist with :face, :priority (number or nil), and :overlay.
+Overlays without a `face' property are skipped."
+ (with-current-buffer (or buffer (current-buffer))
+ (let ((entries
+ (delq nil
+ (mapcar
+ (lambda (ov)
+ (let ((face (overlay-get ov 'face)))
+ (when face
+ (list :face face
+ :priority (overlay-get ov 'priority)
+ :overlay ov))))
+ (overlays-at pos)))))
+ (sort entries
+ (lambda (a b)
+ (> (or (plist-get a :priority) 0)
+ (or (plist-get b :priority) 0)))))))
+
+(defun cj/--face-diag-active-remaps (faces &optional buffer)
+ "Return the `face-remapping-alist' entries in BUFFER that remap any of FACES.
+FACES is a list of face symbols from the stack. Each result is the raw
+\(FACE . SPEC) entry from the alist."
+ (with-current-buffer (or buffer (current-buffer))
+ (seq-filter (lambda (entry) (memq (car-safe entry) faces))
+ face-remapping-alist)))
+
+(defun cj/--face-diag-stack (pos &optional buffer)
+ "Return the face stack at POS in BUFFER as a plist separated by source.
+Keys: :text-property (list of faces/specs), :overlays (list of plists),
+:remaps (matching `face-remapping-alist' entries), :default (the symbol
+`default')."
+ (let* ((tp (cj/--face-diag-text-property-faces pos buffer))
+ (ov (cj/--face-diag-overlay-faces pos buffer))
+ (stack-syms
+ (append (seq-filter #'symbolp tp)
+ (delq nil (mapcar (lambda (e)
+ (let ((f (plist-get e :face)))
+ (and (symbolp f) f)))
+ ov))
+ '(default))))
+ (list :text-property tp
+ :overlays ov
+ :remaps (cj/--face-diag-active-remaps stack-syms buffer)
+ :default 'default)))
+
+;; -------------------------- Effective merged attributes ----------------------
+;; Emacs exposes no single call for the final merged attribute plist at a
+;; position (the C redisplay engine merges text-prop + overlay faces, applies
+;; remaps, and picks a font). The core folds the ordered, remap-expanded spec
+;; list itself and labels the result "computed": exotic relative-height or deep
+;; :inherit cases may diverge slightly from the engine.
+
+(defconst cj/--face-diag-attributes
+ '(:family :height :weight :slant :foreground :background
+ :underline :overline :strike-through :box :inverse-video)
+ "Face attributes reported in the effective-merge group, in display order.")
+
+(defun cj/--face-diag-spec-attr (spec attr)
+ "Return ATTR's value from a single face SPEC, or the symbol `unspecified'.
+A face symbol resolves through `face-attribute' (following :inherit); an
+attribute plist is read directly; anything else is `unspecified'."
+ (cond
+ ((and spec (symbolp spec)) (face-attribute spec attr nil t))
+ ((and (consp spec) (keywordp (car spec)))
+ (if (plist-member spec attr) (plist-get spec attr) 'unspecified))
+ (t 'unspecified)))
+
+(defun cj/--face-diag-remap-specs (face &optional buffer)
+ "Return the remap specs for FACE from `face-remapping-alist' in BUFFER, or nil.
+Only symbol faces are looked up. The remapping is normalized to a list of
+specs: a lone face symbol or an attribute plist becomes a one-element list."
+ (with-current-buffer (or buffer (current-buffer))
+ (when (symbolp face)
+ (let ((entry (assq face face-remapping-alist)))
+ (when entry
+ (let ((remap (cdr entry)))
+ (cond
+ ((null remap) nil)
+ ((keywordp (car-safe remap)) (list remap)) ; (:attr val ...)
+ ((listp remap) remap) ; (spec spec ...)
+ (t (list remap))))))))) ; a lone face symbol
+
+(defun cj/--face-diag-ordered-specs (pos &optional buffer)
+ "Return the ordered face specs at POS in BUFFER, highest priority first.
+Overlay faces (priority descending), then text-property faces, then the
+default. Each contributing face's remap specs come ahead of the face itself,
+mirroring how a remap overrides its base."
+ (let ((bases (append (mapcar (lambda (e) (plist-get e :face))
+ (cj/--face-diag-overlay-faces pos buffer))
+ (cj/--face-diag-text-property-faces pos buffer)
+ '(default)))
+ (specs '()))
+ (dolist (face bases)
+ (setq specs (append specs
+ (cj/--face-diag-remap-specs face buffer)
+ (list face))))
+ specs))
+
+(defun cj/--face-diag-merged-attributes (pos &optional buffer)
+ "Return the computed effective attribute plist at POS in BUFFER.
+For each attribute the first non-`unspecified' value down the ordered,
+remap-expanded spec list wins; if none specifies it the value is `unspecified'."
+ (let ((specs (cj/--face-diag-ordered-specs pos buffer))
+ (result '()))
+ (dolist (attr cj/--face-diag-attributes)
+ (let ((found (seq-some (lambda (spec)
+ (let ((v (cj/--face-diag-spec-attr spec attr)))
+ (unless (eq v 'unspecified) (list v))))
+ specs)))
+ (setq result (append result (list attr (if found (car found) 'unspecified))))))
+ result))
+
+;; ------------------------------- Real font -----------------------------------
+
+(defun cj/--face-diag-real-font (pos &optional buffer)
+ "Return a plist for the font actually used at POS in BUFFER.
+Keys: :font (the font's name, or \"unavailable\") and :family (its family or
+nil). `font-at' is nil in batch and on text terminals, reported as
+\"unavailable\" rather than an error -- this exposes fontset substitution when
+the real family differs from the merged :family."
+ (with-current-buffer (or buffer (current-buffer))
+ (let ((font (ignore-errors (font-at pos))))
+ (if (null font)
+ (list :font "unavailable" :family nil)
+ (list :font (or (ignore-errors (font-get font :name))
+ (ignore-errors (aref (query-font font) 0))
+ "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-5).
+Keys: :classification (symbol), :char (plist or nil at end-of-buffer), :stack
+\(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)
+ :provenance (cj/--face-diag-provenance pos buffer)))
+
+;; ------------------------------- Rendering -----------------------------------
+
+(defun cj/--face-diag-render-banner (classification)
+ "Return a one-line banner for an out-of-scope CLASSIFICATION, or \"\"."
+ (pcase classification
+ ('terminal-ansi
+ "NOTE: terminal buffer -- colors come from the ANSI palette, not the theme.\n\n")
+ ('document-shr
+ "NOTE: document buffer -- colors come from the rendered document, not the theme.\n\n")
+ ('image-no-text
+ "NOTE: image/no-text buffer -- little face information applies here.\n\n")
+ (_ "")))
+
+(defun cj/--face-diag-render-char (char)
+ "Render the CHAR context plist as a line, or a no-character notice."
+ (if (null char)
+ "Character: none at point.\n\n"
+ (format "Character: %S (U+%04X %s, script: %s)\n\n"
+ (plist-get char :char)
+ (plist-get char :codepoint)
+ (or (plist-get char :name) "no name")
+ (or (plist-get char :script) "none"))))
+
+(defun cj/--face-diag-render-faces (faces)
+ "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\"."
+ (if faces (mapconcat (lambda (f) (format "%s" f)) faces ", ") "(none)"))
+
+(defun cj/--face-diag-render-stack (stack)
+ "Render the STACK plist (faces by source) as a block."
+ (concat
+ "Face stack (highest priority first):\n"
+ (format " text properties: %s\n"
+ (cj/--face-diag-render-faces (plist-get stack :text-property)))
+ (format " overlays: %s\n"
+ (let ((ov (plist-get stack :overlays)))
+ (if ov
+ (mapconcat (lambda (e)
+ (format "%s (priority %s)"
+ (plist-get e :face)
+ (or (plist-get e :priority) "nil")))
+ ov ", ")
+ "(none)")))
+ (format " active remaps: %s\n"
+ (let ((rm (plist-get stack :remaps)))
+ (if rm (mapconcat (lambda (e) (format "%s" (car e))) rm ", ") "(none)")))
+ " default: default\n\n"))
+
+(defun cj/--face-diag-render-attributes (attrs)
+ "Render the merged ATTRS plist as a block."
+ (concat
+ "Effective attributes (computed):\n"
+ (mapconcat (lambda (attr) (format " %s: %s" attr (plist-get attrs attr)))
+ cj/--face-diag-attributes "\n")
+ "\n\n"))
+
+(defun cj/--face-diag-render-font (font attrs)
+ "Render the real FONT plist beside the merged ATTRS declared :family."
+ (let ((real (plist-get font :font))
+ (declared (plist-get attrs :family))
+ (real-family (plist-get font :family)))
+ (concat
+ (format "Real font: %s\n" real)
+ (format "Declared family: %s\n" declared)
+ (if (and (stringp real-family) (stringp declared)
+ (not (string-equal-ignore-case real-family declared)))
+ (format " (substituted: real family %s differs from declared %s)\n\n"
+ real-family declared)
+ "\n"))))
+
+(defun cj/--face-diag-render-provenance (prov)
+ "Render the per-face PROV list as a block."
+ (concat
+ "Provenance:\n"
+ (if prov
+ (mapconcat
+ (lambda (p)
+ (format (concat " %s\n themes: %s\n config: %s\n"
+ " inherits: %s\n unspecified (-> default): %s")
+ (plist-get p :face)
+ (or (plist-get p :themes) "(none)")
+ (or (plist-get p :config) "(none)")
+ (or (plist-get p :inherit-chain) "(none)")
+ (or (plist-get p :unspecified) "(none)")))
+ prov "\n")
+ " (no named faces)")
+ "\n"))
+
+(defun cj/--face-diag-render (diag)
+ "Render the face-diagnosis DIAG plist into a report string."
+ (concat
+ (cj/--face-diag-render-banner (plist-get diag :classification))
+ (cj/--face-diag-render-char (plist-get diag :char))
+ (cj/--face-diag-render-stack (plist-get diag :stack))
+ (cj/--face-diag-render-attributes (plist-get diag :attributes))
+ (cj/--face-diag-render-font (plist-get diag :font) (plist-get diag :attributes))
+ (cj/--face-diag-render-provenance (plist-get diag :provenance))))
+
+;; ------------------------------- Region mode ---------------------------------
+
+(defun cj/--face-diag-run-starts (beg end)
+ "Return the positions in [BEG, END) where the `face' property run begins."
+ (let ((pos beg) (starts (list beg)))
+ (while (and (setq pos (next-single-property-change pos 'face nil end))
+ (< pos end))
+ (push pos starts))
+ (nreverse starts)))
+
+(defun cj/--face-diag-render-region (beg end)
+ "Render a diagnosis for each distinct face-run in [BEG, END), capped at 20."
+ (let* ((starts (cj/--face-diag-run-starts beg end))
+ (cap 20)
+ (shown (seq-take starts cap)))
+ (concat
+ (mapconcat (lambda (pos)
+ (concat (format "=== position %d ===\n" pos)
+ (cj/--face-diag-render (cj/--face-diagnosis-at pos))))
+ shown "\n")
+ (when (> (length starts) cap)
+ (format "\n... %d more face-runs not shown (cap %d).\n"
+ (- (length starts) cap) cap)))))
+
+;; ------------------------------- Command -------------------------------------
+
+(define-derived-mode cj/face-diagnostic-mode special-mode "Face-Diag"
+ "Major mode for the read-only face/font diagnosis report.")
+
+(defun cj/--face-diag-display (report)
+ "Show REPORT in the read-only *Face Diagnosis* buffer; return the buffer."
+ (let ((buf (get-buffer-create "*Face Diagnosis*")))
+ (with-current-buffer buf
+ (cj/face-diagnostic-mode)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert report)
+ (goto-char (point-min))))
+ (display-buffer buf)
+ buf))
+
+(defun cj/describe-face-at-point ()
+ "Pop up a read-only diagnosis of the face and font at point.
+With an active region, diagnose each distinct face-run in the region. The
+report separates the face stack by source, shows the computed merged
+attributes, the real font versus the declared family, and per-face
+theme/config/inherit provenance. Read-only; never mutates buffer or frame.
+See docs/specs/face-font-diagnostic-popup-spec-implemented.org."
+ (interactive)
+ (cj/--face-diag-display
+ (if (use-region-p)
+ (cj/--face-diag-render-region (region-beginning) (region-end))
+ (cj/--face-diag-render (cj/--face-diagnosis-at (point))))))
+
+;; Bound on C-h F (Face) in the help cluster. This shadows helpful-function,
+;; which also sits on C-h F here; face-diagnostic loads after help-config, so
+;; this binding wins.
+(keymap-set help-map "F" #'cj/describe-face-at-point)
+
+(provide 'face-diagnostic)
+;;; face-diagnostic.el ends here