#!/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", "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"", 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"" if k == "{char}": return "" return f"" 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("", "<").replace("", "<") 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. "{char1}{char2}" is fine, "{char1}{char2}" is not).""" t = tokens(lhs) # Only a count or register can come before the keys; "{char1}{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`||||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("") if leader == " " else leader def add(m, buffer_ctx=None): lhs = m["lhs"] if lhs.startswith("") or lhs.startswith("") or "" 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("("): 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("(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("", "")), 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 "; 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("", "")) == e["k"][: len(canon(g.replace("", "")))] 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("