aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/face_coverage.py
blob: 99a4e01fc56c24f28b02c050b5fa9401896276a9 (plain)
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
#!/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:]))