From 8f56aced97f128b6b4d4dcf19fe5c1ba43447e6b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 18 Jun 2026 20:35:55 -0500 Subject: feat(theme-studio): add reproducible face-coverage generator and diff face-coverage.org was rebuilt by a throwaway /tmp script each time. This makes it reproducible: face-coverage-dump.el dumps every face's name, docstring, and defface file from the live daemon (plus all group docs and package summaries), and face_coverage.py turns that into the tiered worklist (emacs-core / emacs-general / per-package), classifying each face by where its defface lives. make face-coverage regenerates the file; make face-coverage-diff reports the coverage delta against the committed copy. The dump binds coding-system-for-write so writing the docstring JSON never drops into the interactive coding-system prompt. I validated the builder by regenerating and diffing against the hand-built worklist: headings identical, only the intro and one sharper description differ. --- scripts/theme-studio/Makefile | 20 +- scripts/theme-studio/face-coverage-dump.el | 51 +++++ scripts/theme-studio/face-coverage.org | 5 +- scripts/theme-studio/face_coverage.py | 335 +++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 scripts/theme-studio/face-coverage-dump.el create mode 100644 scripts/theme-studio/face_coverage.py (limited to 'scripts/theme-studio') diff --git a/scripts/theme-studio/Makefile b/scripts/theme-studio/Makefile index 374c7fb1b..7b8430182 100644 --- a/scripts/theme-studio/Makefile +++ b/scripts/theme-studio/Makefile @@ -16,7 +16,10 @@ OUT ?= ../../themes EMACS ?= emacs EMACSCLIENT ?= emacsclient -.PHONY: help test check check-generated coverage gen open theme theme-load theme-reload +.PHONY: help test check check-generated coverage gen open theme theme-load theme-reload face-coverage-dump face-coverage face-coverage-diff + +# Scratch path for the face-coverage Emacs data dump. +FACE_DUMP ?= /tmp/face-coverage-data.json .DEFAULT_GOAL := help @@ -31,6 +34,8 @@ help: @echo " make theme JSON=x.json - Convert a Theme Studio JSON export to OUT/-theme.el" @echo " make theme-load THEME=x - Disable all custom themes, then load THEME in current Emacs" @echo " make theme-reload JSON=x - Convert JSON, then cleanly reload its theme in current Emacs" + @echo " make face-coverage - Regenerate face-coverage.org from the live Emacs daemon" + @echo " make face-coverage-diff - Show the coverage delta vs the committed face-coverage.org" test: @./run-tests.sh @@ -101,3 +106,16 @@ endif @theme_name='$(THEME)'; \ if [ -z "$$theme_name" ]; then theme_name="$$(basename '$(JSON)' .json)"; fi; \ $(MAKE) theme-load THEME="$$theme_name" OUT='$(OUT)' EMACSCLIENT='$(EMACSCLIENT)' + +# Dump face/group/package data from the running daemon (falls back to a batch +# Emacs that loads the full init when no daemon is reachable). +face-coverage-dump: + @$(EMACSCLIENT) -e '(progn (load "$(HERE)face-coverage-dump.el") (face-coverage-dump "$(FACE_DUMP)"))' >/dev/null 2>&1 \ + || $(EMACS) --batch -l "$$HOME/.emacs.d/init.el" -l "$(HERE)face-coverage-dump.el" \ + --eval '(face-coverage-dump "$(FACE_DUMP)")' + +face-coverage: face-coverage-dump + @python3 face_coverage.py --data "$(FACE_DUMP)" + +face-coverage-diff: face-coverage-dump + @python3 face_coverage.py --data "$(FACE_DUMP)" --compare face-coverage.org diff --git a/scripts/theme-studio/face-coverage-dump.el b/scripts/theme-studio/face-coverage-dump.el new file mode 100644 index 000000000..6fc73469f --- /dev/null +++ b/scripts/theme-studio/face-coverage-dump.el @@ -0,0 +1,51 @@ +;;; face-coverage-dump.el --- Dump face/group/package data for the coverage worklist -*- lexical-binding: t -*- + +;;; Commentary: +;; Emits a JSON file that face_coverage.py consumes to build face-coverage.org. +;; For every face in `face-list' it records the name, its documentation string, +;; and the file its `defface' lives in (used to classify built-in vs package). +;; It also dumps every customization group's documentation and every elpa +;; package's summary, so the builder can describe each bucket offline. +;; +;; Run against a live daemon to capture actually-loaded packages: +;; emacsclient -e '(progn (load ".../face-coverage-dump.el") +;; (face-coverage-dump "/tmp/face-coverage-data.json"))' +;; or on a clean checkout via `emacs --batch -l init.el' then the same calls +;; (lazily-loaded packages will be absent until required). + +;;; Code: + +(require 'json) +(require 'package) + +(defun face-coverage-dump (outfile) + "Write face, group, and package data as JSON to OUTFILE." + (let ((faces nil) + (groups (make-hash-table :test 'equal)) + (packages (make-hash-table :test 'equal))) + (dolist (f (face-list)) + (push (vector (symbol-name f) + (or (face-documentation f) :null) + (or (symbol-file f 'defface) :null)) + faces)) + (mapatoms + (lambda (s) + (let ((d (get s 'group-documentation))) + (when (stringp d) (puthash (symbol-name s) d groups))))) + (when (boundp 'package-alist) + (dolist (entry package-alist) + (let ((sum (ignore-errors (package-desc-summary (cadr entry))))) + (when (stringp sum) (puthash (symbol-name (car entry)) sum packages))))) + ;; Docstrings carry curly quotes and other non-ASCII; bind the write coding + ;; system so `with-temp-file' never drops into the interactive + ;; select-safe-coding-system prompt (which pops in the daemon's frame). + (let ((n (length faces)) + (coding-system-for-write 'utf-8-unix)) + (with-temp-file outfile + (insert (json-serialize (list :faces (vconcat (nreverse faces)) + :groups groups + :packages packages)))) + (message "face-coverage-dump: %d faces -> %s" n outfile)))) + +(provide 'face-coverage-dump) +;;; face-coverage-dump.el ends here diff --git a/scripts/theme-studio/face-coverage.org b/scripts/theme-studio/face-coverage.org index 1fff4d346..b5f8b795b 100644 --- a/scripts/theme-studio/face-coverage.org +++ b/scripts/theme-studio/face-coverage.org @@ -9,7 +9,8 @@ studio already themes it; TODO = not yet. Three top-level tiers: - emacs-general: built-in Emacs subsystems (org, gnus, erc, diff, vc, custom, ...), one child each. - one heading per third-party package installed from elpa (magit, vertico, consult, ...). Tier is decided by where each face's defface lives: /usr/share/emacs = built-in, elpa = package. -The line under each face is its Emacs docstring (first line), where one exists. +The line under each bucket is its group/package description; the line under each face is its +Emacs docstring (first line), where one exists. Totals: 690 / 1293 faces covered; 1129 carry a docstring. Tiers: core 1, general 75, packages 43. Coverage tiers in the studio: syntax font-lock=23, UI tier=21, package inventory=643 (39 packages). @@ -935,7 +936,7 @@ Mechanism to close a TODO: core/UI faces -> UI_FACES in generate.py; package + s *** TODO message-signature-separator Face used for displaying the signature separator. ** TODO message-header [0/7] - Mail and news message composing. + Message Headers. *** TODO message-header-cc Face used for displaying Cc headers. *** TODO message-header-name diff --git a/scripts/theme-studio/face_coverage.py b/scripts/theme-studio/face_coverage.py new file mode 100644 index 000000000..99a4e01fc --- /dev/null +++ b/scripts/theme-studio/face_coverage.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Build (and diff) face-coverage.org from a face-coverage-dump.el JSON dump. + +The worklist lists every known face -- the live Emacs face-list unioned with +everything theme-studio manages -- grouped into three tiers: + + emacs-core standalone built-in faces (frame chrome, cursor, region, ...) + emacs-general built-in Emacs subsystems (org, gnus, erc, diff, vc, ...) + one heading per third-party elpa package + +Tier is decided by where each face's defface lives: /usr/share/emacs is built-in, +elpa is a package. Each face carries its docstring; each bucket carries its +customization-group doc or package summary. + +Usage: + python3 face_coverage.py --data DUMP.json --out face-coverage.org + python3 face_coverage.py --data DUMP.json --compare face-coverage.org + +The builder is deterministic given a dump. The --compare mode regenerates in +memory and reports the coverage delta against an existing org file (newly +covered, newly present, disappeared, per-tier totals) without writing. +""" + +import argparse +import collections +import datetime +import json +import os +import re +import sys + +import generate # sibling module: UI_FACES, CATS + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Faces that belong to emacs-core (fundamental display faces), even though their +# name prefix would otherwise route them to a subsystem bucket. +CORE_HINT = { + 'default', 'cursor', 'region', 'secondary-selection', 'highlight', 'hl-line', + 'shadow', 'match', 'fringe', 'minibuffer-prompt', 'mode-line', 'mode-line-inactive', + 'mode-line-highlight', 'mode-line-emphasis', 'mode-line-buffer-id', 'mode-line-active', + 'header-line', 'header-line-highlight', 'vertical-border', 'window-divider', + 'window-divider-first-pixel', 'window-divider-last-pixel', 'line-number', + 'line-number-current-line', 'line-number-major-tick', 'line-number-minor-tick', + 'isearch', 'isearch-fail', 'isearch-group-1', 'isearch-group-2', 'lazy-highlight', + 'show-paren-match', 'show-paren-mismatch', 'show-paren-match-expression', 'link', + 'link-visited', 'error', 'warning', 'success', 'tooltip', 'trailing-whitespace', + 'fill-column-indicator', 'escape-glyph', 'homoglyph', 'nobreak-space', 'nobreak-hyphen', + 'glyphless-char', 'button', 'help-key-binding', 'separator-line', 'scroll-bar', 'tool-bar', + 'menu', 'border', 'internal-border', 'child-frame-border', 'mouse', 'mouse-drag-and-drop-region', + 'bold', 'italic', 'bold-italic', 'underline', 'fixed-pitch', 'fixed-pitch-serif', + 'variable-pitch', 'variable-pitch-text', 'next-error', 'query-replace', + 'completions-common-part', 'completions-first-difference', 'blink-matching-paren-offscreen', + 'tty-menu-disabled-face', 'tty-menu-enabled-face', 'tty-menu-selected-face', +} + +# Extra subsystem/package buckets beyond the package-inventory keys, so every +# face routes to a named bucket rather than falling into emacs-core. +EXTRA_FAMILIES = { + 'font-lock', 'org', 'org-agenda', 'org-block', 'org-table', 'org-habit', 'org-document', + 'org-priority', 'gnus', 'gnus-group', 'gnus-summary', 'gnus-header', 'gnus-cite', + 'gnus-server', 'gnus-splash', 'gnus-emphasis', 'gnus-signature', 'gnus-button', 'erc', + 'message', 'message-header', 'custom', 'diff', 'smerge', 'ediff', 'dired', 'image-dired', + 'wdired', 'info', 'vc', 'shr', 'eww', 'epa', 'package', 'compilation', 'outline', + 'completions', 'widget', 'apropos', 'change-log', 'calendar', 'diary', 'holiday', + 'which-key', 'tab-bar', 'tab-line', 'ansi-color', 'xref', 'comint', 'shell', 'sh', + 'makefile', 'eshell', 'term', 'help', 'eldoc', 'ert', 'kmacro', 'gud', 'bookmark', + 'ibuffer', 'grep', 'tabulated-list', 'flymake', 'flyspell', 'whitespace', + 'rainbow-delimiters', 'tmr', 'alert', 'breakpoint', 'mm', 'treesit', 'image', 'icon', + 'auto', 'buffer', 'browse', 'confusingly', 'adob', 'log', 'next', 'read', 'table', + 'rectangle', 'file', 'ffap', 'edmacro', 'elisp', 'doc', 'markdown', 'lsp', 'abbrev', + 'which-func', 'git-gutter', 'git-commit', 'twentyfortyeight', 'yas', 'edit-indirect', +} + +# Curated descriptions for buckets whose group/package docs don't resolve. +DESC_FALLBACK = { + 'completions': 'Faces for the default *Completions* buffer.', + 'twentyfortyeight': 'Tile faces for the 2048 game.', + 'yas': 'Faces for YASnippet template fields.', + 'json-mode': 'Major mode for editing JSON.', + 'adob': 'auto-dim-other-buffers: dimmed inactive windows.', + 'diary': 'Faces for diary entries in the calendar.', + 'holiday': 'Faces for holidays in the calendar.', + 'mm': 'MIME handling faces (gnus/mm).', + 'emacs-core': 'Standalone built-in faces: frame chrome, cursor, region, mode line, ' + 'search, line numbers, base typography.', + 'emacs-general': 'Built-in Emacs subsystems, one child per subsystem.', +} + + +def clean_doc(doc): + """First non-empty line of DOC, smart-quotes normalized, whitespace collapsed.""" + if not doc: + return '' + line = next((ln for ln in doc.split('\n') if ln.strip()), '') + line = (line.replace('‘', "'").replace('’', "'") + .replace('“', '"').replace('”', '"').replace('—', '-')) + return re.sub(r'[ \t]+', ' ', line).strip() + + +def load_managed(): + """Return the set of faces theme-studio already themes (its three tiers).""" + bt = open(os.path.join(HERE, 'build-theme.el')).read() + fontlock = set(re.findall(r'font-lock-[a-z-]+', bt)) + ui = {f[0] for f in generate.UI_FACES} + inv = json.load(open(os.path.join(HERE, 'package-inventory.json'))) + pkg = {f for faces in inv.values() for f in faces if isinstance(f, str)} + managed = fontlock | ui | pkg | {'default', 'fixed-pitch', 'variable-pitch'} + return managed, fontlock, ui, pkg, inv + + +def make_group_of(families): + fams = sorted(families, key=len, reverse=True) + + def group_of(f): + if f.startswith('bg:erc') or f.startswith('fg:erc'): + return 'erc-ansi' + if f in CORE_HINT: + return 'emacs-core' + if f.startswith('font-lock'): + return 'font-lock' + for p in fams: + if f == p or (f.startswith(p) and len(f) > len(p) and f[len(p)] in '-:/'): + return p + if f.lower().startswith('info-'): + return 'info' + return 'emacs-core' + return group_of + + +def bucket_of_source(path): + if not path: + return 'unloaded' + if '/elpa/' in path: + return 'elpa' + if '/.emacs.d/modules' in path: + return 'user' + if path.startswith('/usr/share/emacs') or path.startswith('/usr/lib/emacs'): + return 'builtin' + return 'other' + + +def classify(name, items, src, pkgfaces): + """core / general / package for a bucket, by its faces' defface source.""" + if name == 'emacs-core': + return 'core' + c = collections.Counter(bucket_of_source(src.get(f, '')) for f in items) + loaded = c['elpa'] + c['builtin'] + c['user'] + c['other'] + if loaded == 0: + return 'package' if any(f in pkgfaces for f in items) else 'general' + if c['elpa'] >= max(c['builtin'], c['user'], c['other']): + return 'package' + if c['other'] > c['builtin'] and c['other'] >= c['elpa']: + return 'package' + return 'general' + + +def resolve_desc(bucket, groups, packages): + """One-line description for BUCKET via group doc / package summary / parent.""" + if bucket in DESC_FALLBACK: + # umbrella tiers and curated fills take precedence over a weak group hit + if bucket in ('emacs-core', 'emacs-general'): + return DESC_FALLBACK[bucket] + for cand in (bucket, bucket + '-faces', bucket + 's', bucket + '-mode', bucket + '-mode-faces'): + if groups.get(cand): + return clean_doc(groups[cand]) + for cand in (bucket, bucket + '-mode'): + if packages.get(cand): + return clean_doc(packages[cand]) + if '-' in bucket: + parent = bucket.rsplit('-', 1)[0] + if groups.get(parent): + return clean_doc(groups[parent]) + return DESC_FALLBACK.get(bucket, '') + + +def status(done, total): + return 'DONE' if done == total else ('TODO' if done == 0 else 'DOING') + + +def build(data, today): + faces_raw = data['faces'] + docs = {row[0]: ('' if row[1] in (None, ':null') else clean_doc(row[1])) for row in faces_raw} + src = {row[0]: ('' if row[2] in (None, ':null') else row[2]) for row in faces_raw} + groups_doc = data.get('groups', {}) + packages = data.get('packages', {}) + + managed, fontlock, ui, pkgfaces, inv = load_managed() + universe = sorted(set(docs.keys()) | managed) + + families = set(inv.keys()) | EXTRA_FAMILIES + group_of = make_group_of(families) + groups = collections.defaultdict(list) + for f in universe: + groups[group_of(f)].append(f) + + cls = {k: classify(k, v, src, pkgfaces) for k, v in groups.items()} + done = lambda items: sum(1 for f in items if f in managed) + + gen_groups = sorted(k for k in groups if cls[k] == 'general') + pkg_groups = sorted(k for k in groups if cls[k] == 'package') + gen_faces = [f for k in gen_groups for f in groups[k]] + tot_done = done(universe) + ndoc = sum(1 for f in universe if docs.get(f)) + + out = [] + + def emit_desc(bucket, stars): + d = resolve_desc(bucket, groups_doc, packages) + if d: + out.append(' ' * (stars + 1) + d) + + def emit_face(f, stars): + out.append('%s %s %s' % ('*' * stars, 'DONE' if f in managed else 'TODO', f)) + if docs.get(f): + out.append(' ' * (stars + 1) + docs[f]) + + out += [ + '#+TITLE: theme-studio — face coverage master list', + '#+DATE: %s' % today, + '#+TODO: TODO DOING | DONE', + '#+STARTUP: overview', + '', + 'Every known face (live Emacs face-list union everything theme-studio manages). DONE = the', + 'studio already themes it; TODO = not yet. Three top-level tiers:', + '- emacs-core: the standalone built-in faces (frame chrome, cursor, region, mode line, search).', + '- emacs-general: built-in Emacs subsystems (org, gnus, erc, diff, vc, custom, ...), one child each.', + '- one heading per third-party package installed from elpa (magit, vertico, consult, ...).', + "Tier is decided by where each face's defface lives: /usr/share/emacs = built-in, elpa = package.", + 'The line under each bucket is its group/package description; the line under each face is its', + 'Emacs docstring (first line), where one exists.', + '', + 'Totals: %d / %d faces covered; %d carry a docstring. Tiers: core 1, general %d, packages %d.' + % (tot_done, len(universe), ndoc, len(gen_groups), len(pkg_groups)), + 'Coverage tiers in the studio: syntax font-lock=%d, UI tier=%d, package inventory=%d (%d packages).' + % (len(fontlock), len(ui), len(pkgfaces), len(inv)), + '', + 'Mechanism to close a TODO: core/UI faces -> UI_FACES in generate.py; package + subsystem faces', + '-> package-inventory.json (regenerable via build-inventory.el / app_inventory.py).', + '', + ] + + core = sorted(groups['emacs-core']) + out.append('* %s emacs-core [%d/%d]' % (status(done(core), len(core)), done(core), len(core))) + emit_desc('emacs-core', 1) + for f in core: + emit_face(f, 2) + out.append('') + + out.append('* %s emacs-general [%d/%d]' + % (status(done(gen_faces), len(gen_faces)), done(gen_faces), len(gen_faces))) + emit_desc('emacs-general', 1) + for k in gen_groups: + items = sorted(groups[k]) + out.append('** %s %s [%d/%d]' % (status(done(items), len(items)), k, done(items), len(items))) + emit_desc(k, 2) + for f in items: + emit_face(f, 3) + out.append('') + + for k in pkg_groups: + items = sorted(groups[k]) + out.append('* %s %s [%d/%d]' % (status(done(items), len(items)), k, done(items), len(items))) + emit_desc(k, 1) + for f in items: + emit_face(f, 2) + out.append('') + + summary = { + 'total': (tot_done, len(universe)), + 'core': (done(core), len(core)), + 'general': (done(gen_faces), len(gen_faces)), + 'packages': len(pkg_groups), + } + return '\n'.join(out), summary + + +def parse_states(text): + """face -> 'DONE'/'TODO' from an org worklist (face headings have no cookie).""" + states = {} + for m in re.finditer(r'^\*+ (TODO|DONE) (\S+)$', text, re.M): + states[m.group(2)] = m.group(1) + return states + + +def compare(old_text, new_text): + old, new = parse_states(old_text), parse_states(new_text) + newly_covered = sorted(f for f in new if new[f] == 'DONE' and old.get(f) == 'TODO') + newly_present = sorted(set(new) - set(old)) + disappeared = sorted(set(old) - set(new)) + old_done = sum(1 for s in old.values() if s == 'DONE') + new_done = sum(1 for s in new.values() if s == 'DONE') + return { + 'newly_covered': newly_covered, + 'newly_present': newly_present, + 'disappeared': disappeared, + 'old': (old_done, len(old)), + 'new': (new_done, len(new)), + } + + +def main(argv): + ap = argparse.ArgumentParser(description='Build or diff face-coverage.org') + ap.add_argument('--data', default='/tmp/face-coverage-data.json', + help='JSON dump from face-coverage-dump.el') + ap.add_argument('--out', default=os.path.join(HERE, 'face-coverage.org'), + help='org file to write (build mode)') + ap.add_argument('--compare', metavar='ORG', + help='regenerate in memory and report the delta against ORG, do not write') + ap.add_argument('--date', default=None, help='override the #+DATE stamp (YYYY-MM-DD)') + args = ap.parse_args(argv) + + data = json.load(open(args.data)) + today = args.date or datetime.date.today().isoformat() + text, summary = build(data, today) + + if args.compare: + old_text = open(args.compare).read() + d = compare(old_text, text) + print('coverage: %d/%d -> %d/%d' % (d['old'][0], d['old'][1], d['new'][0], d['new'][1])) + print('newly covered (%d): %s' % (len(d['newly_covered']), ' '.join(d['newly_covered']) or '-')) + print('newly present (%d): %s' % (len(d['newly_present']), ' '.join(d['newly_present']) or '-')) + print('disappeared (%d): %s' % (len(d['disappeared']), ' '.join(d['disappeared']) or '-')) + return 0 + + with open(args.out, 'w') as fh: + fh.write(text) + print('wrote %s — %d/%d covered, core %d/%d, general %d/%d, %d package groups' + % (args.out, summary['total'][0], summary['total'][1], summary['core'][0], + summary['core'][1], summary['general'][0], summary['general'][1], summary['packages'])) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) -- cgit v1.2.3