From d0691d8c17e8ebe9ee54b9463048206f54be8668 Mon Sep 17 00:00:00 2001 From: digitalby Date: Sun, 12 Apr 2026 16:19:41 +0200 Subject: [PATCH 1/2] feat: implement :source ex command --- src/cmd_line/commands/source.ts | 137 +++++++++++++++++++++++++++++++ src/vimscript/exCommandParser.ts | 3 +- test/cmd_line/source.test.ts | 123 +++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/cmd_line/commands/source.ts create mode 100644 test/cmd_line/source.test.ts diff --git a/src/cmd_line/commands/source.ts b/src/cmd_line/commands/source.ts new file mode 100644 index 00000000000..709e0b760da --- /dev/null +++ b/src/cmd_line/commands/source.ts @@ -0,0 +1,137 @@ +import * as os from 'os'; +import { optWhitespace, Parser, whitespace } from 'parsimmon'; +import * as path from 'path'; +import { existsAsync, readFileAsync } from 'platform/fs'; +import * as vscode from 'vscode'; +import { configuration } from '../../configuration/configuration'; +import { VimrcImpl } from '../../configuration/vimrc'; +import { vimrcKeyRemappingBuilder } from '../../configuration/vimrcKeyRemappingBuilder'; +import { VimState } from '../../state/vimState'; +import { StatusBar } from '../../statusBar'; +import { Logger } from '../../util/logger'; +import { ExCommand } from '../../vimscript/exCommand'; +import { fileNameParser } from '../../vimscript/parserUtils'; +import { ExCommandLine } from '../commandLine'; + +// +// Implements :source +// http://vimdoc.sourceforge.net/htmldoc/repeat.html#:source +// +// Reads a file and executes each line as an ex command. For each line we +// first try to interpret it as a key mapping (nnoremap, etc.) via the same +// builder used by `vim.vimrc.path`, falling back to the regular ex-command +// parser for everything else (:set, :edit, :nohl, ...). +// +export class SourceCommand extends ExCommand { + public static readonly argParser: Parser = whitespace + .then(fileNameParser) + .skip(optWhitespace) + .map((file) => new SourceCommand(file)); + + private static readonly activeSources: Set = new Set(); + + private readonly file: string; + + constructor(file: string) { + super(); + this.file = file; + } + + async execute(vimState: VimState): Promise { + const resolved = SourceCommand.resolvePath(vimState, this.file); + + if (!(await existsAsync(resolved))) { + StatusBar.setText(vimState, `E484: Can't open file ${this.file}`, true); + return; + } + + if (SourceCommand.activeSources.has(resolved)) { + StatusBar.setText(vimState, `E1092: Recursive use of :source in ${this.file}`, true); + return; + } + + SourceCommand.activeSources.add(resolved); + try { + await SourceCommand.sourceFile(vimState, resolved); + } finally { + SourceCommand.activeSources.delete(resolved); + } + } + + private static async sourceFile(vimState: VimState, filePath: string): Promise { + const content = await readFileAsync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const vscodeCommands = await vscode.commands.getCommands(); + + let errors = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + if (trimmed.length === 0 || trimmed.startsWith('"')) { + continue; + } + + try { + const remap = await vimrcKeyRemappingBuilder.build(line, vscodeCommands); + if (remap) { + VimrcImpl.addRemapToConfig(configuration, remap); + continue; + } + const unremap = await vimrcKeyRemappingBuilder.buildUnmapping(line); + if (unremap) { + VimrcImpl.removeRemapFromConfig(configuration, unremap); + continue; + } + const clearRemap = await vimrcKeyRemappingBuilder.buildClearMapping(line); + if (clearRemap) { + VimrcImpl.clearRemapsFromConfig(configuration, clearRemap); + continue; + } + + const parsed = ExCommandLine.parser.tryParse(trimmed); + if (parsed.lineRange) { + await parsed.command.executeWithRange(vimState, parsed.lineRange); + } else { + await parsed.command.execute(vimState); + } + } catch (err) { + errors++; + Logger.warn( + `:source ${filePath}: line ${i + 1}: ${err instanceof Error ? err.message : err}`, + ); + } + } + + if (errors > 0) { + StatusBar.setText( + vimState, + `:source ${path.basename(filePath)} completed with ${errors} error${errors === 1 ? '' : 's'}`, + true, + ); + } + } + + private static resolvePath(vimState: VimState, file: string): string { + const expanded = SourceCommand.expandHome(file); + if (path.isAbsolute(expanded)) { + return expanded; + } + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (workspaceFolder) { + return path.resolve(workspaceFolder, expanded); + } + const docPath = vimState.document.uri.fsPath; + if (docPath) { + return path.resolve(path.dirname(docPath), expanded); + } + return path.resolve(expanded); + } + + private static expandHome(filePath: string): string { + const match = /^(~|\$HOME)(.*)$/.exec(filePath); + if (!match) { + return filePath; + } + return path.join(os.homedir(), match[2]); + } +} diff --git a/src/vimscript/exCommandParser.ts b/src/vimscript/exCommandParser.ts index 3cdb5f77b86..865b56e4f0e 100644 --- a/src/vimscript/exCommandParser.ts +++ b/src/vimscript/exCommandParser.ts @@ -46,6 +46,7 @@ import { ShCommand } from '../cmd_line/commands/sh'; import { ShiftCommand } from '../cmd_line/commands/shift'; import { SmileCommand } from '../cmd_line/commands/smile'; import { SortCommand } from '../cmd_line/commands/sort'; +import { SourceCommand } from '../cmd_line/commands/source'; import { SubstituteCommand } from '../cmd_line/commands/substitute'; import { TabCommand } from '../cmd_line/commands/tab'; import { TerminalCommand } from '../cmd_line/commands/terminal'; @@ -503,7 +504,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['sno', 'magic'], undefined], [['snor', 'emap'], undefined], [['snoreme', 'nu'], undefined], - [['so', 'urce'], undefined], + [['so', 'urce'], SourceCommand.argParser], [['sor', 't'], SortCommand.argParser], [['sp', 'lit'], FileCommand.argParsers.split], [['spe', 'llgood'], undefined], diff --git a/test/cmd_line/source.test.ts b/test/cmd_line/source.test.ts new file mode 100644 index 00000000000..bd3b1e38c2f --- /dev/null +++ b/test/cmd_line/source.test.ts @@ -0,0 +1,123 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; + +import { getAndUpdateModeHandler } from '../../extension'; +import { ExCommandLine } from '../../src/cmd_line/commandLine'; +import { configuration } from '../../src/configuration/configuration'; +import { ModeHandler } from '../../src/mode/modeHandler'; +import { StatusBar } from '../../src/statusBar'; +import { setupWorkspace } from '../testUtils'; + +const writeFile = promisify(fs.writeFile); +const unlink = promisify(fs.unlink); + +function tmpFile(name: string): string { + return path.join( + os.tmpdir(), + `vscodevim-source-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`, + ); +} + +async function runSource(modeHandler: ModeHandler, file: string): Promise { + await new ExCommandLine(`source ${file}`, modeHandler.vimState.currentMode).run( + modeHandler.vimState, + ); +} + +suite(':source', () => { + let modeHandler: ModeHandler; + const tempFiles: string[] = []; + + setup(async () => { + await setupWorkspace(); + modeHandler = (await getAndUpdateModeHandler())!; + }); + + teardown(async () => { + while (tempFiles.length) { + const f = tempFiles.pop()!; + try { + await unlink(f); + } catch { + // ignore + } + } + }); + + const fixture = async (name: string, contents: string): Promise => { + const p = tmpFile(name); + await writeFile(p, contents); + tempFiles.push(p); + return p; + }; + + test('sources an nnoremap line into the live configuration', async () => { + const before = configuration.normalModeKeyBindingsNonRecursive.length; + const file = await fixture('mapping.vim', 'nnoremap zz :nohl\n'); + + await runSource(modeHandler, file); + + assert.strictEqual( + configuration.normalModeKeyBindingsNonRecursive.length, + before + 1, + 'expected one new non-recursive normal-mode binding', + ); + }); + + test('skips comments and blank lines', async () => { + const before = configuration.normalModeKeyBindingsNonRecursive.length; + const file = await fixture( + 'comments.vim', + ['" a comment', '', ' " indented comment', 'nnoremap xx :nohl', ''].join('\n'), + ); + + await runSource(modeHandler, file); + + assert.strictEqual(configuration.normalModeKeyBindingsNonRecursive.length, before + 1); + }); + + test('executes regular ex commands (:nohl)', async () => { + const file = await fixture('nohl.vim', 'nohl\n'); + await runSource(modeHandler, file); + // :nohl is a no-op when no search is active; the test verifies it + // does not error out and no "not yet implemented" status is set. + assert.ok(!StatusBar.getText().includes('not yet implemented')); + }); + + test('follows nested :source', async () => { + const before = configuration.normalModeKeyBindingsNonRecursive.length; + const inner = await fixture('inner.vim', 'nnoremap aa :nohl\n'); + const outer = await fixture('outer.vim', `source ${inner}\n`); + + await runSource(modeHandler, outer); + + assert.strictEqual(configuration.normalModeKeyBindingsNonRecursive.length, before + 1); + }); + + test('detects recursive source cycles', async () => { + const aPath = tmpFile('cycle-a.vim'); + const bPath = tmpFile('cycle-b.vim'); + tempFiles.push(aPath, bPath); + await writeFile(aPath, `source ${bPath}\n`); + await writeFile(bPath, `source ${aPath}\n`); + + await runSource(modeHandler, aPath); + + assert.ok( + StatusBar.getText().includes('Recursive') || StatusBar.getText().includes('error'), + `expected recursion error in status bar, got: ${StatusBar.getText()}`, + ); + }); + + test('reports missing file via status bar', async () => { + const missing = path.join(os.tmpdir(), 'vscodevim-source-does-not-exist.vim'); + await runSource(modeHandler, missing); + assert.ok( + StatusBar.getText().includes("Can't open file"), + `expected missing-file error, got: ${StatusBar.getText()}`, + ); + }); +}); From 01ce319ff1e666673cfe62145f9a7482e386dc8e Mon Sep 17 00:00:00 2001 From: digitalby Date: Fri, 24 Apr 2026 13:31:07 +0200 Subject: [PATCH 2/2] fix: address :source review comments so indented mappings, I/O failures, unnormalized absolute paths and a hard-coded missing-file path don't silently break --- src/cmd_line/commands/source.ts | 19 +++++++++++++------ test/cmd_line/source.test.ts | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/cmd_line/commands/source.ts b/src/cmd_line/commands/source.ts index 709e0b760da..8a27267cfce 100644 --- a/src/cmd_line/commands/source.ts +++ b/src/cmd_line/commands/source.ts @@ -59,7 +59,13 @@ export class SourceCommand extends ExCommand { } private static async sourceFile(vimState: VimState, filePath: string): Promise { - const content = await readFileAsync(filePath, 'utf8'); + let content: string; + try { + content = await readFileAsync(filePath, 'utf8'); + } catch { + StatusBar.setText(vimState, `E484: Can't open file ${path.basename(filePath)}`, true); + return; + } const lines = content.split(/\r?\n/); const vscodeCommands = await vscode.commands.getCommands(); @@ -72,17 +78,17 @@ export class SourceCommand extends ExCommand { } try { - const remap = await vimrcKeyRemappingBuilder.build(line, vscodeCommands); + const remap = await vimrcKeyRemappingBuilder.build(trimmed, vscodeCommands); if (remap) { VimrcImpl.addRemapToConfig(configuration, remap); continue; } - const unremap = await vimrcKeyRemappingBuilder.buildUnmapping(line); + const unremap = await vimrcKeyRemappingBuilder.buildUnmapping(trimmed); if (unremap) { VimrcImpl.removeRemapFromConfig(configuration, unremap); continue; } - const clearRemap = await vimrcKeyRemappingBuilder.buildClearMapping(line); + const clearRemap = await vimrcKeyRemappingBuilder.buildClearMapping(trimmed); if (clearRemap) { VimrcImpl.clearRemapsFromConfig(configuration, clearRemap); continue; @@ -114,7 +120,7 @@ export class SourceCommand extends ExCommand { private static resolvePath(vimState: VimState, file: string): string { const expanded = SourceCommand.expandHome(file); if (path.isAbsolute(expanded)) { - return expanded; + return path.resolve(expanded); } const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (workspaceFolder) { @@ -132,6 +138,7 @@ export class SourceCommand extends ExCommand { if (!match) { return filePath; } - return path.join(os.homedir(), match[2]); + const relativePath = match[2].replace(/^[/\\]+/, ''); + return path.join(os.homedir(), relativePath); } } diff --git a/test/cmd_line/source.test.ts b/test/cmd_line/source.test.ts index bd3b1e38c2f..82e5c63f104 100644 --- a/test/cmd_line/source.test.ts +++ b/test/cmd_line/source.test.ts @@ -113,7 +113,7 @@ suite(':source', () => { }); test('reports missing file via status bar', async () => { - const missing = path.join(os.tmpdir(), 'vscodevim-source-does-not-exist.vim'); + const missing = tmpFile('does-not-exist.vim'); await runSource(modeHandler, missing); assert.ok( StatusBar.getText().includes("Can't open file"),