aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/Makefile20
-rw-r--r--scripts/theme-studio/face-coverage-dump.el51
-rw-r--r--scripts/theme-studio/face-coverage.org5
-rw-r--r--scripts/theme-studio/face_coverage.py335
4 files changed, 408 insertions, 3 deletions
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/<name>-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, ...)
+ <package> 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:]))