import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { parseCanonicalKeymap } from "./canonical-parser.js"; import type { ReadlineKeymap, ReadlineKeymapName } from "./keymap.js"; import type { ResolvedReadlineProfile } from "./profile-loader.js"; export type InputrcReader = (path: string) => Promise; type MutableProfile = { command: ReadlineKeymap; insert: ReadlineKeymap; keymap: ReadlineKeymapName; variables: Map; }; function selected(profile: MutableProfile): ReadlineKeymap { return profile.keymap === "vi-command" ? profile.command : profile.insert; } function replaceSelected( profile: MutableProfile, keymap: ReadlineKeymap, ): void { if (profile.keymap === "vi-command") profile.command = keymap; else profile.insert = keymap; } function normalizeKeymap(value: string): ReadlineKeymapName | undefined { if (value === "vi-command" || value === "vi-move") return "vi-command"; if (value === "vi-insertion" || value === "vi-insert") return "vi-insert"; return undefined; } /** Portable subset used when Bash cannot canonicalize a user's inputrc. */ export async function loadPortableInputrc( base: ResolvedReadlineProfile, inputrcPath: string, reader: InputrcReader = (path) => readFile(path, "utf8"), maxDepth = 16, ): Promise { const state: MutableProfile = { command: base.viCommand, insert: base.viInsert, keymap: "vi-insert", variables: new Map(base.variables), }; const activeFiles = new Set(); const visit = async (path: string, depth: number): Promise => { if (depth > maxDepth) throw new Error(`Inputrc include depth exceeds ${maxDepth}`); const absolute = resolve(path); if (activeFiles.has(absolute)) throw new Error(`Inputrc include cycle at ${absolute}`); activeFiles.add(absolute); const source = await reader(absolute); const conditions: Array<{ parent: boolean; matched: boolean; active: boolean; }> = []; const isActive = () => conditions.every((condition) => condition.active); for (const rawLine of source.split(/\r?\n/u)) { const line = rawLine.trim(); if (line.length === 0 || line.startsWith("#")) continue; if (line.startsWith("$if ")) { const parent = isActive(); const expression = line.slice(4).trim().toLowerCase(); const matched = expression === "bash" || expression === "mode=vi"; conditions.push({ parent, matched, active: parent && matched }); continue; } if (line === "$else") { const condition = conditions.at(-1); if (condition === undefined) throw new Error("Inputrc $else without $if"); condition.active = condition.parent && !condition.matched; continue; } if (line === "$endif") { if (conditions.pop() === undefined) throw new Error("Inputrc $endif without $if"); continue; } if (!isActive()) continue; if (line.startsWith("$include ")) { const include = line.slice(9).trim(); await visit(resolve(dirname(absolute), include), depth + 1); continue; } const variableMatch = line.match(/^set\s+(\S+)\s+(.*)$/u); if (variableMatch !== null) { const name = variableMatch[1] ?? ""; const value = variableMatch[2] ?? ""; if (name === "keymap") { const keymap = normalizeKeymap(value); if (keymap !== undefined) state.keymap = keymap; } else { state.variables.set(name, value); } continue; } if (line.startsWith('"')) { const parsed = parseCanonicalKeymap(state.keymap, line); const entry = parsed.entries()[0]; if (entry !== undefined) replaceSelected( state, selected(state).withBinding(entry[0], entry[1]), ); } } if (conditions.length > 0) throw new Error(`Unclosed inputrc conditional in ${absolute}`); activeFiles.delete(absolute); }; await visit(inputrcPath, 0); return { ...base, viCommand: state.command, viInsert: state.insert, variables: state.variables, }; }