Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/cmd_line/commands/source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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<SourceCommand> = whitespace
.then(fileNameParser)
.skip(optWhitespace)
.map((file) => new SourceCommand(file));

private static readonly activeSources: Set<string> = new Set();

private readonly file: string;

constructor(file: string) {
super();
this.file = file;
}

async execute(vimState: VimState): Promise<void> {
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<void> {
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();

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(trimmed, vscodeCommands);
if (remap) {
VimrcImpl.addRemapToConfig(configuration, remap);
continue;
}
const unremap = await vimrcKeyRemappingBuilder.buildUnmapping(trimmed);
if (unremap) {
VimrcImpl.removeRemapFromConfig(configuration, unremap);
continue;
}
const clearRemap = await vimrcKeyRemappingBuilder.buildClearMapping(trimmed);
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 path.resolve(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;
}
const relativePath = match[2].replace(/^[/\\]+/, '');
return path.join(os.homedir(), relativePath);
}
}
3 changes: 2 additions & 1 deletion src/vimscript/exCommandParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
123 changes: 123 additions & 0 deletions test/cmd_line/source.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> => {
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 <leader>zz :nohl<CR>\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 <leader>xx :nohl<CR>', ''].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 <leader>aa :nohl<CR>\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 = tmpFile('does-not-exist.vim');
await runSource(modeHandler, missing);
assert.ok(
StatusBar.getText().includes("Can't open file"),
`expected missing-file error, got: ${StatusBar.getText()}`,
Comment thread
digitalby marked this conversation as resolved.
);
});
});