From c08b0f543e6801a81636a0c846c4f0d08c5296c1 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Sat, 31 Oct 2026 03:06:29 +0800
Subject: [PATCH] feat(vim): read other files through source in the rc file
`source ~/.vimrc` was reported as unsupported, so mappings kept in a shared vimrc had no effect. `source` now reads that file in place by the same rules as the rc file, sharing the leader. Lines it cannot use are listed with the file they came from, and nothing in a sourced file is executed. Unreadable files and files that source themselves are reported.
---
src/features/vim/VimSettingsPage.tsx | 5 ++-
src/features/vim/__tests__/rc.test.ts | 46 ++++++++++++++++++++++-
src/features/vim/rc.ts | 54 ++++++++++++++++++++++++---
src/features/vim/rcFile.ts | 29 +++++++++++++-
src/platform/locales/text/zh.json | 2 +-
5 files changed, 124 insertions(+), 12 deletions(-)
diff --git a/src/features/vim/VimSettingsPage.tsx b/src/features/vim/VimSettingsPage.tsx
index 5639ef7d3..3bb8af3af 100644
--- a/src/features/vim/VimSettingsPage.tsx
+++ b/src/features/vim/VimSettingsPage.tsx
@@ -61,15 +61,16 @@ export function VimSettingsPage() {
- Supported: set options, map / nmap / imap / vmap / noremap variants, let mapleader, and :action /
+ Supported: set options, map / nmap / imap / vmap / noremap variants, let mapleader, source, and :action /
<Action>() mappings that run IDE actions. :w saves, :q closes the editor tab.
{rc.ignored.length > 0 && (
{rc.ignored.map((i) => (
- -
+
-
+ {i.file ? `${i.file}:` : ""}
{i.line}: {i.text}
{" "}
— {i.reason}
diff --git a/src/features/vim/__tests__/rc.test.ts b/src/features/vim/__tests__/rc.test.ts
index f94d58362..7dfe9a82a 100644
--- a/src/features/vim/__tests__/rc.test.ts
+++ b/src/features/vim/__tests__/rc.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { PRESETS } from "@/platform/keymap";
import { exOfRhs, IDEA_ACTIONS, parseVimrc, resolveActionId } from "../rc";
+import { readSourcedFiles } from "../rcFile";
const RC = `
" OpenIDEA vimrc
@@ -50,7 +51,7 @@ describe("ideavimrc parsing", () => {
it("reports everything else as ignored, never executed", () => {
const reasons = ignored.map((i) => `${i.line}:${i.reason}`);
- expect(reasons).toContain("15:unsupported command: source");
+ expect(reasons).toContain("15:cannot read file");
expect(reasons).toContain("16:mapping needs a left and right side");
expect(reasons).toContain("17:unsupported command: Plug");
});
@@ -91,3 +92,46 @@ describe(":action ids", () => {
expect(missing).toEqual([]);
});
});
+
+describe("source", () => {
+ it("applies the sourced file in place, sharing the leader", () => {
+ const sources = new Map([["~/.vimrc", 'let mapleader = ","\nset textwidth=80\nset number\nnmap a b']]);
+ const { commands, ignored } = parseVimrc("nmap x y\nsource ~/.vimrc\nnmap c d", sources);
+ expect(commands).toEqual([
+ { kind: "map", mode: "normal", noremap: false, lhs: "x", rhs: "y" },
+ { kind: "set", name: "textwidth", value: "80" },
+ { kind: "map", mode: "normal", noremap: false, lhs: ",a", rhs: "b" },
+ { kind: "map", mode: "normal", noremap: false, lhs: ",c", rhs: "d" },
+ ]);
+ expect(ignored).toEqual([
+ { file: "~/.vimrc", line: 3, text: "set number", reason: "option not supported: number" },
+ ]);
+ });
+
+ it("reports unreadable files and files that source themselves", () => {
+ const sources = new Map([
+ ["a", "source b"],
+ ["b", "source a"],
+ ["gone", null],
+ ]);
+ expect(parseVimrc("so gone\nsource a", sources).ignored).toEqual([
+ { line: 1, text: "so gone", reason: "cannot read file" },
+ { file: "b", line: 1, text: "source a", reason: "file sources itself" },
+ ]);
+ });
+
+ it("reads nested files once, expanding ~ only at the start", async () => {
+ const files: Record = { "/home/me/.vimrc": "source /etc/extra\nsource ~/.vimrc", "/etc/extra": "" };
+ const read = async (path: string) => {
+ const text = files[path];
+ if (text === undefined) throw new Error("missing");
+ return text;
+ };
+ const sources = await readSourcedFiles("source ~/.vimrc\nsource ~other/x", read, "/home/me");
+ expect([...sources]).toEqual([
+ ["~/.vimrc", files["/home/me/.vimrc"]],
+ ["~other/x", null],
+ ["/etc/extra", ""],
+ ]);
+ });
+});
diff --git a/src/features/vim/rc.ts b/src/features/vim/rc.ts
index 96199afbf..001834214 100644
--- a/src/features/vim/rc.ts
+++ b/src/features/vim/rc.ts
@@ -2,7 +2,8 @@
* `~/.openideavimrc` / `~/.ideavimrc`: the safe subset IdeaVim users keep in
* their rc file — `set` options, `map` variants, `let mapleader` — plus
* IdeaVim's `:action ` / `(id)` mappings, which run OpenIDEA
- * actions. Everything else is reported as ignored, never executed.
+ * actions. `source` reads another file by the same rules. Everything else is
+ * reported as ignored, never executed.
*/
/** codemirror-vim mapping context; undefined = Normal, Visual and Operator-pending. */
@@ -15,6 +16,8 @@ export type RcCommand =
| { kind: "mapEx"; mode: MapMode; lhs: string; ex: string };
export interface RcIgnored {
+ /** The sourced file it came from, as written after `source`; absent for the rc file itself. */
+ file?: string;
line: number;
text: string;
reason: string;
@@ -27,6 +30,7 @@ export interface RcParseResult {
export const RC_FILES = [".openideavimrc", ".ideavimrc"] as const;
+const SOURCE_RE = /^so(?:urce)?\s+(.+)$/;
const MAP_RE = /^(n|v|x|i|o)?(map|noremap)$/;
const MAP_MODES: Record = {
"": undefined,
@@ -64,15 +68,53 @@ export function exOfRhs(rhs: string): string | null {
return ex ? ex[1]!.trim() : null;
}
-export function parseVimrc(text: string): RcParseResult {
+/** What `source` lines name, for reading them ahead of the synchronous parse. */
+export function sourcedPaths(text: string): string[] {
+ return text.split(/\r?\n/).flatMap((raw) => {
+ const m = SOURCE_RE.exec(raw.trim());
+ return m ? [m[1]!.trim()] : [];
+ });
+}
+
+interface RcLine {
+ file?: string;
+ line: number;
+ text: string;
+}
+
+/** The rc file's lines with each readable `source` line replaced by that file's lines. */
+function expandSources(
+ text: string,
+ sources: ReadonlyMap | undefined,
+ ignored: RcIgnored[],
+ file?: string,
+ active: readonly string[] = [],
+): RcLine[] {
+ const at = file === undefined ? {} : { file };
+ return text.split(/\r?\n/).flatMap((raw, i): RcLine[] => {
+ const line = raw.trim();
+ const m = SOURCE_RE.exec(line);
+ if (!m) return [{ ...at, line: i + 1, text: line }];
+ const path = m[1]!.trim();
+ const sourced = sources?.get(path);
+ const reason = active.includes(path) ? "file sources itself" : typeof sourced !== "string" && "cannot read file";
+ if (reason) {
+ ignored.push({ ...at, line: i + 1, text: line, reason });
+ return [];
+ }
+ return expandSources(sourced as string, sources, ignored, path, [...active, path]);
+ });
+}
+
+/** `sources` holds the text of sourced files by the path written in the rc file; null = unreadable. */
+export function parseVimrc(text: string, sources?: ReadonlyMap): RcParseResult {
const commands: RcCommand[] = [];
const ignored: RcIgnored[] = [];
let leader = "\\";
- const lines = text.split(/\r?\n/);
- lines.forEach((raw, i) => {
- const line = raw.trim();
+ expandSources(text, sources, ignored).forEach(({ file, line: lineNumber, text: line }) => {
if (!line || line.startsWith('"')) return;
- const ignore = (reason: string) => ignored.push({ line: i + 1, text: line, reason });
+ const ignore = (reason: string) =>
+ ignored.push({ ...(file === undefined ? {} : { file }), line: lineNumber, text: line, reason });
const [word = "", ...restWords] = splitArgs(line);
const rest = line.slice(line.indexOf(word) + word.length);
diff --git a/src/features/vim/rcFile.ts b/src/features/vim/rcFile.ts
index 382bd4fce..47b91c544 100644
--- a/src/features/vim/rcFile.ts
+++ b/src/features/vim/rcFile.ts
@@ -1,7 +1,7 @@
/** Locating and reading the rc file: `~/.openideavimrc`, else `~/.ideavimrc`. */
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
-import { parseVimrc, RC_FILES, type RcIgnored, type RcParseResult } from "./rc";
+import { parseVimrc, RC_FILES, sourcedPaths, type RcIgnored, type RcParseResult } from "./rc";
export interface RcFile {
path: string;
@@ -23,6 +23,26 @@ export async function readRcFile(): Promise {
return null;
}
+const MAX_SOURCED_FILES = 32;
+
+/** The files `source` lines reach, by the path as written. `~` is the home directory, others stay as given. */
+export async function readSourcedFiles(
+ text: string,
+ read: (path: string) => Promise,
+ home: string,
+): Promise