1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
|
#!/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
# Built-in source files whose faces are core display faces, not a subsystem;
# an unrecognized face from one of these stays in emacs-core rather than
# spawning an odd subsystem bucket under emacs-general.
CORE_FILES = {'faces', 'frame'}
def bucket_from_source(path):
"""Derive a bucket name from a face's defface file, for faces that match no
known family. elpa -> the package dir name (version stripped); built-in ->
the source file basename; otherwise emacs-core (can't tell)."""
if not path:
return 'emacs-core'
if '/elpa/' in path:
pkgdir = path.split('/elpa/', 1)[1].split('/', 1)[0]
return re.sub(r'-[0-9].*$', '', pkgdir) or 'emacs-core'
if '/.emacs.d/modules' in path:
return 'user-config'
if path.startswith('/usr/share/emacs') or path.startswith('/usr/lib/emacs'):
base = os.path.basename(path)
base = base[:-3] if base.endswith('.el') else base
return 'emacs-core' if base in CORE_FILES else base
return 'emacs-core'
def make_group_of(families, src):
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'
# Unrecognized: route by where the defface lives so a newly-loaded
# package buckets itself instead of falling into emacs-core.
return bucket_from_source(src.get(f, ''))
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, src)
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:]))
|