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 <esc>` 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), `<lt>` kept literal on the right-hand side, Ex mappings ending in <Enter>/<Return>, and leader values containing `$&`. Unknown tokens are left untouched.

Closes #2281

Co-authored-by: ppxb <[email protected]>
Цей коміт міститься в:
ppxbтаppxb authored and GitHub committed 2026-11-09 15:23:41 +08:00
1 джерело b12fabd472
коміт a206ac0ca5
3 files changed
+209 -6

No files matched your search

+101
Переглянути файл
@@ -49,6 +49,107 @@ describe("ideavimrc parsing", () => {
]);
});
it("canonicalises special-key names in mappings, whatever their case", () => {
const { commands } = parseVimrc("inoremap jk <esc>\nmap <c-c> <C-C>\nimap <CR> <cr>");
expect(commands).toEqual([
{ kind: "map", mode: "insert", noremap: true, lhs: "jk", rhs: "<Esc>" },
{ kind: "map", mode: undefined, noremap: false, lhs: "<C-c>", rhs: "<C-C>" },
{ kind: "map", mode: "insert", noremap: false, lhs: "<CR>", rhs: "<CR>" },
]);
});
it.each([
["esc", "<Esc>"],
["cr", "<CR>"],
["enter", "<CR>"],
["return", "<CR>"],
["tab", "<Tab>"],
["bs", "<BS>"],
["backspace", "<BS>"],
["space", "<Space>"],
["del", "<Del>"],
["delete", "<Del>"],
["up", "<Up>"],
["down", "<Down>"],
["left", "<Left>"],
["right", "<Right>"],
["home", "<Home>"],
["end", "<End>"],
["pageup", "<PageUp>"],
["pagedown", "<PageDown>"],
["ins", "<Ins>"],
["insert", "<Ins>"],
["bar", "|"],
["bslash", "\\"],
...Array.from({ length: 12 }, (_, i) => [`f${i + 1}`, `<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([
["<c-f>", "<C-f>"],
["<s-tab>", "<S-Tab>"],
["<s-cr>", "<S-CR>"],
["<a-x>", "<A-x>"],
["<m-x>", "<A-x>"],
["<d-x>", "<M-x>"],
["<s-c-x>", "<C-S-X>"],
["<C-S-x>", "<C-S-X>"],
["<s-x>", "X"],
["<d-a-c-s-x>", "<C-A-M-S-X>"],
["<c-C>", "<C-C>"],
["<frob>", "<frob>"],
["<c-Frob>", "<c-Frob>"],
["<z-esc>", "<z-esc>"],
["<constructor>", "<constructor>"],
["<toString>", "<toString>"],
["<", "<"],
["<<esc>", "<<Esc>"],
["<c->", "<c->"],
])("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 <LT> <Lt>Esc>").commands[0]).toMatchObject({ lhs: "<", rhs: "<lt>Esc>" });
expect(parseVimrc("nmap <c-f> <pageup><lt>x").commands[0]).toMatchObject({
lhs: "<C-f>",
rhs: "<PageUp><lt>x",
});
});
it("expands mixed-case leaders on both sides without replacement-string interpolation", () => {
expect(parseVimrc('let mapleader="$&"\nnmap <LeAdEr>a <LEADER>b').commands[0]).toMatchObject({
lhs: "$&a",
rhs: "$&b",
});
expect(parseVimrc('let mapleader="\\<tAb>"\nnmap <LEADER>a <leader>b').commands[0]).toMatchObject({
lhs: "<Tab>a",
rhs: "<Tab>b",
});
});
it("normalizes Ex mapping lhs while preserving command and action text", () => {
expect(parseVimrc("nmap <f1> <Action>(GotoDeclaration)\nnmap <s-tab> :echo <cr>").commands).toEqual([
{ kind: "mapEx", mode: "normal", lhs: "<F1>", ex: "action GotoDeclaration" },
{ kind: "mapEx", mode: "normal", lhs: "<S-Tab>", ex: "echo" },
]);
});
it.each(["cr", "CR", "enter", "ENTER", "return", "RETURN"])("recognizes Ex terminator <%s>", (enter) => {
expect(parseVimrc(`nmap <f1> :w<${enter}>`).commands[0]).toEqual({
kind: "mapEx",
mode: "normal",
lhs: "<F1>",
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");
+41
Переглянути файл
@@ -115,6 +115,47 @@ describe("Vim engine", () => {
view.destroy();
});
it("applies insert-mode mappings, accepting <esc> in any case", () => {
const view = makeView();
const cm = getCM(view)!;
applyRcCommands(parseVimrc("inoremap jk <esc>").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([
["<f1>", { key: "F1" }],
["<s-tab>", { key: "Tab", shiftKey: true }],
["<c-f>", { key: "f", ctrlKey: true }],
["<C-S-x>", { key: "X", ctrlKey: true, shiftKey: true }],
["<m-x>", { key: "x", altKey: true }],
["<d-x>", { 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 <LT>Esc><BAR><BSLASH>").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("<Esc>|\\one two\nthree");
view.destroy();
});
it("keeps an IME composition out of the document in Normal mode", async () => {
const transient: boolean[] = [];
const view = makeView([
+67 -6
Переглянути файл
@@ -40,6 +40,67 @@ const MAP_MODES: Record<string, MapMode> = {
i: "insert",
o: "operatorPending",
};
/**
* Vim special-key names are case-insensitive (`<esc>`, `<Esc>`, `<CR>`, `<cr>`…), but
* codemirror-vim compares the exact canonical spelling, so a lowercase `<esc>` in a mapping
* never matches and the mapping does nothing. Canonical names, keyed by lower-cased name.
*/
const SPECIAL_KEYS: Record<string, string> = {
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 `<esc>`/`<cr>`/`<c-c>`… to the exact `<Esc>`/`<CR>`/`<C-c>` 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 ? "<lt>" : "<";
// 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 === "<lt>") 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 = /^<Action>\(([^)\s]+)\)$/i.exec(rhs);
if (action) return `action ${action[1]}`;
const ex = /^:(?:<C-u>)?([^<]+)<CR>$/i.exec(rhs);
const ex = /^:(?:<C-u>)?([^<]+)<(?:CR|Enter|Return)>$/i.exec(rhs);
return ex ? ex[1]!.trim() : null;
}
@@ -148,13 +209,13 @@ export function parseVimrc(text: string, sources?: ReadonlyMap<string, string |
if (map) {
const args = splitArgs(rest).filter((a) => !MAP_FLAGS.test(a));
if (args.length < 2) return ignore("mapping needs a left and right side");
const withLeader = (keys: string) => keys.replace(/<leader>/gi, leader);
const lhs = withLeader(args[0]!);
const rhs = withLeader(args.slice(1).join(" "));
const withLeader = (keys: string) => keys.replace(/<leader>/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;
}