дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
b12fabd472
коміт
a206ac0ca5
3 files changed
+209
-6
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача