Files
neovim-config/docs/cheatsheet/gen_cheatsheet.py
T
rootiest 6e1c6d081a docs: add generated Vim keymap cheatsheet
Interactive single-file cheatsheet of every built-in key and this
config's live keymaps, generated by docs/cheatsheet/gen_cheatsheet.py
from $VIMRUNTIME/doc/index.txt, a headless dump of the config
(dump.lua) and curated plugin/config notes (curated/*.json).
2026-10-03 23:39:28 -04:00

554 lines
22 KiB
Python

#!/usr/bin/env python3
"""Generate cheatsheet.html: every Vim/Neovim key and this config's keymaps.
Run from anywhere: python3 gen_cheatsheet.py [--dump FILE]
Sources, merged into one searchable single-file page:
* $VIMRUNTIME/doc/index.txt every built-in key in every mode, plus Ex commands
* the live config dump.lua runs Neovim headless with this config and
records every keymap/command with the file and
line that set it (config, plugin or runtime)
* curated/*.json plugin-internal keys (pickers, completion, text
objects, …), descriptions, categories and notes
Built-in keys that a mapping replaces, delays or makes unreachable are flagged.
--dump FILE reuses (or, when FILE is missing, writes) a dump instead of
starting Neovim each run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
from datetime import date
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "cheatsheet.html"
MOTION_MODES = ["n", "x", "o"]
# ---------------------------------------------------------------------------
# Live dump
# ---------------------------------------------------------------------------
def run_dump(path):
env = dict(os.environ, CHEATSHEET_DUMP=str(path))
subprocess.run(
["nvim", "--headless", "-V1", "-c", f"luafile {HERE / 'dump.lua'}"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120, check=False,
)
if not Path(path).exists():
sys.exit("dump.lua produced no output — run it by hand to see the error:\n"
f" CHEATSHEET_DUMP=/tmp/d.json nvim --headless -V1 -c 'luafile {HERE / 'dump.lua'}'")
return json.loads(Path(path).read_text())
def load_dump():
args = sys.argv[1:]
if "--dump" in args:
path = Path(args[args.index("--dump") + 1])
return json.loads(path.read_text()) if path.exists() else run_dump(path)
with tempfile.TemporaryDirectory() as tmp:
return run_dump(Path(tmp) / "dump.json")
# ---------------------------------------------------------------------------
# Key notation
# ---------------------------------------------------------------------------
def index_keys(raw):
"""index.txt key column ("CTRL-W CTRL-B", "z<CR>", "a\"") -> Vim notation."""
out = []
if raw == '"``"': # index.txt quotes this one so the help syntax stays intact
return "``"
for word in raw.split(" "):
if not word:
continue
word = re.sub(r"CTRL-SHIFT-(.)", lambda m: f"<C-S-{m.group(1).upper()}>", word)
word = re.sub(r"CTRL-(<[^>]+>|\{char\}|.)", lambda m: ctrl(m.group(1)), word)
out.append(word)
return "".join(out)
def ctrl(k):
if k.startswith("<") and k.endswith(">"):
return f"<C-{k[1:-1]}>"
if k == "{char}":
return "<C-{char}>"
return f"<C-{k.upper() if k.isalpha() else k}>"
def canon(lhs):
"""Comparable form of a key sequence (keytrans style, case-folded names)."""
def fix(m):
inner = m.group(1)
parts = inner.split("-")
mods, key = parts[:-1], parts[-1] or "-"
if len(key) > 1:
key = {"cr": "CR", "esc": "Esc", "tab": "Tab", "bs": "BS", "space": "Space", "nl": "NL",
"lt": "lt", "bar": "Bar", "bslash": "Bslash"}.get(key.lower(), key[0].upper() + key[1:])
elif mods and set(m.upper() for m in mods) == {"C"} and key.isalpha():
key = key.upper()
return "<" + "-".join([*(x.upper() for x in mods), key]) + ">"
return re.sub(r"<([^<>]+)>", fix, lhs).replace("<lt>", "<").replace("<Lt>", "<")
TOKEN = re.compile(r'\["x\]|\{[^}]+\}|<(?:[A-Za-z0-9]+-)*(?:[A-Za-z0-9]+|[^>\s])>|.')
def tokens(lhs):
return TOKEN.findall(lhs)
def is_ph(tok):
return tok == '["x]' or (tok.startswith("{") and len(tok) > 1)
def base(lhs):
"""The keys a mapping would have to take over to replace this command:
leading count/register and trailing {placeholders} removed; None when a
placeholder sits in the middle (e.g. "<C-K>{char1}{char2}" is fine,
"{char1}<BS>{char2}" is not)."""
t = tokens(lhs)
# Only a count or register can come before the keys; "{char1}<BS>{char2}"
# starts with a typed character, so no mapping can replace it.
while t and t[0] in ('["x]', "{count}"):
t.pop(0)
while t and is_ph(t[-1]):
t.pop()
if not t or any(is_ph(x) for x in t):
return None
return t
def starts_with(longer, shorter):
return len(longer) > len(shorter) and longer[: len(shorter)] == shorter
# ---------------------------------------------------------------------------
# Built-ins: index.txt
# ---------------------------------------------------------------------------
SECTIONS = {
"insert-index": ("i", "Insert mode"),
"normal-index": ("n", "Normal mode"),
"objects": ("o", "Text objects"),
"CTRL-W": ("n", "Window commands"),
"[": ("n", "Square bracket commands"),
"g": ("n", "g commands"),
"z": ("n", "z commands"),
"operator-pending-index": ("o", "Operator-pending mode"),
"visual-index": ("x", "Visual mode"),
"ex-edit-index": ("c", "Command-line editing"),
"terminal-mode-index": ("t", "Terminal mode"),
"ex-cmd-index": (":", "Ex commands"),
}
# help file -> category, for built-ins without a more specific rule below.
FILE_CAT = {
"motion.txt": "Motion", "change.txt": "Edit", "undo.txt": "Undo & repeat", "scroll.txt": "Scroll & view",
"windows.txt": "Window", "tabpage.txt": "Buffer & tab", "fold.txt": "Fold", "pattern.txt": "Search",
"insert.txt": "Insert", "visual.txt": "Visual", "cmdline.txt": "Command line", "quickfix.txt": "Quickfix & lists",
"tagsrch.txt": "Tags", "diff.txt": "Diff", "spell.txt": "Spell", "editing.txt": "Files", "terminal.txt": "Terminal",
"lsp.txt": "LSP & diagnostics", "diagnostic.txt": "LSP & diagnostics", "repeat.txt": "Register & macro",
"digraph.txt": "Insert", "map.txt": "Misc", "options.txt": "Misc", "various.txt": "Misc", "message.txt": "Misc",
"starting.txt": "Files", "helphelp.txt": "Help", "eval.txt": "Misc", "gui.txt": "Misc", "sign.txt": "Misc",
"if_pyth.txt": "Misc", "if_lua.txt": "Misc", "lua.txt": "Misc", "syntax.txt": "Misc", "autocmd.txt": "Misc",
"usr_41.txt": "Misc", "vimfn.txt": "Misc", "vimeval.txt": "Misc", "print.txt": "Misc", "remote.txt": "Misc",
"mbyte.txt": "Misc", "arabic.txt": "Misc", "rileft.txt": "Misc", "provider.txt": "Misc", "pi_netrw.txt": "Files",
"pi_spec.txt": "Misc", "treesitter.txt": "Misc", "news.txt": "Misc", "uganda.txt": "Help", "intro.txt": "Help",
}
OPERATORS = {"c", "d", "y", "<", ">", "!", "=", "g~", "gu", "gU", "g?", "gq", "gw", "g@", "zf", "gc"}
MARKS = re.compile(r"^(m|'|`|g'|g`|<C-O>|<C-I>|<Tab>|g;|g,|''|``|\['|\]'|\[`|\]`|'\[|'\]|`\[|`\]|'<|'>|`<|`>|'\"|`\"|'\^|`\^|'\.|`\.)")
REGISTERS = re.compile(r'^("|q|@|Q)')
def parse_tags(runtime):
tags = {}
for line in (Path(runtime) / "doc/tags").read_text(errors="replace").splitlines():
parts = line.split("\t")
if len(parts) >= 2:
tags[parts[0]] = parts[1]
return tags
def parse_index(runtime, tags):
lines = (Path(runtime) / "doc/index.txt").read_text().splitlines()
entries, section, in_table, cur, sub = [], None, False, None, None
def flush():
nonlocal cur
if cur:
cur["desc"] = re.sub(r"\s+", " ", cur["desc"]).strip()
if cur["desc"] and not cur["desc"].startswith("not used") and cur["desc"] != "reserved":
entries.append(cur)
cur = None
for raw in lines:
m = re.search(r"\*([^*\s]+)\*", raw)
if raw.startswith(tuple("0123456789")) and m:
flush()
names = re.findall(r"\*([^*\s]+)\*", raw)
section = next((n for n in names if n in SECTIONS), None)
in_table, sub = False, None
continue
if section is None:
continue
if raw.rstrip().endswith("~") and raw.startswith("-----"):
in_table = True
continue
if not in_table:
continue
if raw.startswith("====="):
flush()
section, in_table = None, False
continue
line = raw.expandtabs(8)
if not line.strip():
flush()
continue
indent = len(line) - len(line.lstrip())
if raw.startswith("'") and "\t" in raw:
# "'cedit' CTRL-F ...": an option name in the tag column.
raw = raw.split("\t", 1)[1].lstrip("\t")
elif indent == 0 and not raw.startswith("|"):
# Sub-heading inside a table ("commands in CTRL-X submode").
flush()
sub = re.sub(r"\s*\*[^*]+\*\s*", "", raw).replace("|", "").strip()
continue
if indent >= 24 and cur is not None:
cur["desc"] += " " + line.strip()
continue
if indent >= 24:
continue
flush()
m = re.match(r"^(?:\|([^|]+)\|)?\s*(\S.*)$", raw)
if not m:
continue
tag, body = m.group(1), m.group(2)
sep = re.search(r"\t+|\s{2,}", body)
if sep:
key, rest = body[: sep.start()].strip(), body[sep.end():]
else:
# Key column overflowed into the description with single spaces:
# keep key-like words ("CTRL-V {number}", "CTRL-\\ e {expr}").
words, key_words = body.split(" "), []
while words and (not key_words or len(words[0]) == 1
or re.match(r"^(CTRL-|<|\{)", words[0])):
key_words.append(words.pop(0))
key, rest = " ".join(key_words), " ".join(words)
note = ""
nm = re.match(r"^([12])\s+(.*)$", rest.strip())
if nm and section in ("normal-index", "CTRL-W", "[", "g", "z", "visual-index"):
note, rest = nm.group(1), nm.group(2)
mode, sect_name = SECTIONS[section]
cur = {"raw": key, "tag": tag, "note": note, "desc": rest, "section": sect_name, "sect": section, "mode": mode,
"sub": sub}
flush()
out = []
for e in entries:
if e["mode"] == ":":
out.append(ex_entry(e, tags))
continue
keys = index_keys(e["raw"])
tag = e["tag"]
if e["sect"] == "objects":
modes = ["x", "o"]
elif e["note"] == "1" and e["mode"] == "n":
modes = MOTION_MODES
else:
modes = [e["mode"]]
out.append({
"k": keys, "m": modes, "d": e["desc"], "s": "builtin",
"t": tag, "h": tags.get(tag) if tag else None, "sec": e["section"],
"cat": builtin_cat(e, keys, tags.get(tag, "") if tag else ""),
"mo": 1 if e["note"] == "1" else None, "ch": 1 if e["note"] == "2" else None,
"ctx": e["sub"][0].upper() + e["sub"][1:] if e["sub"] else None,
})
return out
def ex_entry(e, tags):
name = e["raw"]
return {
"k": name, "m": [":"], "d": e["desc"], "s": "builtin", "t": e["tag"],
"h": tags.get(e["tag"]) if e["tag"] else None, "sec": "Ex commands",
"cat": FILE_CAT.get(tags.get(e["tag"], ""), "Misc") if e["tag"] else "Misc", "ex": 1,
}
def builtin_cat(e, keys, helpfile):
if e["sect"] == "objects":
return "Text object"
if e["sect"] == "CTRL-W":
return "Window"
if e["sect"] == "ex-edit-index":
return "Command line"
if e["sect"] == "insert-index":
return "Insert"
if e["sect"] in ("normal-index", "g", "z", "visual-index", "operator-pending-index", "["):
if keys in OPERATORS or (e["sect"] == "visual-index" and keys in OPERATORS):
return "Operator"
if MARKS.match(keys) and e["sect"] != "visual-index":
return "Mark & jump"
if REGISTERS.match(keys) and helpfile in ("change.txt", "repeat.txt", "undo.txt"):
return "Register & macro"
cat = FILE_CAT.get(helpfile)
if cat == "Motion" and e["note"] != "1":
return "Mark & jump" if "jump" in e["desc"] or "mark" in e["desc"] else "Motion"
return cat or ("Motion" if e["note"] == "1" else "Misc")
# ---------------------------------------------------------------------------
# Live maps
# ---------------------------------------------------------------------------
def source_of(path, d):
"""(source id, short file label) for the file that set a mapping."""
if not path:
return "unknown", None
cfg, runtime = d["config"].rstrip("/") + "/", d["vimruntime"].rstrip("/") + "/"
packs = d["data"].rstrip("/") + "/site/pack/"
if path.startswith(cfg):
return "config", path[len(cfg):]
if path.startswith(runtime):
rel = path[len(runtime):]
if rel.startswith("ftplugin/"):
return "ftplugin", rel
if "matchit" in rel:
return "matchit", rel
return "neovim", rel
if path.startswith(packs):
m = re.match(r"[^/]+/(?:opt|start)/([^/]+)/(.*)", path[len(packs):])
if m:
return m.group(1), m.group(2)
if "/vimfiles/" in path or path.startswith("/usr/share/vim"):
return Path(path).stem + " (system)", path
return "other", path
def live_entries(d, curated_desc):
out = []
leader = d.get("leader") or "\\"
lead = canon("<Space>") if leader == " " else leader
def add(m, buffer_ctx=None):
lhs = m["lhs"]
if lhs.startswith("<Plug>") or lhs.startswith("<SNR>") or "<Plug>" in lhs[:1]:
return
src, file = source_of(m.get("file"), d)
key = canon(lhs)
desc = m.get("desc")
rhs = m.get("rhs")
help_tag = desc[6:].strip() if desc and desc.startswith(":help ") else None
# map_desc may carry a `ctx` ("markdown buffers") to target one buffer-local
# map; entries without one apply everywhere.
cd = next((curated_desc[t] for c in dict.fromkeys((buffer_ctx, None))
for t in ((src, m["mode"], key, c), (src, "*", key, c)) if t in curated_desc), {})
# The config's own desc wins (which-key shows it too) unless it is
# missing or only a ":help" pointer; category/notes always apply.
if cd.get("d") and (not desc or desc.startswith(":help") or cd.get("force")):
desc = cd["d"]
if not desc:
if rhs and rhs.startswith("<Plug>("):
desc = rhs[7:-1].replace("-", " ")
elif rhs:
desc = f"runs {rhs}"
else:
desc = "(disabled)" if not m.get("callback") else "(Lua function)"
if desc == "which_key_ignore" or (src == "which-key" and not cd.get("d")):
return # which-key's own trigger maps: they only show the popup
e = {
"k": key, "m": [m["mode"]], "d": desc, "s": src, "f": file, "l": m.get("line"),
"rhs": rhs if rhs and not rhs.startswith("<Plug>(leap") else None,
"nw": 1 if m.get("nowait") else None,
"cat": cd.get("cat"), "n": cd.get("n"), "e": cd.get("e"), "sl": cd.get("sl"),
}
if key.startswith(lead) and leader == " " and m["mode"] in ("n", "x", "s", "o"):
e["ld"] = 1
if help_tag:
e["t"] = help_tag
if buffer_ctx:
e["ctx"] = buffer_ctx
out.append(e)
for m in d["maps"]:
add(m)
for b in d["buffers"]:
for m in b["maps"]:
add(m, f"{b['filetype']} buffers")
return merge_modes(out)
def merge_modes(entries):
"""Collapse identical maps registered for several modes into one entry."""
merged, order = {}, []
for e in entries:
k = (e["k"], e["d"], e["s"], e.get("f"), e.get("l"), e.get("ctx"))
if k in merged:
for mode in e["m"]:
if mode not in merged[k]["m"]:
merged[k]["m"].append(mode)
else:
merged[k] = e
order.append(k)
return [merged[k] for k in order]
# ---------------------------------------------------------------------------
# Curated data
# ---------------------------------------------------------------------------
def load_curated():
cur = {"entries": [], "notes": [], "map_desc": [], "builtin": {}, "essentials": [], "commands": {}, "groups": {}}
for f in sorted((HERE / "curated").glob("*.json")):
data = json.loads(f.read_text())
for k in ("entries", "notes", "map_desc", "essentials"):
cur[k] += data.get(k, [])
for tag, fields in data.get("builtin", {}).items():
cur["builtin"].setdefault(tag, {}).update(fields) # several files may add to one tag
cur["commands"].update(data.get("commands", {}))
cur["groups"].update(data.get("groups", {}))
return cur
# ---------------------------------------------------------------------------
# Conflicts: what the config changes about stock Vim
# ---------------------------------------------------------------------------
def annotate(builtins, live):
by_mode_key = {}
for i, b in enumerate(builtins):
if b.get("ex"):
continue
bt = base(canon(b["k"]))
if bt is None:
continue
b["_t"] = bt
for mode in b["m"]:
by_mode_key.setdefault((mode, tuple(bt)), []).append(i)
live_by_mode = {}
for j, e in enumerate(live):
e["_t"] = tokens(e["k"])
if e.get("ctx"):
continue
for mode in e["m"]:
live_by_mode.setdefault(mode, {})[tuple(e["_t"])] = j
for j, e in enumerate(live):
for mode in e["m"]:
for i in by_mode_key.get((mode, tuple(e["_t"])), []):
builtins[i].setdefault("ov", []).append({"m": mode, "by": j})
e.setdefault("rep", []).append({"m": mode, "b": i})
for mode, maps in live_by_mode.items():
# Longer maps that a shorter `nowait` map makes unreachable.
for lhs, j in maps.items():
if live[j].get("nw"):
for other, k in maps.items():
if starts_with(list(other), list(lhs)):
live[k].setdefault("sh", []).append({"m": mode, "by": j})
# Complete built-in commands that now wait for 'timeoutlen' because a
# mapping extends them (e.g. "ga" once "gai"/"gao" exist).
for (bmode, bkey), idxs in by_mode_key.items():
if bmode != mode or bkey in maps:
continue
ext = [live[j]["k"] for lhs, j in maps.items() if starts_with(list(lhs), list(bkey))]
if ext:
for i in idxs:
if not is_ph(tokens(builtins[i]["k"])[-1]):
builtins[i].setdefault("wait", []).append({"m": mode, "ext": sorted(ext)[:6], "n": len(ext)})
for x in builtins + live:
x.pop("_t", None)
# ---------------------------------------------------------------------------
def main():
d = load_dump()
runtime = d["vimruntime"]
tags = parse_tags(runtime)
builtins = parse_index(runtime, tags)
cur = load_curated()
for b in builtins:
extra = cur["builtin"].get(f"{''.join(b['m'])}|{b['k']}") or cur["builtin"].get(b.get("t") or "")
if extra:
b.update({k: v for k, v in extra.items() if v is not None})
ess = {(e.get("t") or "", e.get("k") or "") for e in cur["essentials"]}
for b in builtins:
if (b.get("t") or "", "") in ess or ("", b["k"]) in ess:
b["e"] = 1
curated_desc = {(c["s"], c.get("m", "*"), canon(c["k"].replace("<leader>", "<Space>")), c.get("ctx")): c
for c in cur["map_desc"]}
live = live_entries(d, curated_desc)
annotate(builtins, live)
# Help links for the template: Neovim's own defaults describe themselves as
# ":help <tag>"; curated entries name a tag in "h". "hf" is the runtime help
# file holding that tag (None for plugin docs, which neovim.io doesn't host).
for e in live:
if e.get("t"):
e["hf"] = tags.get(e["t"])
for c in cur["entries"]:
if c.get("h"):
c["hf"] = tags.get(c["h"])
for e in live:
if not e.get("cat"):
group = next((g for g in sorted(cur["groups"], key=len, reverse=True)
if canon(g.replace("<leader>", "<Space>")) == e["k"][: len(canon(g.replace("<leader>", "<Space>")))]
and cur["groups"][g].get("cat")), None)
if group:
e["cat"] = cur["groups"][group]["cat"]
elif e.get("rep"):
e["cat"] = builtins[e["rep"][0]["b"]]["cat"]
else:
e["cat"] = "Misc"
commands = []
for c in d["commands"] + [dict(c, ctx=b["filetype"]) for b in d["buffers"] for c in b["commands"]]:
src, file = source_of(c.get("file"), d)
commands.append({"k": ":" + c["name"], "d": cur["commands"].get(c["name"]) or c.get("desc") or "",
"s": src, "f": file, "na": c.get("nargs"), "r": 1 if c.get("range") else None,
"ctx": c.get("ctx")})
plugins = sorted({p["name"] for p in d["plugins"] if p.get("active")})
data = {
"meta": {
"version": d["version"], "leader": d["leader"], "localleader": d["localleader"],
"date": date.today().isoformat(), "plugins": plugins, "options": d["options"],
"timeoutlen": d["options"]["timeoutlen"],
},
"builtins": builtins,
"live": live,
"curated": cur["entries"],
"notes": cur["notes"],
"groups": cur["groups"],
"commands": commands,
"picker": d.get("snacks_picker"),
"explorer": d.get("snacks_explorer"),
"blink": d.get("blink"),
"flash": d.get("flash"),
}
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
template = (HERE / "cheatsheet.template.html").read_text().split("\n", 1)[1] # drop the template note
OUT.write_text(template.replace("/*DATA*/null", payload))
n_ov = sum(1 for b in builtins if b.get("ov"))
print(f"wrote {OUT.name}: {len(builtins)} built-ins ({n_ov} overridden), {len(live)} live maps, "
f"{len(cur['entries'])} curated, {len(commands)} commands, {len(cur['notes'])} notes")
if __name__ == "__main__":
main()