From a206ac0ca58e186ea9a7e0ef315413818a803a90 Mon Sep 17 00:00:00 2001 From: ppxb <317842449@qq.com> Date: Mon, 9 Nov 2026 15:23:41 +0800 Subject: [PATCH] fix(vim): accept special-key names in any case in rc mappings (#2291) codemirror-vim matches special keys only in their exact canonical spelling, so rc mappings written for Vim/IdeaVim such as `inoremap jk ` never fired. The rc parser now canonicalizes `<...>` key names on both sides of a mapping: any case and the usual aliases (Esc, CR/Enter/Return, BS, Del, Ins, Space, Tab, Bar, Bslash, arrows, Home/End, PageUp/PageDown, F1-F12), modifier prefixes in any case and order (C-/S-/A-/M-, D- as Command), `` kept literal on the right-hand side, Ex mappings ending in /, and leader values containing `$&`. Unknown tokens are left untouched. Closes #2281 Co-authored-by: ppxb <17081240+ppxb@users.noreply.github.com> --- src/features/vim/__tests__/rc.test.ts | 101 +++++++++++++++++++++++++ src/features/vim/__tests__/vim.test.ts | 41 ++++++++++ src/features/vim/rc.ts | 73 ++++++++++++++++-- 3 files changed, 209 insertions(+), 6 deletions(-) diff --git a/src/features/vim/__tests__/rc.test.ts b/src/features/vim/__tests__/rc.test.ts index 7dfe9a82a..e0d1d9e05 100644 --- a/src/features/vim/__tests__/rc.test.ts +++ b/src/features/vim/__tests__/rc.test.ts @@ -49,6 +49,107 @@ describe("ideavimrc parsing", () => { ]); }); + it("canonicalises special-key names in mappings, whatever their case", () => { + const { commands } = parseVimrc("inoremap jk \nmap \nimap "); + expect(commands).toEqual([ + { kind: "map", mode: "insert", noremap: true, lhs: "jk", rhs: "" }, + { kind: "map", mode: undefined, noremap: false, lhs: "", rhs: "" }, + { kind: "map", mode: "insert", noremap: false, lhs: "", rhs: "" }, + ]); + }); + + it.each([ + ["esc", ""], + ["cr", ""], + ["enter", ""], + ["return", ""], + ["tab", ""], + ["bs", ""], + ["backspace", ""], + ["space", ""], + ["del", ""], + ["delete", ""], + ["up", ""], + ["down", ""], + ["left", ""], + ["right", ""], + ["home", ""], + ["end", ""], + ["pageup", ""], + ["pagedown", ""], + ["ins", ""], + ["insert", ""], + ["bar", "|"], + ["bslash", "\\"], + ...Array.from({ length: 12 }, (_, i) => [`f${i + 1}`, ``]), + ])("normalizes %s on both sides in any case", (name, canonical) => { + for (const spelling of [name!, name!.toUpperCase(), name![0]!.toUpperCase() + name!.slice(1)]) { + expect(parseVimrc(`nmap <${spelling}> <${spelling}>`).commands[0]).toMatchObject({ + lhs: canonical, + rhs: canonical, + }); + } + }); + + it.each([ + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", "X"], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["", ""], + ["<", "<"], + ["<", "<"], + ["", ""], + ])("normalizes modifiers and preserves unknown tokens: %s", (keys, canonical) => { + expect(parseVimrc(`nmap ${keys} ${keys}`).commands[0]).toMatchObject({ lhs: canonical, rhs: canonical }); + }); + + it("keeps escaped less-than separate from following key names", () => { + expect(parseVimrc("nmap Esc>").commands[0]).toMatchObject({ lhs: "<", rhs: "Esc>" }); + expect(parseVimrc("nmap x").commands[0]).toMatchObject({ + lhs: "", + rhs: "x", + }); + }); + + it("expands mixed-case leaders on both sides without replacement-string interpolation", () => { + expect(parseVimrc('let mapleader="$&"\nnmap a b').commands[0]).toMatchObject({ + lhs: "$&a", + rhs: "$&b", + }); + expect(parseVimrc('let mapleader="\\"\nnmap a b').commands[0]).toMatchObject({ + lhs: "a", + rhs: "b", + }); + }); + + it("normalizes Ex mapping lhs while preserving command and action text", () => { + expect(parseVimrc("nmap (GotoDeclaration)\nnmap :echo ").commands).toEqual([ + { kind: "mapEx", mode: "normal", lhs: "", ex: "action GotoDeclaration" }, + { kind: "mapEx", mode: "normal", lhs: "", ex: "echo" }, + ]); + }); + + it.each(["cr", "CR", "enter", "ENTER", "return", "RETURN"])("recognizes Ex terminator <%s>", (enter) => { + expect(parseVimrc(`nmap :w<${enter}>`).commands[0]).toEqual({ + kind: "mapEx", + mode: "normal", + lhs: "", + ex: "w", + }); + }); + it("reports everything else as ignored, never executed", () => { const reasons = ignored.map((i) => `${i.line}:${i.reason}`); expect(reasons).toContain("15:cannot read file"); diff --git a/src/features/vim/__tests__/vim.test.ts b/src/features/vim/__tests__/vim.test.ts index 069c2ee18..8b4943c5f 100644 --- a/src/features/vim/__tests__/vim.test.ts +++ b/src/features/vim/__tests__/vim.test.ts @@ -115,6 +115,47 @@ describe("Vim engine", () => { view.destroy(); }); + it("applies insert-mode mappings, accepting in any case", () => { + const view = makeView(); + const cm = getCM(view)!; + applyRcCommands(parseVimrc("inoremap jk ").commands); + Vim.handleKey(cm as never, "i", "user"); + expect(cm.state.vim?.insertMode).toBe(true); + Vim.handleKey(cm as never, "j", "user"); + Vim.handleKey(cm as never, "k", "user"); + expect(cm.state.vim?.insertMode).toBe(false); + view.destroy(); + }); + + it.each([ + ["", { key: "F1" }], + ["", { key: "Tab", shiftKey: true }], + ["", { key: "f", ctrlKey: true }], + ["", { key: "X", ctrlKey: true, shiftKey: true }], + ["", { key: "x", altKey: true }], + ["", { key: "x", metaKey: true }], + ])("matches browser key events for %s", (lhs, event) => { + const view = makeView(); + const cm = getCM(view)!; + applyRcCommands(parseVimrc(`nnoremap ${lhs} i`).commands); + const key = Vim.vimKeyFromEvent(new KeyboardEvent("keydown", event)); + Vim.handleKey(cm as never, key!, "user"); + expect(cm.state.vim?.insertMode).toBe(true); + view.destroy(); + }); + + it("inserts escaped key notation literally", () => { + const view = makeView(); + const cm = getCM(view)!; + applyRcCommands(parseVimrc("inoremap jk Esc>").commands); + Vim.handleKey(cm as never, "i", "user"); + Vim.handleKey(cm as never, "j", "user"); + Vim.handleKey(cm as never, "k", "user"); + expect(cm.state.vim?.insertMode).toBe(true); + expect(view.state.doc.toString()).toBe("|\\one two\nthree"); + view.destroy(); + }); + it("keeps an IME composition out of the document in Normal mode", async () => { const transient: boolean[] = []; const view = makeView([ diff --git a/src/features/vim/rc.ts b/src/features/vim/rc.ts index 3d5a550c4..c68f1d5c8 100644 --- a/src/features/vim/rc.ts +++ b/src/features/vim/rc.ts @@ -40,6 +40,67 @@ const MAP_MODES: Record = { i: "insert", o: "operatorPending", }; +/** + * Vim special-key names are case-insensitive (``, ``, ``, ``…), but + * codemirror-vim compares the exact canonical spelling, so a lowercase `` in a mapping + * never matches and the mapping does nothing. Canonical names, keyed by lower-cased name. + */ +const SPECIAL_KEYS: Record = { + esc: "Esc", + return: "CR", + enter: "CR", + cr: "CR", + bs: "BS", + backspace: "BS", + del: "Del", + delete: "Del", + ins: "Ins", + insert: "Ins", + space: "Space", + tab: "Tab", + lt: "lt", + bar: "|", + bslash: "\\", + left: "Left", + right: "Right", + up: "Up", + down: "Down", + home: "Home", + end: "End", + pageup: "PageUp", + pagedown: "PageDown", +}; + +/** Rewrites ``/``/``… to the exact ``/``/`` codemirror-vim matches. */ +function normalizeVimKeys(keys: string, rhs = false): string { + return keys.replace(/<([^<>\s]+)>/g, (whole, inner: string) => { + const m = /^((?:[csamd]-)*)(.+)$/i.exec(inner); + if (!m) return whole; + const prefixes = m[1]!.toUpperCase().split("-"); + const name = m[2]!; + const lower = name.toLowerCase(); + let canonical = Object.hasOwn(SPECIAL_KEYS, lower) + ? SPECIAL_KEYS[lower]! + : /^f(?:[1-9]|1[0-2])$/.test(lower) + ? name.toUpperCase() + : prefixes.length > 1 && name.length === 1 + ? name + : null; + if (canonical === null) return whole; + if (canonical === "lt") canonical = rhs ? "" : "<"; + // Vim's M- aliases Alt; D- is Command (the engine's M-). + const mods = [ + prefixes.includes("C") ? "C-" : "", + prefixes.includes("A") || prefixes.includes("M") ? "A-" : "", + prefixes.includes("D") ? "M-" : "", + ].join(""); + const shift = prefixes.includes("S"); + if (shift && canonical.length === 1) canonical = canonical.toUpperCase(); + if (!mods && canonical.length === 1) return canonical; + if (!mods && canonical === "") return canonical; + return `<${mods}${shift ? "S-" : ""}${canonical}>`; + }); +} /** Options the Vim engine implements (others, e.g. IdeaVim's `ideajoin`, are reported as ignored). */ const SUPPORTED_OPTIONS = new Set(["textwidth", "tw", "pcre", "insertModeEscKeysTimeout", "langmap", "lmap"]); const MAP_FLAGS = /^<(silent|unique|nowait|buffer|expr|script)>$/i; @@ -64,7 +125,7 @@ function splitArgs(s: string): string[] { export function exOfRhs(rhs: string): string | null { const action = /^\(([^)\s]+)\)$/i.exec(rhs); if (action) return `action ${action[1]}`; - const ex = /^:(?:)?([^<]+)$/i.exec(rhs); + const ex = /^:(?:)?([^<]+)<(?:CR|Enter|Return)>$/i.exec(rhs); return ex ? ex[1]!.trim() : null; } @@ -148,13 +209,13 @@ export function parseVimrc(text: string, sources?: ReadonlyMap !MAP_FLAGS.test(a)); if (args.length < 2) return ignore("mapping needs a left and right side"); - const withLeader = (keys: string) => keys.replace(//gi, leader); - const lhs = withLeader(args[0]!); - const rhs = withLeader(args.slice(1).join(" ")); + const withLeader = (keys: string) => keys.replace(//gi, () => leader); + const lhs = normalizeVimKeys(withLeader(args[0]!)); + const rawRhs = withLeader(args.slice(1).join(" ")); const mode = MAP_MODES[map[1] ?? ""]; - const ex = exOfRhs(rhs); + const ex = exOfRhs(rawRhs); if (ex) commands.push({ kind: "mapEx", mode, lhs, ex }); - else commands.push({ kind: "map", mode, noremap: map[2] !== "map", lhs, rhs }); + else commands.push({ kind: "map", mode, noremap: map[2] !== "map", lhs, rhs: normalizeVimKeys(rawRhs, true) }); return; }