дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
7f9dda50b6
коміт
c08b0f543e
5 files changed
+124
-12
No files matched your search
@@ -61,15 +61,16 @@ export function VimSettingsPage() {
|
||||
</p>
|
||||
<p className={styles.hint}>
|
||||
<T>
|
||||
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.
|
||||
</T>
|
||||
</p>
|
||||
{rc.ignored.length > 0 && (
|
||||
<ul className={styles.ignored} aria-label={tx("Ignored lines")}>
|
||||
{rc.ignored.map((i) => (
|
||||
<li key={i.line}>
|
||||
<li key={`${i.file ?? ""}:${i.line}`}>
|
||||
<span className={styles.mono}>
|
||||
{i.file ? `${i.file}:` : ""}
|
||||
{i.line}: {i.text}
|
||||
</span>{" "}
|
||||
— {i.reason}
|
||||
|
||||
@@ -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 <leader>a b']]);
|
||||
const { commands, ignored } = parseVimrc("nmap x y\nsource ~/.vimrc\nnmap <leader>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<string, string> = { "/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", ""],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>` / `<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<string, MapMode> = {
|
||||
"": 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<string, string | null> | 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<string, string | null>): 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);
|
||||
|
||||
|
||||
@@ -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<RcFile | null> {
|
||||
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<string>,
|
||||
home: string,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const sources = new Map<string, string | null>();
|
||||
const pending = sourcedPaths(text);
|
||||
while (pending.length && sources.size < MAX_SOURCED_FILES) {
|
||||
const path = pending.shift()!;
|
||||
if (sources.has(path)) continue;
|
||||
const content = await read(path.replace(/^~(?=$|[\\/])/, home)).catch(() => null);
|
||||
sources.set(path, content);
|
||||
if (content !== null) pending.push(...sourcedPaths(content));
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export interface RcStatus {
|
||||
/** The file read, or null when there is none. */
|
||||
path: string | null;
|
||||
@@ -36,7 +56,12 @@ export const useVimRc = create<RcStatus>(() => ({ path: null, mappings: 0, ignor
|
||||
/** Reads and parses the rc file, publishing what was found. */
|
||||
export async function loadRcFile(): Promise<RcParseResult> {
|
||||
const file = await readRcFile().catch(() => null);
|
||||
const result = parseVimrc(file?.text ?? "");
|
||||
let sources: Map<string, string | null> | undefined;
|
||||
if (file) {
|
||||
const { homeDir } = await import("@tauri-apps/api/path");
|
||||
sources = await readSourcedFiles(file.text, (path) => call(commands.fsReadText(path)), await homeDir());
|
||||
}
|
||||
const result = parseVimrc(file?.text ?? "", sources);
|
||||
useVimRc.setState({
|
||||
path: file?.path ?? null,
|
||||
mappings: result.commands.filter((c) => c.kind !== "set").length,
|
||||
|
||||
@@ -3386,7 +3386,7 @@
|
||||
"Configuration file": "配置文件",
|
||||
"mappings": "个映射",
|
||||
"No ~/.openideavimrc or ~/.ideavimrc found.": "未找到 ~/.openideavimrc 或 ~/.ideavimrc。",
|
||||
"Supported: set options, map / nmap / imap / vmap / noremap variants, let mapleader, and :action / <Action>() mappings that run IDE actions. :w saves, :q closes the editor tab.": "支持:set 选项、map / nmap / imap / vmap / noremap 等映射、let mapleader,以及运行 IDE 操作的 :action / <Action>() 映射。:w 保存,:q 关闭编辑器标签页。",
|
||||
"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.": "支持:set 选项、map / nmap / imap / vmap / noremap 等映射、let mapleader、source,以及运行 IDE 操作的 :action / <Action>() 映射。:w 保存,:q 关闭编辑器标签页。",
|
||||
"Shortcut conflicts": "快捷键冲突",
|
||||
"Keys Vim also handles. With Vim as the handler the key goes to the editor while Vim mode is on and an editor has focus; with IDE, an enabled IDE action runs first.": "Vim 也会处理的按键。处理程序为 Vim 时,在 Vim 模式开启且编辑器获得焦点时按键交给编辑器;为 IDE 时,先运行可用的 IDE 操作。",
|
||||
"Shortcut": "快捷键",
|
||||
|
||||
Reference in new issue
Заблокувати користувача