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