initial fork from modern VSCode/Vim

This commit is contained in:
jasonwilliams 2024-05-07 00:18:57 +01:00
parent ad779c6282
commit 2d190685d5
204 changed files with 34567 additions and 4949 deletions

View File

@ -1,11 +1,15 @@
import esbuild from 'esbuild';
import path from 'path';
import url from 'url';
const production = process.argv[2] === '--production';
const watch = process.argv[2] === '--watch';
let desktopContext, browserContext;
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// This is the base config that will be used by both web and desktop versions of the extension
const baseConfig = {
entryPoints: ['./src/index.ts'],
entryPoints: ['./extension.ts'],
bundle: true,
external: ['vscode'],
sourcemap: !production,
@ -19,7 +23,19 @@ try {
// https://esbuild.github.io/getting-started/#bundling-for-node
esbuild.context({ ...baseConfig, outfile: './dist/index.js', platform: 'node' }),
// https://esbuild.github.io/getting-started/#bundling-for-the-browser
esbuild.context({ ...baseConfig, outfile: './dist/browser.js', platform: 'browser' }),
esbuild.context({
...baseConfig,
external: ['child_process', 'vscode'],
alias: {
os: 'os-browserify/browser',
path: 'path-browserify',
process: 'process/browser',
util: 'util',
platform: path.resolve(__dirname, 'src', 'platform', 'browser'),
},
outfile: './dist/browser.js',
platform: 'browser',
}),
]);
} catch (e) {
console.error(e);

57
extension.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* Extension.ts is a lightweight wrapper around ModeHandler. It converts key
* events to their string names and passes them on to ModeHandler via
* handleKeyEvent().
*/
import './src/actions/include-main';
import './src/actions/include-plugins';
/**
* Load configuration validator
*/
import './src/configuration/validators/inputMethodSwitcherValidator';
import './src/configuration/validators/remappingValidator';
import './src/configuration/validators/vimrcValidator';
import * as path from 'path';
import * as vscode from 'vscode';
import { activate as activateFunc, loadConfiguration, registerCommand, registerEventListener } from './extensionBase';
import { vimrc } from './src/configuration/vimrc';
import { Globals } from './src/globals';
import { Register } from './src/register/register';
import { Logger } from './src/util/logger';
export { getAndUpdateModeHandler } from './extensionBase';
export async function activate(context: vscode.ExtensionContext) {
// Set the storage path to be used by history files
Globals.extensionStoragePath = context.globalStorageUri.fsPath;
await activateFunc(context);
registerEventListener(context, vscode.workspace.onDidSaveTextDocument, async (document) => {
if (vimrc.vimrcPath && path.relative(document.fileName, vimrc.vimrcPath) === '') {
await loadConfiguration();
Logger.info('Sourced new .vimrc');
}
});
registerCommand(
context,
'vim.editVimrc',
async () => {
if (vimrc.vimrcPath) {
const document = await vscode.workspace.openTextDocument(vimrc.vimrcPath);
await vscode.window.showTextDocument(document);
} else {
await vscode.window.showWarningMessage('No .vimrc found. Please set `vim.vimrc.path`.');
}
},
false,
);
}
export async function deactivate() {
await Register.saveToDisk(true);
}

638
extensionBase.ts Normal file
View File

@ -0,0 +1,638 @@
import * as vscode from 'vscode';
import { ExCommandLine, SearchCommandLine } from './src/cmd_line/commandLine';
import { configuration } from './src/configuration/configuration';
import { Notation } from './src/configuration/notation';
import { Globals } from './src/globals';
import { Jump } from './src/jumps/jump';
import { Mode } from './src/mode/mode';
import { ModeHandler } from './src/mode/modeHandler';
import { ModeHandlerMap } from './src/mode/modeHandlerMap';
import { Register } from './src/register/register';
import { CompositionState } from './src/state/compositionState';
import { globalState } from './src/state/globalState';
import { StatusBar } from './src/statusBar';
import { taskQueue } from './src/taskQueue';
import { Logger } from './src/util/logger';
import { SpecialKeys } from './src/util/specialKeys';
import { VSCodeContext } from './src/util/vscodeContext';
import { exCommandParser } from './src/vimscript/exCommandParser';
let extensionContext: vscode.ExtensionContext;
let previousActiveEditorUri: vscode.Uri | undefined;
let lastClosedModeHandler: ModeHandler | null = null;
interface ICodeKeybinding {
after?: string[];
commands?: Array<{ command: string; args: any[] }>;
}
export async function getAndUpdateModeHandler(
forceSyncAndUpdate = false,
): Promise<ModeHandler | undefined> {
const activeTextEditor = vscode.window.activeTextEditor;
if (activeTextEditor === undefined || activeTextEditor.document.isClosed) {
return undefined;
}
const [curHandler, isNew] = await ModeHandlerMap.getOrCreate(activeTextEditor);
if (isNew) {
extensionContext.subscriptions.push(curHandler);
}
curHandler.vimState.editor = activeTextEditor;
if (
forceSyncAndUpdate ||
!previousActiveEditorUri ||
previousActiveEditorUri !== activeTextEditor.document.uri
) {
// We sync the cursors here because ModeHandler is specific to a document, not an editor, so we
// need to update our representation of the cursors when switching between editors for the same document.
// This will be unnecessary once #4889 is fixed.
curHandler.syncCursors();
await curHandler.updateView({ drawSelection: false, revealRange: false });
}
previousActiveEditorUri = activeTextEditor.document.uri;
if (curHandler.focusChanged) {
curHandler.focusChanged = false;
if (previousActiveEditorUri) {
const prevHandler = ModeHandlerMap.get(previousActiveEditorUri);
prevHandler!.focusChanged = true;
}
}
return curHandler;
}
/**
* Loads and validates the user's configuration
*/
export async function loadConfiguration() {
const validatorResults = await configuration.load();
Logger.debug(`${validatorResults.numErrors} errors found with vim configuration`);
if (validatorResults.numErrors > 0) {
for (const validatorResult of validatorResults.get()) {
switch (validatorResult.level) {
case 'error':
Logger.error(validatorResult.message);
break;
case 'warning':
Logger.warn(validatorResult.message);
break;
}
}
}
}
/**
* The extension's entry point
*/
export async function activate(context: vscode.ExtensionContext, handleLocal: boolean = true) {
ExCommandLine.parser = exCommandParser;
Logger.init();
// before we do anything else, we need to load the configuration
await loadConfiguration();
Logger.debug('Start');
extensionContext = context;
extensionContext.subscriptions.push(StatusBar);
// Load state
Register.loadFromDisk(handleLocal);
await Promise.all([ExCommandLine.loadHistory(context), SearchCommandLine.loadHistory(context)]);
if (vscode.window.activeTextEditor) {
const filepathComponents = vscode.window.activeTextEditor.document.fileName.split(/\\|\//);
Register.setReadonlyRegister('%', filepathComponents[filepathComponents.length - 1]);
}
// workspace events
registerEventListener(
context,
vscode.workspace.onDidChangeConfiguration,
async () => {
Logger.info('Configuration changed');
await loadConfiguration();
},
false,
);
registerEventListener(context, vscode.workspace.onDidChangeTextDocument, async (event) => {
if (event.document.uri.scheme === 'output') {
// Without this, we'll get an infinite logging loop
return;
}
if (event.contentChanges.length === 0) {
// This happens when the document is saved
return;
}
Logger.debug(
`${event.contentChanges.length} change(s) to ${event.document.fileName} because ${event.reason}`,
);
for (const x of event.contentChanges) {
Logger.trace(`\t-${x.rangeLength}, +'${x.text}'`);
}
if (event.contentChanges.length === 1) {
const change = event.contentChanges[0];
const anyLinesDeleted = change.range.start.line !== change.range.end.line;
if (anyLinesDeleted && change.text === '') {
globalState.jumpTracker.handleTextDeleted(event.document, change.range);
} else if (!anyLinesDeleted && change.text.includes('\n')) {
globalState.jumpTracker.handleTextAdded(event.document, change.range, change.text);
} else {
// TODO: What to do here?
}
} else {
// TODO: In this case, we should probably loop over the content changes...
}
// Change from VSCode editor should set document.isDirty to true but they initially don't!
// There is a timing issue in VSCode codebase between when the isDirty flag is set and
// when registered callbacks are fired. https://github.com/Microsoft/vscode/issues/11339
const contentChangeHandler = (modeHandler: ModeHandler) => {
if (modeHandler.vimState.currentMode === Mode.Insert) {
if (modeHandler.vimState.historyTracker.currentContentChanges === undefined) {
modeHandler.vimState.historyTracker.currentContentChanges = [];
}
modeHandler.vimState.historyTracker.currentContentChanges =
modeHandler.vimState.historyTracker.currentContentChanges.concat(event.contentChanges);
}
};
const mh = ModeHandlerMap.get(event.document.uri);
if (mh) {
contentChangeHandler(mh);
}
});
registerEventListener(
context,
vscode.workspace.onDidCloseTextDocument,
async (closedDocument) => {
Logger.info(`${closedDocument.fileName} closed`);
// Delete modehandler once all tabs of this document have been closed
for (const [uri, modeHandler] of ModeHandlerMap.entries()) {
let shouldDelete = false;
if (modeHandler == null) {
shouldDelete = true;
} else {
const document = modeHandler.vimState.document;
if (!vscode.workspace.textDocuments.includes(document)) {
shouldDelete = true;
if (closedDocument === document) {
lastClosedModeHandler = modeHandler;
}
}
}
if (shouldDelete) {
ModeHandlerMap.delete(uri);
}
}
},
false,
);
// window events
registerEventListener(
context,
vscode.window.onDidChangeActiveTextEditor,
async (activeTextEditor: vscode.TextEditor | undefined) => {
if (activeTextEditor) {
Logger.info(`Active editor: ${activeTextEditor.document.uri}`);
} else {
Logger.debug(`No active editor`);
}
const mhPrevious: ModeHandler | undefined = previousActiveEditorUri
? ModeHandlerMap.get(previousActiveEditorUri)
: undefined;
// Track the closed editor so we can use it the next time an open event occurs.
// When vscode changes away from a temporary file, onDidChangeActiveTextEditor first twice.
// First it fires when leaving the closed editor. Then onDidCloseTextDocument first, and we delete
// the old ModeHandler. Then a new editor opens.
//
// This also applies to files that are merely closed, which allows you to jump back to that file similarly
// once a new file is opened.
lastClosedModeHandler = mhPrevious || lastClosedModeHandler;
const oldFileRegister = (await Register.get('%'))?.text;
const relativePath = activeTextEditor
? vscode.workspace.asRelativePath(activeTextEditor.document.uri, false)
: '';
if (relativePath !== oldFileRegister) {
if (oldFileRegister && oldFileRegister !== '') {
Register.setReadonlyRegister('#', oldFileRegister as string);
}
Register.setReadonlyRegister('%', relativePath);
}
if (activeTextEditor === undefined) {
return;
}
taskQueue.enqueueTask(async () => {
const mh = await getAndUpdateModeHandler(true);
if (mh) {
globalState.jumpTracker.handleFileJump(
lastClosedModeHandler ? Jump.fromStateNow(lastClosedModeHandler.vimState) : null,
Jump.fromStateNow(mh.vimState),
);
}
});
},
true,
true,
);
registerEventListener(
context,
vscode.window.onDidChangeTextEditorSelection,
async (e: vscode.TextEditorSelectionChangeEvent) => {
if (e.textEditor.document.uri.scheme === 'output') {
// Without this, we can an infinite logging loop
return;
}
if (
vscode.window.activeTextEditor === undefined ||
e.textEditor.document !== vscode.window.activeTextEditor.document
) {
// We don't care if user selection changed in a paneled window (e.g debug console/terminal)
return;
}
const mh = ModeHandlerMap.get(vscode.window.activeTextEditor.document.uri);
if (mh === undefined) {
// We don't care if there is no active editor
return;
}
if (e.kind !== vscode.TextEditorSelectionChangeKind.Mouse) {
const selectionsHash = e.selections.reduce(
(hash, s) =>
hash +
`[${s.anchor.line}, ${s.anchor.character}; ${s.active.line}, ${s.active.character}]`,
'',
);
const idx = mh.selectionsChanged.ourSelections.indexOf(selectionsHash);
if (idx > -1) {
mh.selectionsChanged.ourSelections.splice(idx, 1);
Logger.trace(
`Ignoring selection: ${selectionsHash}. ${mh.selectionsChanged.ourSelections.length} left`,
);
return;
} else if (mh.selectionsChanged.ignoreIntermediateSelections) {
Logger.trace(`Ignoring intermediate selection change: ${selectionsHash}`);
return;
} else if (mh.selectionsChanged.ourSelections.length > 0) {
// Some intermediate selection must have slipped in after setting the
// 'ignoreIntermediateSelections' to false. Which means we didn't count
// for it yet, but since we have selections to be ignored then we probably
// wanted this one to be ignored as well.
Logger.warn(`Ignoring slipped selection: ${selectionsHash}`);
return;
}
}
// We may receive changes from other panels when, having selections in them containing the same file
// and changing text before the selection in current panel.
if (e.textEditor !== mh.vimState.editor) {
return;
}
if (mh.focusChanged) {
mh.focusChanged = false;
return;
}
if (mh.currentMode === Mode.EasyMotionMode) {
return;
}
taskQueue.enqueueTask(() => mh.handleSelectionChange(e));
},
true,
false,
);
registerEventListener(
context,
vscode.window.onDidChangeTextEditorVisibleRanges,
async (e: vscode.TextEditorVisibleRangesChangeEvent) => {
if (e.textEditor !== vscode.window.activeTextEditor) {
return;
}
taskQueue.enqueueTask(async () => {
// Scrolling the viewport clears any status bar message, even errors.
const mh = await getAndUpdateModeHandler();
if (mh && StatusBar.lastMessageTime) {
// TODO: Using the time elapsed works most of the time, but is a bit of a hack
const timeElapsed = Date.now() - Number(StatusBar.lastMessageTime);
if (timeElapsed > 100) {
StatusBar.clear(mh.vimState, true);
}
}
});
},
);
const compositionState = new CompositionState();
// Override VSCode commands
overrideCommand(context, 'type', async (args: { text: string }) => {
taskQueue.enqueueTask(async () => {
const mh = await getAndUpdateModeHandler();
if (mh) {
if (compositionState.isInComposition) {
compositionState.composingText += args.text;
if (mh.vimState.currentMode === Mode.Insert) {
compositionState.insertedText = true;
void vscode.commands.executeCommand('default:type', { text: args.text });
}
} else {
await mh.handleKeyEvent(args.text);
}
}
});
});
overrideCommand(
context,
'replacePreviousChar',
async (args: { replaceCharCnt: number; text: string }) => {
taskQueue.enqueueTask(async () => {
const mh = await getAndUpdateModeHandler();
if (mh) {
if (compositionState.isInComposition) {
compositionState.composingText =
compositionState.composingText.substr(
0,
compositionState.composingText.length - args.replaceCharCnt,
) + args.text;
}
if (compositionState.insertedText) {
await vscode.commands.executeCommand('default:replacePreviousChar', {
text: args.text,
replaceCharCnt: args.replaceCharCnt,
});
mh.vimState.cursorStopPosition = mh.vimState.editor.selection.start;
mh.vimState.cursorStartPosition = mh.vimState.editor.selection.start;
}
} else {
await vscode.commands.executeCommand('default:replacePreviousChar', {
text: args.text,
replaceCharCnt: args.replaceCharCnt,
});
}
});
},
);
overrideCommand(context, 'compositionStart', async () => {
taskQueue.enqueueTask(async () => {
compositionState.isInComposition = true;
});
});
overrideCommand(context, 'compositionEnd', async () => {
taskQueue.enqueueTask(async () => {
const mh = await getAndUpdateModeHandler();
if (mh) {
if (compositionState.insertedText) {
mh.selectionsChanged.ignoreIntermediateSelections = true;
await vscode.commands.executeCommand('default:replacePreviousChar', {
text: '',
replaceCharCnt: compositionState.composingText.length,
});
mh.vimState.cursorStopPosition = mh.vimState.editor.selection.active;
mh.vimState.cursorStartPosition = mh.vimState.editor.selection.active;
mh.selectionsChanged.ignoreIntermediateSelections = false;
}
const text = compositionState.composingText;
await mh.handleMultipleKeyEvents(text.split(''));
}
compositionState.reset();
});
});
// Register extension commands
registerCommand(context, 'vim.showQuickpickCmdLine', async () => {
const mh = await getAndUpdateModeHandler();
if (mh) {
const cmd = await vscode.window.showInputBox({
prompt: 'Vim command line',
value: '',
ignoreFocusOut: false,
valueSelection: [0, 0],
});
if (cmd) {
await new ExCommandLine(cmd, mh.vimState.currentMode).run(mh.vimState);
}
void mh.updateView();
}
});
registerCommand(context, 'vim.remap', async (args: ICodeKeybinding) => {
taskQueue.enqueueTask(async () => {
const mh = await getAndUpdateModeHandler();
if (mh === undefined) {
return;
}
if (!args) {
throw new Error(
"'args' is undefined. For this remap to work it needs to have 'args' with an '\"after\": string[]' and/or a '\"commands\": { command: string; args: any[] }[]'",
);
}
if (args.after) {
for (const key of args.after) {
await mh.handleKeyEvent(Notation.NormalizeKey(key, configuration.leader));
}
}
if (args.commands) {
for (const command of args.commands) {
// Check if this is a vim command by looking for :
if (command.command.startsWith(':')) {
await new ExCommandLine(
command.command.slice(1, command.command.length),
mh.vimState.currentMode,
).run(mh.vimState);
void mh.updateView();
} else {
await vscode.commands.executeCommand(command.command, command.args);
}
}
}
});
});
registerCommand(context, 'toggleVim', async () => {
configuration.disableExtension = !configuration.disableExtension;
void toggleExtension(configuration.disableExtension, compositionState);
});
for (const boundKey of configuration.boundKeyCombinations) {
const command = ['<Esc>', '<C-c>'].includes(boundKey.key)
? async () => {
const mh = await getAndUpdateModeHandler();
if (mh && !(await forceStopRecursiveRemap(mh))) {
await mh.handleKeyEvent(`${boundKey.key}`);
}
}
: async () => {
const mh = await getAndUpdateModeHandler();
if (mh) {
await mh.handleKeyEvent(`${boundKey.key}`);
}
};
registerCommand(context, boundKey.command, async () => {
taskQueue.enqueueTask(command);
});
}
{
// Initialize mode handler for current active Text Editor at startup.
const modeHandler = await getAndUpdateModeHandler();
if (modeHandler) {
if (!configuration.startInInsertMode) {
const vimState = modeHandler.vimState;
// Make sure no cursors start on the EOL character (which is invalid in normal mode)
// This can happen if we quit last session in insert mode at the end of the line
vimState.cursors = vimState.cursors.map((cursor) => {
const eolColumn = vimState.document.lineAt(cursor.stop).text.length;
if (cursor.stop.character >= eolColumn) {
const character = Math.max(eolColumn - 1, 0);
return cursor.withNewStop(cursor.stop.with({ character }));
} else {
return cursor;
}
});
}
// This is called last because getAndUpdateModeHandler() will change cursor
void modeHandler.updateView({ drawSelection: true, revealRange: false });
}
}
// Disable automatic keyboard navigation in lists, so it doesn't interfere
// with our list navigation keybindings
await VSCodeContext.set('listAutomaticKeyboardNavigation', false);
await toggleExtension(configuration.disableExtension, compositionState);
Logger.debug('Finish.');
}
/**
* Toggles the VSCodeVim extension between Enabled mode and Disabled mode. This
* function is activated by calling the 'toggleVim' command from the Command Palette.
*
* @param isDisabled if true, sets VSCodeVim to Disabled mode; else sets to enabled mode
*/
async function toggleExtension(isDisabled: boolean, compositionState: CompositionState) {
await VSCodeContext.set('vim.active', !isDisabled);
const mh = await getAndUpdateModeHandler();
if (mh) {
if (isDisabled) {
await mh.handleKeyEvent(SpecialKeys.ExtensionDisable);
compositionState.reset();
ModeHandlerMap.clear();
} else {
await mh.handleKeyEvent(SpecialKeys.ExtensionEnable);
}
}
}
function overrideCommand(
context: vscode.ExtensionContext,
command: string,
callback: (...args: any[]) => any,
) {
const disposable = vscode.commands.registerCommand(command, async (args) => {
if (configuration.disableExtension) {
return vscode.commands.executeCommand('default:' + command, args);
}
if (!vscode.window.activeTextEditor) {
return;
}
if (
vscode.window.activeTextEditor.document &&
vscode.window.activeTextEditor.document.uri.toString() === 'debug:input'
) {
return vscode.commands.executeCommand('default:' + command, args);
}
return callback(args) as vscode.Disposable;
});
context.subscriptions.push(disposable);
}
export function registerCommand(
context: vscode.ExtensionContext,
command: string,
callback: (...args: any[]) => any,
requiresActiveEditor: boolean = true,
) {
const disposable = vscode.commands.registerCommand(command, async (args) => {
if (requiresActiveEditor && !vscode.window.activeTextEditor) {
return;
}
callback(args);
});
context.subscriptions.push(disposable);
}
export function registerEventListener<T>(
context: vscode.ExtensionContext,
event: vscode.Event<T>,
listener: (e: T) => void,
exitOnExtensionDisable = true,
exitOnTests = false,
) {
const disposable = event(async (e) => {
if (exitOnExtensionDisable && configuration.disableExtension) {
return;
}
if (exitOnTests && Globals.isTesting) {
return;
}
listener(e);
});
context.subscriptions.push(disposable);
}
/**
* @returns true if there was a remap being executed to stop
*/
async function forceStopRecursiveRemap(mh: ModeHandler): Promise<boolean> {
if (mh.remapState.isCurrentlyPerformingRecursiveRemapping) {
mh.remapState.forceStopRecursiveRemapping = true;
return true;
}
return false;
}

22
extensionWeb.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* Extension.ts is a lightweight wrapper around ModeHandler. It converts key
* events to their string names and passes them on to ModeHandler via
* handleKeyEvent().
*/
import './src/actions/include-main';
/**
* Load configuration validator
*/
import './src/configuration/validators/inputMethodSwitcherValidator';
import './src/configuration/validators/remappingValidator';
import * as vscode from 'vscode';
import { activate as activateFunc } from './extensionBase';
require('setimmediate');
export async function activate(context: vscode.ExtensionContext) {
void activateFunc(context, false);
}

316
package-lock.json generated
View File

@ -1,13 +1,24 @@
{
"name": "vscode-helix-emulation",
"version": "0.5.4",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vscode-helix-emulation",
"version": "0.5.4",
"version": "0.6.2",
"license": "MIT",
"dependencies": {
"diff-match-patch": "^1.0.5",
"lodash": "^4.17.21",
"os-browserify": "0.3.0",
"parsimmon": "^1.18.1",
"path-browserify": "1.0.1",
"process": "0.11.10",
"queue": "^7.0.0",
"untildify": "4.0.0",
"util": "0.12.5"
},
"devDependencies": {
"@types/http-errors": "^1.8.0",
"@types/node": "^20.8.9",
@ -974,6 +985,20 @@
"node": ">=8"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/azure-devops-node-api": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz",
@ -1133,13 +1158,18 @@
}
},
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dev": true,
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
"dependencies": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -1328,6 +1358,22 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
@ -1353,6 +1399,11 @@
"node": ">=8"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -1491,6 +1542,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-to-primitive": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
@ -2309,6 +2379,14 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
"integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
"dependencies": {
"is-callable": "^1.1.3"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@ -2336,10 +2414,12 @@
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/function.prototype.name": {
"version": "1.1.5",
@ -2369,14 +2449,18 @@
}
},
"node_modules/get-intrinsic": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
"integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==",
"dev": true,
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"dependencies": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.3"
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -2480,6 +2564,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"dependencies": {
"get-intrinsic": "^1.1.3"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
@ -2523,12 +2618,22 @@
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
"integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
"dev": true,
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dependencies": {
"get-intrinsic": "^1.1.1"
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-proto": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -2538,7 +2643,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -2547,12 +2651,11 @@
}
},
"node_modules/has-tostringtag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
"dev": true,
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.2"
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
@ -2561,6 +2664,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@ -2659,8 +2773,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
@ -2682,6 +2795,21 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-bigint": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
@ -2714,7 +2842,6 @@
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -2746,6 +2873,20 @@
"node": ">=0.10.0"
}
},
"node_modules/is-generator-function": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
"integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
"dependencies": {
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -2861,6 +3002,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
"integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
"dependencies": {
"which-typed-array": "^1.1.14"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-weakref": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
@ -2963,6 +3118,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3222,6 +3382,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/os-browserify": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
"integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -3298,6 +3463,16 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parsimmon": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.18.1.tgz",
"integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3352,6 +3527,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
"integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/prebuild-install": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
@ -3402,6 +3585,14 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -3458,6 +3649,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/queue/-/queue-7.0.0.tgz",
"integrity": "sha512-sphwS7HdfQnvrJAXUNAUgpf9H/546IE3p/5Lf2jr71O4udEYlqAhkevykumas2FYuMkX/29JMOgrRdRoYZ/X9w=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -3654,6 +3850,22 @@
"semver": "bin/semver"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4064,6 +4276,14 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/untildify": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
"integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
"engines": {
"node": ">=8"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -4079,6 +4299,18 @@
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"dev": true
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -4159,6 +4391,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
"integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.7",
"for-each": "^0.3.3",
"gopd": "^1.0.1",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",

View File

@ -244,11 +244,6 @@
"command": "deleteWordRight",
"when": "editorTextFocus && extension.helixKeymap.insertMode"
},
{
"key": "alt+d",
"command": "deleteWordRight",
"when": "editorTextFocus && extension.helixKeymap.insertMode"
},
{
"key": "ctrl+u",
"command": "deleteAllLeft",
@ -314,6 +309,17 @@
"typecheck": "tsc --noEmit",
"release": "bumpp && npm run publish"
},
"dependencies": {
"parsimmon": "^1.18.1",
"path-browserify": "1.0.1",
"os-browserify": "0.3.0",
"untildify": "4.0.0",
"process": "0.11.10",
"util": "0.12.5",
"lodash": "^4.17.21",
"diff-match-patch": "^1.0.5",
"queue": "^7.0.0"
},
"devDependencies": {
"@types/http-errors": "^1.8.0",
"@types/node": "^20.8.9",

View File

@ -1,138 +0,0 @@
import * as vscode from 'vscode';
export class SymbolProvider {
/** The array of symbols in the current document */
tree: vscode.DocumentSymbol[] = [];
/** Current index in tree */
symbolIndex = 0;
/** Flag for if the tree is dirty or not */
dirtyTree = false;
static checkSymbolKindPermitted(symbolKind: vscode.SymbolKind): boolean {
// https://code.visualstudio.com/api/references/vscode-api#SymbolKind
return (
symbolKind === vscode.SymbolKind.Constructor ||
symbolKind === vscode.SymbolKind.Enum ||
symbolKind === vscode.SymbolKind.EnumMember ||
symbolKind === vscode.SymbolKind.Event ||
symbolKind === vscode.SymbolKind.Function ||
symbolKind === vscode.SymbolKind.Interface ||
symbolKind === vscode.SymbolKind.Method
);
}
async refreshTree(uri: vscode.Uri) {
const results = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider',
uri,
);
if (!results) {
return [];
}
const flattenedSymbols: vscode.DocumentSymbol[] = [];
const addSymbols = (flattenedSymbols: vscode.DocumentSymbol[], results: vscode.DocumentSymbol[]) => {
results.forEach((symbol: vscode.DocumentSymbol) => {
if (SymbolProvider.checkSymbolKindPermitted(symbol.kind)) {
flattenedSymbols.push(symbol);
}
if (symbol.children && symbol.children.length > 0) {
addSymbols(flattenedSymbols, symbol.children);
}
});
};
addSymbols(flattenedSymbols, results);
this.tree = flattenedSymbols.sort((x: vscode.DocumentSymbol, y: vscode.DocumentSymbol) => {
const lineDiff = x.selectionRange.start.line - y.selectionRange.start.line;
if (lineDiff === 0) {
return x.selectionRange.start.character - y.selectionRange.start.character;
}
return lineDiff;
});
this.dirtyTree = false;
}
getContainingSymbolIndex(position: vscode.Position): number | undefined {
if (this.tree.length === 0 || this.dirtyTree) {
return;
}
const symbolIndex = this.tree.findIndex((symbol: vscode.DocumentSymbol) => {
return symbol.range.contains(position);
});
return symbolIndex;
}
getContainingSymbolRange(position: vscode.Position): vscode.Range | undefined {
if (this.tree.length === 0 || this.dirtyTree) {
return;
}
const symbolIndex = this.getContainingSymbolIndex(position);
if (symbolIndex === -1 || symbolIndex === undefined) {
return;
}
const symbol = this.tree[symbolIndex];
if (symbol) {
return symbol.range;
}
}
getNextFunctionRange(editor: vscode.TextEditor): vscode.Range | undefined {
if (this.tree.length === 0 || this.dirtyTree) {
return;
}
const activeCursor = editor.selection.active;
const currentSymbolIndex = this.getContainingSymbolIndex(activeCursor);
if (currentSymbolIndex === undefined) {
return;
}
// Iterate forward until we find the next function on the same level
for (let i = currentSymbolIndex + 1; i < this.tree.length; i++) {
if (this.tree[i].kind === vscode.SymbolKind.Function) {
this.symbolIndex = i;
break;
}
}
const symbol = this.tree[this.symbolIndex];
if (symbol) {
return symbol.range;
}
}
getPreviousFunctionRange(editor: vscode.TextEditor): vscode.Range | undefined {
if (this.tree.length === 0 || this.dirtyTree) {
return;
}
const activeCursor = editor.selection.active;
const currentSymbolIndex = this.getContainingSymbolIndex(activeCursor);
if (currentSymbolIndex === undefined) {
return;
}
// Iterate backwards until we find the previouis function on the same level
for (let i = currentSymbolIndex - 1; i > 0; i--) {
if (this.tree[i].kind === vscode.SymbolKind.Function) {
this.symbolIndex = i;
break;
}
}
const symbol = this.tree[this.symbolIndex];
if (symbol) {
return symbol.range;
}
}
}
export const symbolProvider = new SymbolProvider();

View File

@ -1,6 +0,0 @@
import * as vscode from 'vscode';
import { HelixState } from './helix_state_types';
import { ParseKeysStatus } from './parse_keys_types';
export type Action = (vimState: HelixState, keys: string[], editor: vscode.TextEditor) => ParseKeysStatus;

View File

@ -1,498 +0,0 @@
import * as vscode from 'vscode';
import { Action } from '../action_types';
import { HelixState } from '../helix_state_types';
import {
enterInsertMode,
enterNormalMode,
enterSearchMode,
enterSelectMode,
enterVisualLineMode,
enterVisualMode,
setModeCursorStyle,
} from '../modes';
import { Mode } from '../modes_types';
import { parseKeysExact, parseKeysRegex } from '../parse_keys';
import * as positionUtils from '../position_utils';
import { putAfter } from '../put_utils/put_after';
import { putBefore } from '../put_utils/put_before';
import { removeTypeSubscription } from '../type_subscription';
import { flashYankHighlight } from '../yank_highlight';
import { gotoActions } from './gotoMode';
import KeyMap from './keymaps';
import { matchActions } from './matchMode';
import { isSingleLineRange, yank } from './operators';
import { spaceActions } from './spaceMode';
import { unimparedActions } from './unimpared';
import { viewActions } from './viewMode';
import { windowActions } from './windowMode';
enum Direction {
Up,
Down,
}
export const actions: Action[] = [
parseKeysExact(['p'], [Mode.Occurrence], () => {
vscode.commands.executeCommand('editor.action.addSelectionToPreviousFindMatch');
}),
parseKeysExact(['a'], [Mode.Occurrence], () => {
vscode.commands.executeCommand('editor.action.selectHighlights');
}),
parseKeysExact(['n'], [Mode.Normal], (helixState) => {
if (helixState.searchState.selectModeActive) {
vscode.commands.executeCommand('actions.findWithSelection');
helixState.searchState.selectModeActive = false;
return;
}
vscode.commands.executeCommand('editor.action.nextMatchFindAction');
}),
parseKeysExact(['N'], [Mode.Normal], (helixState) => {
if (helixState.searchState.selectModeActive) {
vscode.commands.executeCommand('actions.findWithSelection');
helixState.searchState.selectModeActive = false;
return;
}
vscode.commands.executeCommand('editor.action.previousMatchFindAction');
}),
parseKeysExact(['?'], [Mode.Normal], (helixState) => {
enterSearchMode(helixState);
helixState.searchState.previousSearchResult(helixState);
}),
// Selection Stuff
parseKeysExact(['s'], [Mode.Normal, Mode.Visual], (helixState, editor) => {
enterSelectMode(helixState);
// if we enter select mode we should save the current selection
helixState.currentSelection = editor.selection;
}),
parseKeysExact([','], [Mode.Normal, Mode.Visual], (_, editor) => {
// Keep primary selection only
editor.selections = editor.selections.slice(0, 1);
}),
parseKeysExact(['/'], [Mode.Normal], (helixState) => {
enterSearchMode(helixState);
}),
parseKeysExact(['*'], [Mode.Normal], (_) => {
vscode.commands.executeCommand('actions.findWithSelection');
}),
parseKeysExact(['>'], [Mode.Normal, Mode.Visual], (_) => {
vscode.commands.executeCommand('editor.action.indentLines');
}),
parseKeysExact(['<'], [Mode.Normal, Mode.Visual], (_) => {
vscode.commands.executeCommand('editor.action.outdentLines');
}),
parseKeysExact(['='], [Mode.Normal, Mode.Visual], (_) => {
vscode.commands.executeCommand('editor.action.formatSelection');
}),
parseKeysExact(['`'], [Mode.Normal], (vimState, editor) => {
// Take the selection and make it all lowercase
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const text = editor.document.getText(selection);
editBuilder.replace(selection, text.toLowerCase());
});
});
}),
parseKeysExact(['~'], [Mode.Normal], (vimState, editor) => {
// Switch the case of the selection (so if upper case make lower case and vice versa)
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const text = editor.document.getText(selection);
editBuilder.replace(
selection,
text.replace(/./g, (c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())),
);
});
});
}),
// replace
parseKeysRegex(/^r(.)/, /^r/, [Mode.Normal], (helixState, editor, match) => {
const position = editor.selection.active;
editor.edit((builder) => {
builder.replace(new vscode.Range(position, position.with({ character: position.character + 1 })), match[1]);
});
}),
// existing
parseKeysExact(
[KeyMap.Actions.InsertMode],
[Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.Occurrence],
(vimState, editor) => {
enterInsertMode(vimState);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
},
),
parseKeysExact([KeyMap.Actions.InsertAtLineStart], [Mode.Normal], (vimState, editor) => {
editor.selections = editor.selections.map((selection) => {
const character = editor.document.lineAt(selection.active.line).firstNonWhitespaceCharacterIndex;
const newPosition = selection.active.with({ character: character });
return new vscode.Selection(newPosition, newPosition);
});
enterInsertMode(vimState);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact(['a'], [Mode.Normal], (vimState, editor) => {
enterInsertMode(vimState, false);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact([KeyMap.Actions.InsertAtLineEnd], [Mode.Normal], (vimState, editor) => {
editor.selections = editor.selections.map((selection) => {
const lineLength = editor.document.lineAt(selection.active.line).text.length;
const newPosition = selection.active.with({ character: lineLength });
return new vscode.Selection(newPosition, newPosition);
});
enterInsertMode(vimState);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact(['v'], [Mode.Normal, Mode.VisualLine], (vimState, editor) => {
enterVisualMode(vimState);
setModeCursorStyle(vimState.mode, editor);
}),
parseKeysExact(['x'], [Mode.Normal, Mode.Visual], () => {
vscode.commands.executeCommand('expandLineSelection');
}),
parseKeysExact([KeyMap.Actions.NewLineBelow], [Mode.Normal], (vimState, editor) => {
enterInsertMode(vimState);
vscode.commands.executeCommand('editor.action.insertLineAfter');
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact([KeyMap.Actions.NewLineAbove], [Mode.Normal], (vimState, editor) => {
enterInsertMode(vimState);
vscode.commands.executeCommand('editor.action.insertLineBefore');
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact(['p'], [Mode.Normal, Mode.Visual, Mode.VisualLine], putAfter),
parseKeysExact(['P'], [Mode.Normal], putBefore),
parseKeysExact(['u'], [Mode.Normal, Mode.Visual, Mode.VisualLine], () => {
vscode.commands.executeCommand('undo');
}),
parseKeysExact(['U'], [Mode.Normal, Mode.Visual, Mode.VisualLine], () => {
vscode.commands.executeCommand('redo');
}),
parseKeysExact(['d', 'd'], [Mode.Normal], (vimState, editor) => {
deleteLine(vimState, editor);
}),
parseKeysExact(['D'], [Mode.Normal], () => {
vscode.commands.executeCommand('deleteAllRight');
}),
parseKeysRegex(/(\\d+)g/, /^g$/, [Mode.Normal, Mode.Visual], (helixState, editor, match) => {
new vscode.Position(parseInt(match[1]), 0);
}),
// add 1 character swap
parseKeysRegex(/^x(.)$/, /^x$/, [Mode.Normal, Mode.Visual], (vimState, editor, match) => {
editor.edit((builder) => {
editor.selections.forEach((s) => {
const oneChar = s.with({
end: s.active.with({
character: s.active.character + 1,
}),
});
builder.replace(oneChar, match[1]);
});
});
}),
// same for rip command
parseKeysRegex(
RegExp(`^r(\\d+)(${KeyMap.Motions.MoveUp}|${KeyMap.Motions.MoveDown})$`),
/^(r|r\d+)$/,
[Mode.Normal, Mode.Visual],
(vimState, editor, match) => {
const lineCount = parseInt(match[1]);
const direction = match[2] == KeyMap.Motions.MoveUp ? Direction.Up : Direction.Down;
// console.log(`delete ${lineCount} lines up`);
const selections = makeMultiLineSelection(vimState, editor, lineCount, direction);
yank(vimState, editor, selections, true);
deleteLines(vimState, editor, lineCount, direction);
},
),
// same for duplicate command
parseKeysRegex(
RegExp(`^q(\\d+)(${KeyMap.Motions.MoveUp}|${KeyMap.Motions.MoveDown})$`),
/^(q|q\d+)$/,
[Mode.Normal, Mode.Visual],
(vimState, editor, match) => {
const lineCount = parseInt(match[1]);
const direction = match[2] == KeyMap.Motions.MoveUp ? Direction.Up : Direction.Down;
// console.log(`delete ${lineCount} lines up`);
editor.selections = makeMultiLineSelection(vimState, editor, lineCount, direction);
vscode.commands.executeCommand('editor.action.copyLinesDownAction');
},
),
parseKeysExact(['c', 'c'], [Mode.Normal], (vimState, editor) => {
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const line = editor.document.lineAt(selection.active.line);
editBuilder.delete(
new vscode.Range(
selection.active.with({
character: line.firstNonWhitespaceCharacterIndex,
}),
selection.active.with({ character: line.text.length }),
),
);
});
});
enterInsertMode(vimState);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact(['C'], [Mode.Normal], (vimState, editor) => {
vscode.commands.executeCommand('deleteAllRight');
enterInsertMode(vimState);
setModeCursorStyle(vimState.mode, editor);
removeTypeSubscription(vimState);
}),
parseKeysExact(['y', 'y'], [Mode.Normal], (vimState, editor) => {
yankLine(vimState, editor);
// Yank highlight
const highlightRanges = editor.selections.map((selection) => {
const lineLength = editor.document.lineAt(selection.active.line).text.length;
return new vscode.Range(
selection.active.with({ character: 0 }),
selection.active.with({ character: lineLength }),
);
});
flashYankHighlight(editor, highlightRanges);
}),
parseKeysExact(['y'], [Mode.Normal, Mode.Visual], (vimState, editor) => {
// Yank highlight
const highlightRanges = editor.selections.map((selection) => selection.with());
// We need to detect if the ranges are lines because we need to handle them differently
highlightRanges.every((range) => isSingleLineRange(range));
yank(vimState, editor, highlightRanges, false);
flashYankHighlight(editor, highlightRanges);
if (vimState.mode === Mode.Visual) {
enterNormalMode(vimState);
}
}),
parseKeysExact(['q', 'q'], [Mode.Normal, Mode.Visual], () => {
vscode.commands.executeCommand('editor.action.copyLinesDownAction');
}),
parseKeysExact(['Q', 'Q'], [Mode.Normal, Mode.Visual], () => {
vscode.commands.executeCommand('editor.action.copyLinesUpAction');
}),
parseKeysExact(['r', 'r'], [Mode.Normal], (vimState, editor) => {
yankLine(vimState, editor);
deleteLine(vimState, editor);
}),
parseKeysExact(['s', 's'], [Mode.Normal], (vimState, editor) => {
editor.selections = editor.selections.map((selection) => {
return new vscode.Selection(
selection.active.with({ character: 0 }),
positionUtils.lineEnd(editor.document, selection.active),
);
});
enterVisualLineMode(vimState);
setModeCursorStyle(vimState.mode, editor);
}),
parseKeysExact(['S'], [Mode.Normal], (vimState, editor) => {
editor.selections = editor.selections.map((selection) => {
return new vscode.Selection(selection.active, positionUtils.lineEnd(editor.document, selection.active));
});
enterVisualMode(vimState);
setModeCursorStyle(vimState.mode, editor);
}),
// parseKeysExact(['h'], [Mode.Normal], (vimState, editor) => {
// vscode.commands.executeCommand('deleteLeft')
// }),
parseKeysExact([';'], [Mode.Normal], (vimState, editor) => {
const active = editor.selection.active;
editor.selection = new vscode.Selection(active, active);
}),
...gotoActions,
...windowActions,
...viewActions,
...spaceActions,
...matchActions,
...unimparedActions,
];
function makeMultiLineSelection(
vimState: HelixState,
editor: vscode.TextEditor,
lineCount: number,
direction: Direction,
): vscode.Selection[] {
return editor.selections.map((selection) => {
if (direction == Direction.Up) {
const endLine = selection.active.line - lineCount + 1;
const startPos = positionUtils.lineEnd(editor.document, selection.active);
const endPos = endLine >= 0 ? new vscode.Position(endLine, 0) : new vscode.Position(0, 0);
return new vscode.Selection(startPos, endPos);
} else {
const endLine = selection.active.line + lineCount - 1;
const startPos = new vscode.Position(selection.active.line, 0);
const endPos =
endLine < editor.document.lineCount
? new vscode.Position(endLine, editor.document.lineAt(endLine).text.length)
: positionUtils.lastChar(editor.document);
return new vscode.Selection(startPos, endPos);
}
});
}
function deleteLines(
vimState: HelixState,
editor: vscode.TextEditor,
lineCount: number,
direction: Direction = Direction.Down,
): void {
const selections = editor.selections.map((selection) => {
if (direction == Direction.Up) {
const endLine = selection.active.line - lineCount;
if (endLine >= 0) {
const startPos = positionUtils.lineEnd(editor.document, selection.active);
const endPos = new vscode.Position(endLine, editor.document.lineAt(endLine).text.length);
return new vscode.Selection(startPos, endPos);
} else {
const startPos =
selection.active.line + 1 <= editor.document.lineCount
? new vscode.Position(selection.active.line + 1, 0)
: positionUtils.lineEnd(editor.document, selection.active);
const endPos = new vscode.Position(0, 0);
return new vscode.Selection(startPos, endPos);
}
} else {
const endLine = selection.active.line + lineCount;
if (endLine <= editor.document.lineCount - 1) {
const startPos = new vscode.Position(selection.active.line, 0);
const endPos = new vscode.Position(endLine, 0);
return new vscode.Selection(startPos, endPos);
} else {
const startPos =
selection.active.line - 1 >= 0
? new vscode.Position(
selection.active.line - 1,
editor.document.lineAt(selection.active.line - 1).text.length,
)
: new vscode.Position(selection.active.line, 0);
const endPos = positionUtils.lastChar(editor.document);
return new vscode.Selection(startPos, endPos);
}
}
});
editor
.edit((builder) => {
selections.forEach((sel) => builder.replace(sel, ''));
})
.then(() => {
editor.selections = editor.selections.map((selection) => {
const character = editor.document.lineAt(selection.active.line).firstNonWhitespaceCharacterIndex;
const newPosition = selection.active.with({ character: character });
return new vscode.Selection(newPosition, newPosition);
});
});
}
function deleteLine(vimState: HelixState, editor: vscode.TextEditor, direction: Direction = Direction.Down): void {
deleteLines(vimState, editor, 1, direction);
}
function yankLine(vimState: HelixState, editor: vscode.TextEditor): void {
vimState.registers = {
contentsList: editor.selections.map((selection) => {
return editor.document.lineAt(selection.active.line).text;
}),
linewise: true,
};
}
export function switchToUppercase(editor: vscode.TextEditor): void {
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const text = editor.document.getText(selection);
editBuilder.replace(selection, text.toUpperCase());
});
});
}
export function incremenet(editor: vscode.TextEditor): void {
// Move the cursor to the first number and incremene the number
// If the cursor is not on a number, then do nothing
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const translatedSelection = selection.with(selection.start, selection.start.translate(0, 1));
const text = editor.document.getText(translatedSelection);
const number = parseInt(text, 10);
if (!isNaN(number)) {
editBuilder.replace(translatedSelection, (number + 1).toString());
}
});
});
}
export function decrement(editor: vscode.TextEditor): void {
// Move the cursor to the first number and incremene the number
// If the cursor is not on a number, then do nothing
editor.edit((editBuilder) => {
editor.selections.forEach((selection) => {
const translatedSelection = selection.with(selection.start, selection.start.translate(0, 1));
const text = editor.document.getText(translatedSelection);
const number = parseInt(text, 10);
if (!isNaN(number)) {
editBuilder.replace(translatedSelection, (number - 1).toString());
}
});
});
}

294
src/actions/base.ts Normal file
View File

@ -0,0 +1,294 @@
import { Position } from 'vscode';
import { Cursor } from '../common/motion/cursor';
import { Notation } from '../configuration/notation';
import { ActionType, IBaseAction } from './types';
import { isTextTransformation } from '../transformations/transformations';
import { configuration } from './../configuration/configuration';
import { Mode } from './../mode/mode';
import { VimState } from './../state/vimState';
export abstract class BaseAction implements IBaseAction {
abstract readonly actionType: ActionType;
public name = '';
/**
* If true, the cursor position will be added to the jump list on completion.
*/
public readonly isJump: boolean = false;
/**
* If true, the action will create an undo point.
*/
public readonly createsUndoPoint: boolean = false;
/**
* If this is being run in multi cursor mode, the index of the cursor
* this action is being applied to.
*/
public multicursorIndex: number | undefined;
/**
* Whether we should change `vimState.desiredColumn`
*/
public readonly preservesDesiredColumn: boolean = false;
/**
* Modes that this action can be run in.
*/
public abstract readonly modes: readonly Mode[];
/**
* The sequence of keys you use to trigger the action, or a list of such sequences.
*/
public abstract readonly keys: readonly string[] | readonly string[][];
/**
* The keys pressed at the time that this action was triggered.
*/
// TODO: make readonly
public keysPressed: string[] = [];
private static readonly isSingleNumber: RegExp = /^[0-9]$/;
private static readonly isSingleAlpha: RegExp = /^[a-zA-Z]$/;
/**
* Is this action valid in the current Vim state?
*/
public doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
if (
vimState.currentModeIncludingPseudoModes === Mode.OperatorPendingMode &&
this.actionType === 'command'
) {
return false;
}
return (
this.modes.includes(vimState.currentMode) &&
BaseAction.CompareKeypressSequence(this.keys, keysPressed)
);
}
/**
* Could the user be in the process of doing this action.
*/
public couldActionApply(vimState: VimState, keysPressed: string[]): boolean {
if (
vimState.currentModeIncludingPseudoModes === Mode.OperatorPendingMode &&
this.actionType === 'command'
) {
return false;
}
if (!this.modes.includes(vimState.currentMode)) {
return false;
}
const keys2D = BaseAction.is2DArray(this.keys) ? this.keys : [this.keys];
const keysSlice = keys2D.map((x) => x.slice(0, keysPressed.length));
if (!BaseAction.CompareKeypressSequence(keysSlice, keysPressed)) {
return false;
}
return true;
}
public static CompareKeypressSequence(
one: readonly string[] | readonly string[][],
two: readonly string[],
): boolean {
if (BaseAction.is2DArray(one)) {
for (const sequence of one) {
if (BaseAction.CompareKeypressSequence(sequence, two)) {
return true;
}
}
return false;
}
if (one.length !== two.length) {
return false;
}
for (let i = 0, j = 0; i < one.length; i++, j++) {
const left = one[i];
const right = two[j];
if (left === right && right !== configuration.leader) {
continue;
} else if (left === '<any>') {
continue;
} else if (left === '<leader>' && right === configuration.leader) {
continue;
} else if (left === '<number>' && this.isSingleNumber.test(right)) {
continue;
} else if (left === '<alpha>' && this.isSingleAlpha.test(right)) {
continue;
} else if (left === '<character>' && !Notation.IsControlKey(right)) {
continue;
} else {
return false;
}
}
return true;
}
public toString(): string {
return this.keys.join('');
}
private static is2DArray<T>(x: readonly T[] | readonly T[][]): x is readonly T[][] {
return Array.isArray(x[0]);
}
}
/**
* A command is something like <Esc>, :, v, i, etc.
*/
export abstract class BaseCommand extends BaseAction {
override actionType: ActionType = 'command' as const;
/**
* If isCompleteAction is true, then triggering this command is a complete action -
* that means that we'll go and try to run it.
*/
public isCompleteAction = true;
/**
* In multi-cursor mode, do we run this command for every cursor, or just once?
*/
public runsOnceForEveryCursor(): boolean {
return true;
}
/**
* If true, exec() will get called N times where N is the count.
*
* If false, exec() will only be called once, and you are expected to
* handle count prefixes (e.g. the 3 in 3w) yourself.
*/
public readonly runsOnceForEachCountPrefix: boolean = false;
/**
* Run the command a single time.
*/
public async exec(position: Position, vimState: VimState): Promise<void> {
throw new Error('Not implemented!');
}
/**
* Run the command the number of times VimState wants us to.
*/
public async execCount(position: Position, vimState: VimState): Promise<void> {
const timesToRepeat = this.runsOnceForEachCountPrefix ? vimState.recordedState.count || 1 : 1;
if (!this.runsOnceForEveryCursor()) {
for (let i = 0; i < timesToRepeat; i++) {
await this.exec(position, vimState);
}
for (const transformation of vimState.recordedState.transformer.transformations) {
if (isTextTransformation(transformation) && transformation.cursorIndex === undefined) {
transformation.cursorIndex = 0;
}
}
return;
}
const resultingCursors: Cursor[] = [];
const cursorsToIterateOver = vimState.cursors
.map((x) => new Cursor(x.start, x.stop))
.sort((a, b) =>
a.start.line > b.start.line ||
(a.start.line === b.start.line && a.start.character > b.start.character)
? 1
: -1,
);
let cursorIndex = 0;
for (const { start, stop } of cursorsToIterateOver) {
this.multicursorIndex = cursorIndex++;
vimState.cursorStopPosition = stop;
vimState.cursorStartPosition = start;
for (let j = 0; j < timesToRepeat; j++) {
await this.exec(stop, vimState);
}
resultingCursors.push(new Cursor(vimState.cursorStartPosition, vimState.cursorStopPosition));
for (const transformation of vimState.recordedState.transformer.transformations) {
if (isTextTransformation(transformation) && transformation.cursorIndex === undefined) {
transformation.cursorIndex = this.multicursorIndex;
}
}
}
vimState.cursors = resultingCursors;
}
}
export enum KeypressState {
WaitingOnKeys,
NoPossibleMatch,
}
/**
* Every Vim action will be added here with the @RegisterAction decorator.
*/
const actionMap = new Map<Mode, Array<new () => BaseAction>>();
/**
* Gets the action that should be triggered given a key sequence.
*
* If there is a definitive action that matched, returns that action.
*
* If an action could potentially match if more keys were to be pressed, returns `KeyPressState.WaitingOnKeys`
* (e.g. you pressed "g" and are about to press "g" action to make the full action "gg")
*
* If no action could ever match, returns `KeypressState.NoPossibleMatch`.
*/
export function getRelevantAction(
keysPressed: string[],
vimState: VimState,
): BaseAction | KeypressState {
const possibleActionsForMode = actionMap.get(vimState.currentMode) ?? [];
let hasPotentialMatch = false;
for (const actionType of possibleActionsForMode) {
// TODO: Constructing up to several hundred Actions every time we hit a key is moronic.
// I think we can make `doesActionApply` and `couldActionApply` static...
const action = new actionType();
if (action.doesActionApply(vimState, keysPressed)) {
action.keysPressed = vimState.recordedState.actionKeys.slice(0);
return action;
}
hasPotentialMatch ||= action.couldActionApply(vimState, keysPressed);
}
return hasPotentialMatch ? KeypressState.WaitingOnKeys : KeypressState.NoPossibleMatch;
}
export function RegisterAction(action: new () => BaseAction): void {
const actionInstance = new action();
for (const modeName of actionInstance.modes) {
let actions = actionMap.get(modeName);
if (!actions) {
actions = [];
actionMap.set(modeName, actions);
}
if (actionInstance.keys === undefined) {
// action that can't be called directly
continue;
}
actions.push(action);
}
}

163
src/actions/baseMotion.ts Normal file
View File

@ -0,0 +1,163 @@
import { BaseAction } from './base';
import { Mode } from '../mode/mode';
import { VimState } from '../state/vimState';
import { clamp } from '../util/util';
import { Position } from 'vscode';
export function isIMovement(o: IMovement | Position): o is IMovement {
return (o as IMovement).start !== undefined && (o as IMovement).stop !== undefined;
}
export enum SelectionType {
Concatenating, // Selections that concatenate repeated movements
Expanding, // Selections that expand the start and end of the previous selection
}
/**
* The result of a (more sophisticated) Movement.
*/
export interface IMovement {
start: Position;
stop: Position;
/**
* Whether this motion succeeded. Some commands, like fx when 'x' can't be found,
* will not move the cursor. Furthermore, dfx won't delete *anything*, even though
* deleting to the current character would generally delete 1 character.
*/
failed?: boolean;
/**
* Whether this motion resulted in the current multicursor index being removed.
* This happens when multiple selections combine into one.
*/
removed?: boolean;
}
export function failedMovement(vimState: VimState): IMovement {
return {
start: vimState.cursorStartPosition,
stop: vimState.cursorStopPosition,
failed: true,
};
}
export abstract class BaseMovement extends BaseAction {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
override actionType = 'motion' as const;
/**
* If movement can be repeated with semicolon or comma this will be true when
* running the repetition.
*/
isRepeat = false;
/**
* This is for commands like $ which force the desired column to be at
* the end of even the longest line.
*/
public setsDesiredColumnToEOL = false;
protected selectionType = SelectionType.Concatenating;
constructor(keysPressed?: string[], isRepeat?: boolean) {
super();
if (keysPressed) {
this.keysPressed = keysPressed;
}
if (isRepeat) {
this.isRepeat = isRepeat;
}
}
/**
* Run the movement a single time.
*
* Generally returns a new Position. If necessary, it can return an IMovement instead.
* Note: If returning an IMovement, make sure that repeated actions on a
* visual selection work. For example, V}}
*/
public async execAction(
position: Position,
vimState: VimState,
firstIteration: boolean,
lastIteration: boolean,
): Promise<Position | IMovement> {
throw new Error('Not implemented!');
}
/**
* Run the movement in an operator context a single time.
*
* Some movements operate over different ranges when used for operators.
*/
protected async execActionForOperator(
position: Position,
vimState: VimState,
firstIteration: boolean,
lastIteration: boolean,
): Promise<Position | IMovement> {
return this.execAction(position, vimState, firstIteration, lastIteration);
}
/**
* Run a movement count times.
*
* count: the number prefix the user entered, or 0 if they didn't enter one.
*/
public async execActionWithCount(
position: Position,
vimState: VimState,
count: number,
): Promise<Position | IMovement> {
let result!: Position | IMovement;
let prevResult = failedMovement(vimState);
let firstMovementStart = position;
count = clamp(count, 1, 99999);
for (let i = 0; i < count; i++) {
const firstIteration = i === 0;
const lastIteration = i === count - 1;
result =
vimState.recordedState.operator && lastIteration
? await this.execActionForOperator(position, vimState, firstIteration, lastIteration)
: await this.execAction(position, vimState, firstIteration, lastIteration);
if (result instanceof Position) {
/**
* This position will be passed to the `motion` on the next iteration,
* it may cause some issues when count > 1.
*/
position = result;
} else {
if (result.failed) {
return prevResult;
}
if (firstIteration) {
firstMovementStart = result.start;
}
position = this.adjustPosition(position, result, lastIteration);
prevResult = result;
}
}
if (this.selectionType === SelectionType.Concatenating && isIMovement(result)) {
result.start = firstMovementStart;
}
return result;
}
protected adjustPosition(position: Position, result: IMovement, lastIteration: boolean) {
if (!lastIteration) {
position = result.stop.getRightThroughLineBreaks();
}
return position;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,428 @@
import * as vscode from 'vscode';
import { CommandLine, ExCommandLine, SearchCommandLine } from '../../cmd_line/commandLine';
import { ErrorCode, VimError } from '../../error';
import { Mode } from '../../mode/mode';
import { Register, RegisterMode } from '../../register/register';
import { RecordedState } from '../../state/recordedState';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { TextEditor } from '../../textEditor';
import { Clipboard } from '../../util/clipboard';
import { getPathDetails, readDirectory } from '../../util/path';
import { builtinExCommands } from '../../vimscript/exCommandParser';
import { SearchDirection } from '../../vimscript/pattern';
import { BaseCommand, RegisterAction } from '../base';
abstract class CommandLineAction extends BaseCommand {
modes = [Mode.CommandlineInProgress, Mode.SearchInProgressMode];
override runsOnceForEveryCursor() {
return false;
}
protected abstract run(vimState: VimState, commandLine: CommandLine): Promise<void>;
public override async exec(position: vscode.Position, vimState: VimState): Promise<void> {
if (
!(
vimState.modeData.mode === Mode.CommandlineInProgress ||
vimState.modeData.mode === Mode.SearchInProgressMode
)
) {
throw new Error(`Unexpected mode ${vimState.modeData.mode} in CommandLineAction`);
}
await this.run(vimState, vimState.modeData.commandLine);
}
}
@RegisterAction
class CommandLineTab extends CommandLineAction {
override modes = [Mode.CommandlineInProgress];
keys = [['<tab>'], ['<S-tab>']];
private cycleCompletion(isTabForward: boolean, commandLine: ExCommandLine) {
const autoCompleteItems = commandLine.autoCompleteItems;
if (autoCompleteItems.length === 0) {
return;
}
commandLine.autoCompleteIndex = isTabForward
? (commandLine.autoCompleteIndex + 1) % autoCompleteItems.length
: (commandLine.autoCompleteIndex - 1 + autoCompleteItems.length) % autoCompleteItems.length;
const lastPos = commandLine.preCompleteCharacterPos;
const lastCmd = commandLine.preCompleteCommand;
const evalCmd = lastCmd.slice(0, lastPos);
const restCmd = lastCmd.slice(lastPos);
commandLine.text = evalCmd + autoCompleteItems[commandLine.autoCompleteIndex] + restCmd;
commandLine.cursorIndex = commandLine.text.length - restCmd.length;
}
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
if (!(commandLine instanceof ExCommandLine)) {
throw new Error('Expected ExCommandLine in CommandLineTab::run()');
}
const key = this.keysPressed[0];
const isTabForward = key === '<tab>';
// If we hit <Tab> twice in a row, definitely cycle
if (
commandLine.autoCompleteItems.length !== 0 &&
vimState.recordedState.actionsRun[vimState.recordedState.actionsRun.length - 2] instanceof
CommandLineTab
) {
this.cycleCompletion(isTabForward, commandLine);
return;
}
let newCompletionItems: string[] = [];
// Sub string since vim does completion before the cursor
let evalCmd = commandLine.text.slice(0, commandLine.cursorIndex);
const restCmd = commandLine.text.slice(commandLine.cursorIndex);
// \s* is the match the extra space before any character like ': edit'
const cmdRegex = /^\s*\w+$/;
const fileRegex = /^\s*\w+\s+/g;
if (cmdRegex.test(evalCmd)) {
// Command completion
newCompletionItems = builtinExCommands
.map((pair) => pair[0][0] + pair[0][1])
.filter((cmd) => cmd.startsWith(evalCmd))
// Remove the already typed portion in the array
.map((cmd) => cmd.slice(cmd.search(evalCmd) + evalCmd.length))
.sort();
} else if (fileRegex.exec(evalCmd)) {
// File completion by searching if there is a space after the first word/command
// ideally it should be a process of white-listing to selected commands like :e and :vsp
const filePathInCmd = evalCmd.substring(fileRegex.lastIndex);
const currentUri = vimState.document.uri;
const isRemote = !!vscode.env.remoteName;
const {
fullDirPath,
baseName,
partialPath,
path: p,
} = getPathDetails(filePathInCmd, currentUri, isRemote);
// Update the evalCmd in case of windows, where we change / to \
evalCmd = evalCmd.slice(0, fileRegex.lastIndex) + partialPath;
// test if the baseName is . or ..
const shouldAddDotItems = /^\.\.?$/g.test(baseName);
const dirItems = await readDirectory(
fullDirPath,
p.sep,
currentUri,
isRemote,
shouldAddDotItems,
);
const startWithBaseNameRegex = new RegExp(
`^${baseName}`,
process.platform === 'win32' ? 'i' : '',
);
newCompletionItems = dirItems
.map((name): [RegExpExecArray | null, string] => [startWithBaseNameRegex.exec(name), name])
.filter(([isMatch]) => isMatch !== null)
.map(([match, name]) => name.slice(match![0].length))
.sort();
}
const newIndex = isTabForward ? 0 : newCompletionItems.length - 1;
commandLine.autoCompleteIndex = newIndex;
// If here only one items we fill cmd direct, so the next tab will not cycle the one item array
commandLine.autoCompleteItems = newCompletionItems.length <= 1 ? [] : newCompletionItems;
commandLine.preCompleteCharacterPos = commandLine.cursorIndex;
commandLine.preCompleteCommand = evalCmd + restCmd;
const completion = newCompletionItems.length === 0 ? '' : newCompletionItems[newIndex];
commandLine.text = evalCmd + completion + restCmd;
commandLine.cursorIndex = commandLine.text.length - restCmd.length;
}
}
@RegisterAction
class ExCommandLineEnter extends CommandLineAction {
override modes = [Mode.CommandlineInProgress];
keys = [['\n'], ['<C-m>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.run(vimState);
await vimState.setCurrentMode(Mode.Normal);
}
}
@RegisterAction
class SearchCommandLineEnter extends CommandLineAction {
override modes = [Mode.SearchInProgressMode];
keys = [['\n'], ['<C-m>']];
override runsOnceForEveryCursor() {
return true;
}
override isJump = true;
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.run(vimState);
if (this.multicursorIndex === vimState.cursors.length - 1) {
// TODO: gah, this is stupid
await vimState.setCurrentMode(commandLine.previousMode);
}
}
}
@RegisterAction
class CommandLineEscape extends CommandLineAction {
keys = [['<Esc>'], ['<C-c>'], ['<C-[>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.escape(vimState);
}
}
@RegisterAction
class CommandLineCtrlF extends CommandLineAction {
keys = ['<C-f>'];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.ctrlF(vimState);
}
}
@RegisterAction
class CommandLineBackspace extends CommandLineAction {
keys = [['<BS>'], ['<S-BS>'], ['<C-h>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.backspace(vimState);
}
}
@RegisterAction
class CommandLineDelete extends CommandLineAction {
keys = ['<Del>'];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.delete(vimState);
}
}
@RegisterAction
class CommandlineHome extends CommandLineAction {
keys = [['<Home>'], ['<C-b>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.home();
}
}
@RegisterAction
class CommandLineEnd extends CommandLineAction {
keys = [['<End>'], ['<C-e>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.end();
}
}
@RegisterAction
class CommandLineDeleteWord extends CommandLineAction {
keys = [['<C-w>'], ['<C-BS>']];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.deleteWord();
}
}
@RegisterAction
class CommandLineDeleteToBeginning extends CommandLineAction {
keys = ['<C-u>'];
protected override async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.deleteToBeginning();
}
}
@RegisterAction
class CommandLineWordLeft extends CommandLineAction {
keys = ['<C-left>'];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.wordLeft();
}
}
@RegisterAction
class CommandLineWordRight extends CommandLineAction {
keys = ['<C-right>'];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.wordRight();
}
}
@RegisterAction
class CommandLineHistoryBack extends CommandLineAction {
keys = [['<up>'], ['<C-p>']];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.historyBack();
}
}
@RegisterAction
class CommandLineHistoryForward extends CommandLineAction {
keys = [['<down>'], ['<C-n>']];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
await commandLine.historyForward();
}
}
@RegisterAction
class CommandInsertRegisterContentInCommandLine extends CommandLineAction {
keys = ['<C-r>', '<character>'];
override isCompleteAction = false;
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
if (!Register.isValidRegister(this.keysPressed[1])) {
return;
}
vimState.recordedState.registerName = this.keysPressed[1];
const register = await Register.get(vimState.recordedState.registerName, this.multicursorIndex);
if (register === undefined) {
StatusBar.displayError(
vimState,
VimError.fromCode(ErrorCode.NothingInRegister, vimState.recordedState.registerName),
);
return;
}
let text: string;
if (register.text instanceof Array) {
text = register.text.join('\n');
} else if (register.text instanceof RecordedState) {
let keyStrokes: string[] = [];
for (const action of register.text.actionsRun) {
keyStrokes = keyStrokes.concat(action.keysPressed);
}
text = keyStrokes.join('\n');
} else {
text = register.text;
}
if (register.registerMode === RegisterMode.LineWise) {
text += '\n';
}
commandLine.text += text;
commandLine.cursorIndex += text.length;
}
}
@RegisterAction
class CommandInsertWord extends CommandLineAction {
keys = ['<C-r>', '<C-w>'];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
const word = TextEditor.getWord(vimState.document, vimState.cursorStopPosition.getLeftIfEOL());
if (word !== undefined) {
commandLine.text += word;
commandLine.cursorIndex += word.length;
}
}
}
@RegisterAction
class CommandLineLeftRight extends CommandLineAction {
keys = [['<left>'], ['<right>']];
private getTrimmedStatusBarText() {
// first regex removes the : / and | from the string
// second regex removes a single space from the end of the string
const trimmedStatusBarText = StatusBar.getText()
.replace(/^(?:\/|\:)(.*)(?:\|)(.*)/, '$1$2')
.replace(/(.*) $/, '$1');
return trimmedStatusBarText;
}
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
const key = this.keysPressed[0];
const statusBarText = this.getTrimmedStatusBarText();
if (key === '<right>') {
commandLine.cursorIndex = Math.min(commandLine.cursorIndex + 1, statusBarText.length);
} else if (key === '<left>') {
commandLine.cursorIndex = Math.max(commandLine.cursorIndex - 1, 0);
}
}
}
@RegisterAction
class CommandLinePaste extends CommandLineAction {
keys = [['<C-v>'], ['<D-v>']];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
const textFromClipboard = await Clipboard.Paste();
commandLine.text = commandLine.text
.substring(0, commandLine.cursorIndex)
.concat(textFromClipboard)
.concat(commandLine.text.slice(commandLine.cursorIndex));
commandLine.cursorIndex += textFromClipboard.length;
}
}
@RegisterAction
class CommandCtrlLInSearchMode extends CommandLineAction {
override modes = [Mode.SearchInProgressMode];
keys = ['<C-l>'];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
if (commandLine instanceof SearchCommandLine) {
const currentMatch = commandLine.getCurrentMatchRange(vimState);
if (currentMatch) {
const line = vimState.document.lineAt(currentMatch.range.end).text;
if (currentMatch.range.end.character < line.length) {
commandLine.getSearchState().searchString += line[currentMatch.range.end.character];
commandLine.cursorIndex++;
}
}
}
}
}
@RegisterAction
class CommandAdvanceCurrentMatch extends CommandLineAction {
override modes = [Mode.SearchInProgressMode];
keys = [['<C-g>'], ['<C-t>']];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
const key = this.keysPressed[0];
const direction =
key === '<C-g>'
? SearchDirection.Forward
: key === '<C-t>'
? SearchDirection.Backward
: undefined;
if (commandLine instanceof SearchCommandLine && direction !== undefined) {
void commandLine.advanceCurrentMatch(vimState, direction);
}
}
}
@RegisterAction
class CommandLineType extends CommandLineAction {
keys = [['<character>']];
protected async run(vimState: VimState, commandLine: CommandLine): Promise<void> {
void commandLine.typeCharacter(this.keysPressed[0]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
import * as vscode from 'vscode';
import { Position } from 'vscode';
import { Cursor } from '../../common/motion/cursor';
import { Mode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { BaseCommand, RegisterAction } from '../base';
import { BaseOperator } from '../operator';
type FoldDirection = 'up' | 'down' | undefined;
abstract class CommandFold extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
abstract commandName: string;
direction: FoldDirection | undefined;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
const timesToRepeat = vimState.recordedState.count || 1;
const args =
this.direction !== undefined
? { levels: timesToRepeat, direction: this.direction }
: undefined;
vimState.recordedState.transformer.vscodeCommand(this.commandName, args);
await vimState.setCurrentMode(Mode.Normal);
}
}
@RegisterAction
class CommandToggleFold extends CommandFold {
keys = ['z', 'a'];
commandName = 'editor.toggleFold';
}
@RegisterAction
class CommandCloseFold extends CommandFold {
keys = ['z', 'c'];
commandName = 'editor.fold';
override direction: FoldDirection = 'up';
}
@RegisterAction
class CommandCloseAllFolds extends CommandFold {
keys = ['z', 'M'];
commandName = 'editor.foldAll';
}
@RegisterAction
class CommandOpenFold extends CommandFold {
keys = ['z', 'o'];
commandName = 'editor.unfold';
override direction: FoldDirection = 'down';
}
@RegisterAction
class CommandOpenAllFolds extends CommandFold {
keys = ['z', 'R'];
commandName = 'editor.unfoldAll';
}
@RegisterAction
class CommandCloseAllFoldsRecursively extends CommandFold {
override modes = [Mode.Normal];
keys = ['z', 'C'];
commandName = 'editor.foldRecursively';
}
@RegisterAction
class CommandOpenAllFoldsRecursively extends CommandFold {
override modes = [Mode.Normal];
keys = ['z', 'O'];
commandName = 'editor.unfoldRecursively';
}
@RegisterAction
class AddFold extends BaseOperator {
override modes = [Mode.Normal, Mode.Visual];
keys = ['z', 'f'];
readonly commandName = 'editor.createFoldingRangeFromSelection';
public async run(vimState: VimState, start: Position, end: Position): Promise<void> {
const previousSelections = vimState.lastVisualSelection; // keep in case of Normal mode
vimState.editor.selection = new vscode.Selection(start, end);
await vscode.commands.executeCommand(this.commandName);
vimState.lastVisualSelection = previousSelections;
vimState.cursors = [new Cursor(start, start)];
await vimState.setCurrentMode(Mode.Normal); // Vim behavior
}
}
@RegisterAction
class RemoveFold extends BaseCommand {
override modes = [Mode.Normal, Mode.Visual];
keys = ['z', 'd'];
readonly commandName = 'editor.removeManualFoldingRanges';
override async exec(position: Position, vimState: VimState): Promise<void> {
await vscode.commands.executeCommand(this.commandName);
const newCursorPosition =
vimState.currentMode === Mode.Visual ? vimState.editor.selection.start : position;
vimState.cursors = [new Cursor(newCursorPosition, newCursorPosition)];
await vimState.setCurrentMode(Mode.Normal); // Vim behavior
}
}

View File

@ -0,0 +1,557 @@
import * as vscode from 'vscode';
import { Position } from 'vscode';
import { lineCompletionProvider } from '../../completion/lineCompletionProvider';
import { ErrorCode, VimError } from '../../error';
import { RecordedState } from '../../state/recordedState';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { isHighSurrogate, isLowSurrogate } from '../../util/util';
import { PositionDiff } from './../../common/motion/position';
import { configuration } from './../../configuration/configuration';
import { Mode } from './../../mode/mode';
import { Register, RegisterMode } from './../../register/register';
import { TextEditor } from './../../textEditor';
import { BaseCommand, RegisterAction } from './../base';
import { ArrowsInInsertMode } from './../motion';
import {
CommandInsertAfterCursor,
CommandInsertAtCursor,
CommandInsertAtFirstCharacter,
CommandInsertAtLastChange,
CommandInsertAtLineBegin,
CommandInsertAtLineEnd,
CommandInsertNewLineAbove,
CommandInsertNewLineBefore,
CommandReplaceAtCursorFromNormalMode,
DocumentContentChangeAction,
} from './actions';
import { DefaultDigraphs } from './digraphs';
@RegisterAction
export class CommandEscInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = [['<Esc>'], ['<C-c>'], ['<C-[>']];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
void vscode.commands.executeCommand('closeParameterHints');
vimState.cursors = vimState.cursors.map((x) => x.withNewStop(x.stop.getLeft()));
if (vimState.returnToInsertAfterCommand && position.character !== 0) {
vimState.cursors = vimState.cursors.map((x) => x.withNewStop(x.stop.getRight()));
}
// only remove leading spaces inserted by vscode.
// vscode only inserts them when user enter a new line,
// ie, o/O in Normal mode or \n in Insert mode.
const lastActionBeforeEsc =
vimState.recordedState.actionsRun[vimState.recordedState.actionsRun.length - 2];
if (
vimState.document.languageId !== 'plaintext' &&
(lastActionBeforeEsc instanceof CommandInsertNewLineBefore ||
lastActionBeforeEsc instanceof CommandInsertNewLineAbove ||
(lastActionBeforeEsc instanceof DocumentContentChangeAction &&
lastActionBeforeEsc.keysPressed[lastActionBeforeEsc.keysPressed.length - 1] === '\n'))
) {
for (const cursor of vimState.cursors) {
const line = vimState.document.lineAt(cursor.stop);
if (line.text.length > 0 && line.isEmptyOrWhitespace) {
vimState.recordedState.transformer.delete(line.range);
}
}
}
await vimState.setCurrentMode(Mode.Normal);
// If we wanted to repeat this insert (only for i and a), now is the time to do it. Insert
// count amount of these strings before returning back to normal mode
const shouldRepeatInsert =
vimState.recordedState.count > 1 &&
vimState.recordedState.actionsRun.find(
(a) =>
a instanceof CommandInsertAtCursor ||
a instanceof CommandInsertAfterCursor ||
a instanceof CommandInsertAtLineBegin ||
a instanceof CommandInsertAtLineEnd ||
a instanceof CommandInsertAtFirstCharacter ||
a instanceof CommandInsertAtLastChange,
) !== undefined;
// If this is the type to repeat insert, do this now
if (shouldRepeatInsert) {
const changeAction = vimState.recordedState.actionsRun
.slice()
.reverse()
.find((a) => a instanceof DocumentContentChangeAction);
if (changeAction instanceof DocumentContentChangeAction) {
// Add count amount of inserts in the case of 4i=<esc>
// TODO: A few actions such as <C-t> should be repeated, but are not
for (let i = 0; i < vimState.recordedState.count - 1; i++) {
// If this is the last transform, move cursor back one character
const positionDiff =
i === vimState.recordedState.count - 2
? PositionDiff.offset({ character: -1 })
: PositionDiff.identity();
// Add a transform containing the change
vimState.recordedState.transformer.addTransformation(
changeAction.getTransformation(positionDiff),
);
}
}
}
if (vimState.historyTracker.currentContentChanges.length > 0) {
vimState.historyTracker.currentContentChanges = [];
}
if (vimState.isFakeMultiCursor) {
vimState.cursors = [vimState.cursors[0]];
vimState.isFakeMultiCursor = false;
}
}
}
@RegisterAction
export class CommandInsertPreviousText extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-a>'];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
const register = await Register.get('.');
if (
register === undefined ||
!(register.text instanceof RecordedState) ||
!register.text.actionsRun
) {
throw VimError.fromCode(ErrorCode.NoInsertedTextYet);
}
const recordedState = register.text.clone();
// The first action is entering Insert Mode, which is not necessary in this case
recordedState.actionsRun.shift();
// The last action is leaving Insert Mode, which is not necessary in this case
recordedState.actionsRun.pop();
if (recordedState.actionsRun?.[0] instanceof ArrowsInInsertMode) {
// Note, arrow keys are the only Insert action command that can't be repeated here as far as @rebornix knows.
recordedState.actionsRun.shift();
}
vimState.recordedState.transformer.addTransformation({
type: 'replayRecordedState',
recordedState,
});
}
}
@RegisterAction
class CommandInsertPreviousTextAndQuit extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-shift+2>']; // <C-@>
public override async exec(position: Position, vimState: VimState): Promise<void> {
await new CommandInsertPreviousText().exec(position, vimState);
await vimState.setCurrentMode(Mode.Normal);
}
}
abstract class IndentCommand extends BaseCommand {
modes = [Mode.Insert];
abstract readonly delta: number;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const line = vimState.document.lineAt(position);
const tabSize = Number(vimState.editor.options.tabSize);
const indentationWidth = TextEditor.getIndentationLevel(line.text, tabSize);
const newIndentationWidth = (Math.floor(indentationWidth / tabSize) + this.delta) * tabSize;
vimState.recordedState.transformer.replace(
new vscode.Range(
position.getLineBegin(),
position.with({ character: line.firstNonWhitespaceCharacterIndex }),
),
TextEditor.setIndentationLevel(
line.text,
newIndentationWidth,
vimState.editor.options.insertSpaces as boolean,
).match(/^(\s*)/)![1],
);
}
}
@RegisterAction
class IncreaseIndent extends IndentCommand {
keys = ['<C-t>'];
override readonly delta = 1;
}
@RegisterAction
class DecreaseIndent extends IndentCommand {
keys = ['<C-d>'];
override readonly delta = -1;
}
@RegisterAction
export class CommandBackspaceInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = [['<BS>'], ['<C-h>']];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.recordedState.transformer.vscodeCommand('deleteLeft');
}
}
@RegisterAction
class CommandDeleteInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<Del>'];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.recordedState.transformer.vscodeCommand('deleteRight');
}
}
@RegisterAction
export class CommandInsertInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<character>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
const char = this.keysPressed[this.keysPressed.length - 1];
let text = char;
if (char.length === 1) {
const prevHighSurrogate =
vimState.modeData.mode === Mode.Insert ? vimState.modeData.highSurrogate : undefined;
if (isHighSurrogate(char.charCodeAt(0))) {
await vimState.setModeData({
mode: Mode.Insert,
highSurrogate: char,
});
if (prevHighSurrogate === undefined) return;
text = prevHighSurrogate;
} else {
if (isLowSurrogate(char.charCodeAt(0)) && prevHighSurrogate !== undefined) {
text = prevHighSurrogate + char;
}
await vimState.setModeData({
mode: Mode.Insert,
highSurrogate: undefined,
});
}
}
vimState.recordedState.transformer.addTransformation({
type: 'insertTextVSCode',
text,
isMultiCursor: vimState.isMultiCursor,
});
}
public override toString(): string {
return this.keysPressed[this.keysPressed.length - 1];
}
}
@RegisterAction
class CommandInsertDigraph extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-k>', '<any>', '<any>'];
override isCompleteAction = false;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const digraph = this.keysPressed.slice(1, 3).join('');
const reverseDigraph = digraph.split('').reverse().join('');
let charCodes = (DefaultDigraphs.get(digraph) ||
DefaultDigraphs.get(reverseDigraph) ||
configuration.digraphs[digraph] ||
configuration.digraphs[reverseDigraph])[1];
if (!(charCodes instanceof Array)) {
charCodes = [charCodes];
}
const char = String.fromCharCode(...charCodes);
vimState.recordedState.transformer.insert(position, char);
}
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
if (!super.doesActionApply(vimState, keysPressed)) {
return false;
}
const chars = keysPressed.slice(1, 3).join('');
const reverseChars = chars.split('').reverse().join('');
return (
chars in configuration.digraphs ||
reverseChars in configuration.digraphs ||
DefaultDigraphs.has(chars) ||
DefaultDigraphs.has(reverseChars)
);
}
public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean {
if (!super.couldActionApply(vimState, keysPressed)) {
return false;
}
const chars = keysPressed.slice(1, keysPressed.length).join('');
const reverseChars = chars.split('').reverse().join('');
if (chars.length > 0) {
const predicate = (digraph: string) => {
const digraphChars = digraph.substring(0, chars.length);
return chars === digraphChars || reverseChars === digraphChars;
};
const match =
Object.keys(configuration.digraphs).find(predicate) ||
[...DefaultDigraphs.keys()].find(predicate);
return match !== undefined;
}
return true;
}
}
@RegisterAction
class CommandInsertRegisterContent extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-r>', '<character>'];
override isCompleteAction = false;
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (!Register.isValidRegister(this.keysPressed[1])) {
return;
}
const register = await Register.get(this.keysPressed[1], this.multicursorIndex);
if (register === undefined) {
StatusBar.displayError(
vimState,
VimError.fromCode(ErrorCode.NothingInRegister, this.keysPressed[1]),
);
return;
}
if (register.text instanceof RecordedState) {
vimState.recordedState.transformer.addTransformation({
type: 'macro',
register: vimState.recordedState.registerName,
replay: 'keystrokes',
});
return;
}
let text = register.text;
if (register.registerMode === RegisterMode.LineWise && !vimState.isMultiCursor) {
text += '\n';
}
vimState.recordedState.transformer.insert(position, text);
}
}
@RegisterAction
class CommandOneNormalCommandInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-o>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.returnToInsertAfterCommand = true;
vimState.actionCount = 0;
await new CommandEscInsertMode().exec(position, vimState);
}
}
@RegisterAction
class CommandCtrlW extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-w>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (position.isAtDocumentBegin()) {
return;
}
let wordBegin: Position;
if (position.isInLeadingWhitespace(vimState.document)) {
wordBegin = position.getLineBegin();
} else if (position.isLineBeginning()) {
wordBegin = position.getUp().getLineEnd();
} else {
wordBegin = position.prevWordStart(vimState.document);
}
vimState.recordedState.transformer.delete(new vscode.Range(wordBegin, position));
vimState.cursorStopPosition = wordBegin;
}
}
@RegisterAction
export class InsertCharAbove extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-y>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (position.line === 0) {
return;
}
const charPos = position.getUp();
if (charPos.isLineEnd()) {
return;
}
const char = vimState.document.getText(new vscode.Range(charPos, charPos.getRight()));
vimState.recordedState.transformer.insert(position, char);
}
}
@RegisterAction
export class InsertCharBelow extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-e>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (position.line >= vimState.document.lineCount - 1) {
return;
}
const charPos = position.getDown();
if (charPos.isLineEnd()) {
return;
}
const char = vimState.document.getText(new vscode.Range(charPos, charPos.getRight()));
vimState.recordedState.transformer.insert(position, char);
}
}
@RegisterAction
class CommandCtrlUInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-u>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
let start: Position;
if (position.character === 0) {
start = position.getLeftThroughLineBreaks(true);
} else if (position.isInLeadingWhitespace(vimState.document)) {
start = position.getLineBegin();
} else {
start = position.getLineBeginRespectingIndent(vimState.document);
}
vimState.recordedState.transformer.delete(new vscode.Range(start, position));
vimState.cursorStopPosition = start;
vimState.cursorStartPosition = start;
}
}
@RegisterAction
class CommandNavigateAutocompleteDown extends BaseCommand {
modes = [Mode.Insert];
keys = [['<C-n>'], ['<C-j>']];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
await vscode.commands.executeCommand('selectNextSuggestion');
}
}
@RegisterAction
class CommandNavigateAutocompleteUp extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-p>'];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
await vscode.commands.executeCommand('selectPrevSuggestion');
}
}
@RegisterAction
class CommandCtrlVInInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-v>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
const clipboard = await Register.get('*', this.multicursorIndex);
const text = clipboard?.text instanceof RecordedState ? undefined : clipboard?.text;
if (text) {
vimState.recordedState.transformer.insert(vimState.cursorStopPosition, text);
}
}
}
@RegisterAction
class CommandShowLineAutocomplete extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-x>', '<C-l>'];
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
await lineCompletionProvider.showLineCompletionsQuickPick(position, vimState);
}
}
@RegisterAction
class NewLineInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = [['<C-j>'], ['<C-m>']];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.recordedState.transformer.insert(
position,
'\n',
PositionDiff.offset({ character: -1 }),
);
}
}
@RegisterAction
class CommandReplaceAtCursorFromInsertMode extends BaseCommand {
modes = [Mode.Insert];
keys = ['<Insert>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
await new CommandReplaceAtCursorFromNormalMode().exec(position, vimState);
}
}
@RegisterAction
class CreateUndoPoint extends BaseCommand {
modes = [Mode.Insert];
keys = ['<C-g>', 'u'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.historyTracker.addChange(true);
vimState.historyTracker.finishCurrentStep();
}
}

618
src/actions/commands/put.ts Normal file
View File

@ -0,0 +1,618 @@
import * as vscode from 'vscode';
import { Position, TextDocument } from 'vscode';
import { laterOf, PositionDiff, sorted } from '../../common/motion/position';
import { configuration } from '../../configuration/configuration';
import { isVisualMode, Mode } from '../../mode/mode';
import { Register, RegisterMode, IRegisterContent } from '../../register/register';
import { RecordedState } from '../../state/recordedState';
import { VimState } from '../../state/vimState';
import { TextEditor } from '../../textEditor';
import { reportLinesChanged } from '../../util/statusBarTextUtils';
import { BaseCommand, RegisterAction } from '../base';
import { StatusBar } from '../../statusBar';
import { VimError, ErrorCode } from '../../error';
import { Cursor } from '../../common/motion/cursor';
import { Transformation } from '../../transformations/transformations';
function firstNonBlankChar(text: string): number {
return text.match(/\S/)?.index ?? 0;
}
type GetCursorPositionParams = {
document: TextDocument;
mode: Mode;
replaceRange: vscode.Range;
registerMode: RegisterMode;
count: number;
text: string;
returnToInsertAfterCommand: boolean;
};
abstract class BasePutCommand extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
override createsUndoPoint = true;
protected overwritesRegisterWithSelection = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const register = await Register.get(vimState.recordedState.registerName, this.multicursorIndex);
if (register === undefined) {
StatusBar.displayError(
vimState,
VimError.fromCode(ErrorCode.NothingInRegister, vimState.recordedState.registerName),
);
return;
}
const count = vimState.recordedState.count || 1;
const mode =
vimState.currentMode === Mode.CommandlineInProgress ? Mode.Normal : vimState.currentMode;
const registerMode = this.getRegisterMode(register);
const replaceRange = this.getReplaceRange(mode, vimState.cursors[0], registerMode);
let text = this.getRegisterText(mode, register, count);
if (this.shouldAdjustIndent(mode, registerMode)) {
let lineToMatch: number | undefined;
if (mode === Mode.VisualLine) {
const [start, end] = sorted(vimState.cursorStartPosition, vimState.cursorStopPosition);
if (end.line < vimState.document.lineCount - 1) {
lineToMatch = end.line + 1;
} else if (start.line > 0) {
lineToMatch = start.line - 1;
}
} else {
lineToMatch = position.line;
}
text = this.adjustIndent(
lineToMatch !== undefined ? vimState.document.lineAt(lineToMatch).text : '',
text,
);
}
const newCursorPosition = this.getCursorPosition({
document: vimState.document,
returnToInsertAfterCommand: vimState.returnToInsertAfterCommand,
mode,
replaceRange,
registerMode,
count,
text,
});
vimState.recordedState.transformer.moveCursor(
PositionDiff.exactPosition(newCursorPosition),
this.multicursorIndex ?? 0,
);
if (registerMode === RegisterMode.LineWise) {
text = this.adjustLinewiseRegisterText(mode, text);
}
for (const transformation of this.getTransformations(
vimState.document,
mode,
replaceRange,
registerMode,
text,
)) {
vimState.recordedState.transformer.addTransformation(transformation);
}
// We do not run this in multi-cursor mode as it will overwrite the register for upcoming put iterations
if (isVisualMode(mode) && !vimState.isMultiCursor) {
// After using "p" or "P" in Visual mode the text that was put will be selected (from Vim's ":help gv").
vimState.lastVisualSelection = {
mode,
start: replaceRange.start,
end: replaceRange.start.advancePositionByText(text),
};
if (this.overwritesRegisterWithSelection) {
vimState.recordedState.registerName = configuration.useSystemClipboard ? '*' : '"';
Register.put(
vimState,
vimState.document.getText(replaceRange),
this.multicursorIndex,
true,
);
}
}
// Report lines changed
let numNewlinesAfterPut = text.split('\n').length;
if (registerMode === RegisterMode.LineWise) {
numNewlinesAfterPut--;
}
reportLinesChanged(numNewlinesAfterPut, vimState);
const isLastCursor =
!vimState.isMultiCursor || vimState.cursors.length - 1 === this.multicursorIndex;
// Place the cursor back into normal mode after all puts are completed
if (isLastCursor) {
await vimState.setCurrentMode(Mode.Normal);
}
}
private getRegisterText(mode: Mode, register: IRegisterContent, count: number): string {
if (register.text instanceof RecordedState) {
return register.text.actionsRun
.map((action) => action.keysPressed.join(''))
.join('')
.repeat(count);
}
if (register.registerMode === RegisterMode.CharacterWise) {
return mode === Mode.VisualLine
? Array(count).fill(register.text).join('\n')
: register.text.repeat(count);
} else if (register.registerMode === RegisterMode.LineWise || mode === Mode.VisualLine) {
return Array(count).fill(register.text).join('\n');
} else if (register.registerMode === RegisterMode.BlockWise) {
const lines = register.text.split('\n');
const longestLength = Math.max(...lines.map((line) => line.length));
return lines
.map((line) => {
const space = longestLength - line.length;
const lineWithSpace = line + ' '.repeat(space);
return lineWithSpace.repeat(count - 1) + line;
})
.join('\n');
} else {
throw new Error(`Unexpected RegisterMode ${register.registerMode}`);
}
}
private adjustIndent(lineToMatch: string, text: string): string {
const lines = text.split('\n');
// Adjust indent to current line
const tabSize = configuration.tabstop; // TODO: Use `editor.options.tabSize`, I think
const indentationWidth = TextEditor.getIndentationLevel(lineToMatch, tabSize);
const firstLineIdentationWidth = TextEditor.getIndentationLevel(lines[0], tabSize);
return lines
.map((line) => {
const currentIdentationWidth = TextEditor.getIndentationLevel(line, tabSize);
const newIndentationWidth =
currentIdentationWidth - firstLineIdentationWidth + indentationWidth;
// TODO: Use `editor.options.insertSpaces`, I think
return TextEditor.setIndentationLevel(line, newIndentationWidth, configuration.expandtab);
})
.join('\n');
}
private getTransformations(
document: TextDocument,
mode: Mode,
replaceRange: vscode.Range,
registerMode: RegisterMode,
text: string,
): Transformation[] {
// Pasting block-wise content is very different, except in VisualLine mode, where it works exactly like line-wise
if (registerMode === RegisterMode.BlockWise && mode !== Mode.VisualLine) {
const transformations: Transformation[] = [];
const lines = text.split('\n');
const lineCount = Math.max(lines.length, replaceRange.end.line - replaceRange.start.line + 1);
const longestLength = Math.max(...lines.map((line) => line.length));
// Only relevant for Visual mode
// If we replace 2 newlines, subsequent transformations need to take that into account (otherwise we get overlaps)
let deletedNewlines = 0;
for (let idx = 0; idx < lineCount; idx++) {
const lineText = lines[idx] ?? '';
let range: vscode.Range;
if (mode === Mode.VisualBlock) {
if (replaceRange.start.line + idx > replaceRange.end.line) {
const pos = replaceRange.start.with({ line: replaceRange.start.line + idx });
range = new vscode.Range(pos, pos);
} else {
range = new vscode.Range(
replaceRange.start.with({ line: replaceRange.start.line + idx }),
replaceRange.end.with({ line: replaceRange.start.line + idx }),
);
}
} else {
if (idx > 0) {
const pos = replaceRange.start.with({
line: replaceRange.start.line + idx + deletedNewlines,
});
range = new vscode.Range(pos, pos);
} else {
range = new vscode.Range(replaceRange.start, replaceRange.end);
deletedNewlines = document.getText(range).split('\n').length - 1;
}
}
const lineNumber = replaceRange.start.line + idx;
if (lineNumber > document.lineCount - 1) {
transformations.push({
type: 'replaceText',
range,
text: '\n' + ' '.repeat(replaceRange.start.character) + lineText,
});
} else {
const lineLength = document.lineAt(lineNumber).text.length;
const leftPadding = Math.max(replaceRange.start.character - lineLength, 0);
let rightPadding = 0;
if (
mode !== Mode.VisualBlock &&
((lineNumber <= replaceRange.end.line && replaceRange.end.character < lineLength) ||
(lineNumber > replaceRange.end.line && replaceRange.start.character < lineLength))
) {
rightPadding = longestLength - lineText.length;
}
transformations.push({
type: 'replaceText',
range,
text: ' '.repeat(leftPadding) + lineText + ' '.repeat(rightPadding),
});
}
}
return transformations;
}
if (mode === Mode.Normal || mode === Mode.Visual || mode === Mode.VisualLine) {
return [
{
type: 'replaceText',
range: replaceRange,
text,
},
];
} else if (mode === Mode.VisualBlock) {
const transformations: Transformation[] = [];
if (registerMode === RegisterMode.CharacterWise) {
for (let line = replaceRange.start.line; line <= replaceRange.end.line; line++) {
const range = new vscode.Range(
new Position(line, replaceRange.start.character),
new Position(line, replaceRange.end.character),
);
const lineText = !text.includes('\n') || line === replaceRange.start.line ? text : '';
transformations.push({
type: 'replaceText',
range,
text: lineText,
});
}
} else if (registerMode === RegisterMode.LineWise) {
// Weird case: first delete the block...
for (let line = replaceRange.start.line; line <= replaceRange.end.line; line++) {
const range = new vscode.Range(
new Position(line, replaceRange.start.character),
new Position(line, replaceRange.end.character),
);
transformations.push({
type: 'replaceText',
range,
text: '',
});
}
// ...then paste the lines before/after the block
const insertPos = this.putBefore()
? new Position(replaceRange.start.line, 0)
: new Position(replaceRange.end.line, 0).getLineEnd();
transformations.push({
type: 'replaceText',
range: new vscode.Range(insertPos, insertPos),
text,
});
} else {
throw new Error(`Unexpected RegisterMode ${registerMode}`);
}
return transformations;
} else {
throw new Error(`Unexpected Mode ${mode}`);
}
}
protected abstract putBefore(): boolean;
protected abstract getRegisterMode(register: IRegisterContent): RegisterMode;
protected abstract getReplaceRange(
mode: Mode,
cursor: Cursor,
registerMode: RegisterMode,
): vscode.Range;
protected abstract adjustLinewiseRegisterText(mode: Mode, text: string): string;
protected abstract shouldAdjustIndent(mode: Mode, registerMode: RegisterMode): boolean;
protected abstract getCursorPosition(params: GetCursorPositionParams): Position;
}
@RegisterAction
class PutCommand extends BasePutCommand {
keys: string[] | string[][] = ['p'];
protected putBefore(): boolean {
return false;
}
protected getRegisterMode(register: IRegisterContent): RegisterMode {
return register.registerMode;
}
protected getReplaceRange(mode: Mode, cursor: Cursor, registerMode: RegisterMode): vscode.Range {
if (mode === Mode.Normal) {
let pos: Position;
if (registerMode === RegisterMode.CharacterWise || registerMode === RegisterMode.BlockWise) {
pos = cursor.stop.getRight();
} else if (registerMode === RegisterMode.LineWise) {
pos = cursor.stop.getLineEnd();
} else {
throw new Error(`Unexpected RegisterMode ${registerMode}`);
}
return new vscode.Range(pos, pos);
} else if (mode === Mode.Visual) {
const [start, end] = sorted(cursor.start, cursor.stop);
return new vscode.Range(start, end.getRight());
} else if (mode === Mode.VisualLine) {
const [start, end] = sorted(cursor.start, cursor.stop);
return new vscode.Range(start.getLineBegin(), end.getLineEnd());
} else {
const [start, end] = sorted(cursor.start, cursor.stop);
return new vscode.Range(start, end.getRight());
}
}
protected adjustLinewiseRegisterText(mode: Mode, text: string): string {
if (mode === Mode.Normal || mode === Mode.VisualBlock) {
return '\n' + text;
} else if (mode === Mode.Visual) {
return '\n' + text + '\n';
} else {
return text;
}
}
protected shouldAdjustIndent(mode: Mode, registerMode: RegisterMode): boolean {
return false;
}
protected getCursorPosition({
mode,
replaceRange,
registerMode,
text,
returnToInsertAfterCommand,
}: GetCursorPositionParams): Position {
const rangeStart = replaceRange.start;
if (mode === Mode.Normal || mode === Mode.Visual) {
if (registerMode === RegisterMode.CharacterWise) {
if (text.includes('\n')) {
return rangeStart;
} else if (returnToInsertAfterCommand) {
return rangeStart.advancePositionByText(text);
} else {
return rangeStart.advancePositionByText(text).getLeft();
}
} else if (registerMode === RegisterMode.LineWise) {
return new Position(rangeStart.line + 1, firstNonBlankChar(text));
} else if (registerMode === RegisterMode.BlockWise) {
return rangeStart;
} else {
throw new Error(`Unexpected RegisterMode ${registerMode}`);
}
} else if (mode === Mode.VisualLine) {
return rangeStart.with({ character: firstNonBlankChar(text) });
} else if (mode === Mode.VisualBlock) {
if (registerMode === RegisterMode.LineWise) {
return new Position(replaceRange.end.line + 1, firstNonBlankChar(text));
} else if (registerMode === RegisterMode.BlockWise) {
return rangeStart;
} else {
return rangeStart.with({ character: rangeStart.character + text.length - 1 });
}
} else {
throw new Error(`Unexpected Mode ${mode}`);
}
}
}
@RegisterAction
class PutBeforeCommand extends PutCommand {
override keys: string[] | string[][] = ['P'];
// Since Vim 9.0, Visual `P` does not overwrite the unnamed register with selection's contents
override overwritesRegisterWithSelection = false;
protected override putBefore(): boolean {
return true;
}
protected override adjustLinewiseRegisterText(mode: Mode, text: string): string {
if (mode === Mode.Normal || mode === Mode.VisualBlock) {
return text + '\n';
}
return super.adjustLinewiseRegisterText(mode, text);
}
protected override getReplaceRange(
mode: Mode,
cursor: Cursor,
registerMode: RegisterMode,
): vscode.Range {
if (mode === Mode.Normal) {
if (registerMode === RegisterMode.CharacterWise || registerMode === RegisterMode.BlockWise) {
const pos = cursor.stop;
return new vscode.Range(pos, pos);
} else if (registerMode === RegisterMode.LineWise) {
const pos = cursor.stop.getLineBegin();
return new vscode.Range(pos, pos);
}
}
return super.getReplaceRange(mode, cursor, registerMode);
}
protected override getCursorPosition({
mode,
replaceRange,
text,
registerMode,
...params
}: GetCursorPositionParams): Position {
const rangeStart = replaceRange.start;
if (mode === Mode.Normal || mode === Mode.VisualBlock) {
if (registerMode === RegisterMode.LineWise) {
return rangeStart.with({ character: firstNonBlankChar(text) });
}
}
return super.getCursorPosition({ mode, replaceRange, text, registerMode, ...params });
}
}
function PlaceCursorAfterText<TBase extends new (...args: any[]) => PutCommand>(Base: TBase) {
return class CursorAfterText extends Base {
protected override getCursorPosition({
document,
mode,
replaceRange,
registerMode,
count,
text,
...params
}: GetCursorPositionParams): Position {
const rangeStart = replaceRange.start;
if (mode === Mode.Normal || mode === Mode.Visual) {
if (registerMode === RegisterMode.CharacterWise) {
if (text.includes('\n')) {
// Weird case: if there's a newline, the cursor goes to the same place, regardless of [count]
// HACK: We're undoing the repeat() here - definitely a bit janky
text = text.slice(0, text.length / count);
}
return rangeStart.advancePositionByText(text);
} else if (registerMode === RegisterMode.LineWise) {
let line = rangeStart.line + text.split('\n').length;
if (
mode === Mode.Visual ||
(!this.putBefore() && rangeStart.line < document.lineCount - 1)
) {
line++;
}
return new Position(line, 0);
} else if (registerMode === RegisterMode.BlockWise) {
const lines = text.split('\n');
const lastLine = rangeStart.line + lines.length - 1;
const longestLineLength = Math.max(...lines.map((line) => line.length));
return new Position(lastLine, rangeStart.character + longestLineLength);
}
} else if (mode === Mode.VisualLine) {
return new Position(rangeStart.line + text.split('\n').length, 0);
} else if (mode === Mode.VisualBlock) {
const lines = text.split('\n');
if (registerMode === RegisterMode.LineWise) {
if (this.putBefore()) {
return new Position(rangeStart.line + lines.length, 0);
} else {
return new Position(replaceRange.end.line + lines.length + 1, 0);
}
} else if (registerMode === RegisterMode.BlockWise) {
return new Position(
replaceRange.start.line + lines.length - 1,
replaceRange.start.character + lines[lines.length - 1].length,
);
} else {
return rangeStart.with({ character: rangeStart.character + text.length });
}
}
return super.getCursorPosition({
document,
mode,
replaceRange,
registerMode,
count,
text,
...params,
});
}
};
}
@RegisterAction
@PlaceCursorAfterText
class GPutCommand extends PutCommand {
override keys = ['g', 'p'];
}
@RegisterAction
@PlaceCursorAfterText
class GPutBeforeCommand extends PutBeforeCommand {
override keys = ['g', 'P'];
override overwritesRegisterWithSelection = true;
}
function AdjustIndent<TBase extends new (...args: any[]) => PutCommand>(Base: TBase) {
return class AdjustedIndent extends Base {
protected override shouldAdjustIndent(mode: Mode, registerMode: RegisterMode): boolean {
return (
(mode === Mode.Normal || mode === Mode.VisualLine) && registerMode === RegisterMode.LineWise
);
}
};
}
@RegisterAction
@AdjustIndent
class PutWithIndentCommand extends PutCommand {
override keys = [']', 'p'];
}
@RegisterAction
@AdjustIndent
class PutBeforeWithIndentCommand extends PutBeforeCommand {
override keys = [
['[', 'P'],
[']', 'P'],
['[', 'p'],
];
}
function ExCommand<TBase extends new (...args: any[]) => PutCommand>(Base: TBase) {
return class Ex extends Base {
private insertLine?: number;
public setInsertionLine(insertLine: number) {
this.insertLine = insertLine;
}
protected override getRegisterMode(register: IRegisterContent): RegisterMode {
return RegisterMode.LineWise;
}
protected override getReplaceRange(
mode: Mode,
cursor: Cursor,
registerMode: RegisterMode,
): vscode.Range {
const line = this.insertLine ?? laterOf(cursor.start, cursor.stop).line;
const pos = this.putBefore() ? new Position(line, 0) : new Position(line, 0).getLineEnd();
return new vscode.Range(pos, pos);
}
protected override getCursorPosition({
replaceRange,
text,
}: GetCursorPositionParams): Position {
const lines = text.split('\n');
return new Position(
replaceRange.start.line + lines.length - (this.putBefore() ? 1 : 0),
firstNonBlankChar(lines[lines.length - 1]),
);
}
};
}
export const PutFromCmdLine = ExCommand(PutCommand);
export const PutBeforeFromCmdLine = ExCommand(PutBeforeCommand);

View File

@ -0,0 +1,142 @@
import { Position, Range } from 'vscode';
import { PositionDiff } from '../../common/motion/position';
import { Mode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { RegisterAction, BaseCommand } from '../base';
@RegisterAction
class ExitReplaceMode extends BaseCommand {
modes = [Mode.Replace];
keys = [['<Esc>'], ['<C-c>'], ['<C-[>']];
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (vimState.modeData.mode !== Mode.Replace) {
throw new Error(`Unexpected mode ${vimState.modeData.mode} in ExitReplaceMode`);
}
const timesToRepeat = vimState.modeData.replaceState.timesToRepeat;
const cursorIdx = this.multicursorIndex ?? 0;
const changes = vimState.modeData.replaceState.getChanges(cursorIdx);
// `3Rabc` results in 'abc' replacing the next characters 2 more times
if (changes && timesToRepeat > 1) {
const newText = changes
.map((change) => change.after)
.join('')
.repeat(timesToRepeat - 1);
vimState.recordedState.transformer.replace(
new Range(position, position.getRight(newText.length)),
newText,
);
} else {
vimState.cursorStopPosition = vimState.cursorStopPosition.getLeft();
}
if (this.multicursorIndex === vimState.cursors.length - 1) {
await vimState.setCurrentMode(Mode.Normal);
}
}
}
@RegisterAction
class ReplaceModeToInsertMode extends BaseCommand {
modes = [Mode.Replace];
keys = ['<Insert>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
await vimState.setCurrentMode(Mode.Insert);
}
}
@RegisterAction
class BackspaceInReplaceMode extends BaseCommand {
modes = [Mode.Replace];
keys = [['<BS>'], ['<S-BS>'], ['<C-BS>'], ['<C-h>']];
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (vimState.modeData.mode !== Mode.Replace) {
throw new Error(`Unexpected mode ${vimState.modeData.mode} in BackspaceInReplaceMode`);
}
const cursorIdx = this.multicursorIndex ?? 0;
const changes = vimState.modeData.replaceState.getChanges(cursorIdx);
if (changes.length === 0) {
// If you backspace before the beginning of where you started to replace, just move the cursor back.
const newPosition = position.getLeftThroughLineBreaks();
vimState.modeData.replaceState.resetChanges(cursorIdx);
vimState.cursorStopPosition = newPosition;
vimState.cursorStartPosition = newPosition;
} else {
const { before } = changes.pop()!;
if (before === '') {
// We've gone beyond the originally existing text; just backspace.
// TODO: should this use a 'deleteLeft' transformation?
vimState.recordedState.transformer.addTransformation({
type: 'deleteRange',
range: new Range(position.getLeftThroughLineBreaks(), position),
});
} else {
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
text: before,
range: new Range(position.getLeft(), position),
diff: PositionDiff.offset({ character: -1 }),
});
}
}
}
}
@RegisterAction
class ReplaceInReplaceMode extends BaseCommand {
modes = [Mode.Replace];
keys = ['<character>'];
override createsUndoPoint = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (vimState.modeData.mode !== Mode.Replace) {
throw new Error(`Unexpected mode ${vimState.modeData.mode} in ReplaceInReplaceMode`);
}
const char = this.keysPressed[0];
const isNewLineOrTab = char === '\n' || char === '<tab>';
const replaceRange = new Range(position, position.getRight());
let before = vimState.document.getText(replaceRange);
if (!position.isLineEnd() && !isNewLineOrTab) {
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
text: char,
range: replaceRange,
diff: PositionDiff.offset({ character: 1 }),
});
} else if (char === '<tab>') {
vimState.recordedState.transformer.delete(replaceRange);
vimState.recordedState.transformer.vscodeCommand('tab');
} else {
vimState.recordedState.transformer.insert(position, char);
before = '';
}
vimState.modeData.replaceState.getChanges(this.multicursorIndex ?? 0).push({
before,
after: char,
});
}
}
@RegisterAction
class CreateUndoPoint extends BaseCommand {
modes = [Mode.Replace];
keys = ['<C-g>', 'u'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.historyTracker.addChange(true);
vimState.historyTracker.finishCurrentStep();
}
}

View File

@ -0,0 +1,342 @@
import * as vscode from 'vscode';
import { clamp } from 'lodash';
import { Position } from 'vscode';
import { configuration } from '../../configuration/configuration';
import { Mode, isVisualMode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { EditorScrollDirection, EditorScrollByUnit, TextEditor } from '../../textEditor';
import { BaseCommand, RegisterAction } from '../base';
abstract class CommandEditorScroll extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
override runsOnceForEachCountPrefix = false;
abstract to: EditorScrollDirection;
abstract by: EditorScrollByUnit;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const timesToRepeat = vimState.recordedState.count || 1;
const scrolloff = configuration
.getConfiguration('editor')
.get<number>('cursorSurroundingLines', 0);
const visibleRange = vimState.editor.visibleRanges[0];
if (visibleRange === undefined) {
return;
}
const linesAboveCursor =
visibleRange.end.line - vimState.cursorStopPosition.line - timesToRepeat;
const linesBelowCursor =
vimState.cursorStopPosition.line - visibleRange.start.line - timesToRepeat;
if (this.to === 'up' && scrolloff > linesAboveCursor) {
vimState.cursorStopPosition = vimState.cursorStopPosition
.getUp(scrolloff - linesAboveCursor)
.withColumn(vimState.desiredColumn);
} else if (this.to === 'down' && scrolloff > linesBelowCursor) {
vimState.cursorStopPosition = vimState.cursorStopPosition
.getDown(scrolloff - linesBelowCursor)
.withColumn(vimState.desiredColumn);
}
vimState.postponedCodeViewChanges.push({
command: 'editorScroll',
args: {
to: this.to,
by: this.by,
value: timesToRepeat,
select: isVisualMode(vimState.currentMode),
},
});
}
}
@RegisterAction
class CommandCtrlE extends CommandEditorScroll {
keys = ['<C-e>'];
override preservesDesiredColumn = true;
to: EditorScrollDirection = 'down';
by: EditorScrollByUnit = 'line';
}
@RegisterAction
class CommandCtrlY extends CommandEditorScroll {
keys = ['<C-y>'];
override preservesDesiredColumn = true;
to: EditorScrollDirection = 'up';
by: EditorScrollByUnit = 'line';
}
/**
* Commands like `<C-d>` and `<C-f>` act *sort* of like `<count><C-e>`, but they move
* your cursor down and put it on the first non-whitespace character of the line.
*/
abstract class CommandScrollAndMoveCursor extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
override runsOnceForEachCountPrefix = false;
abstract to: EditorScrollDirection;
/** if true, set scroll option instead of repeating command */
setScroll = false;
/**
* @returns the number of lines this command should move the cursor
*/
protected abstract getNumLines(visibleRanges: readonly vscode.Range[]): number;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const { visibleRanges } = vimState.editor;
if (visibleRanges.length === 0) {
return;
}
const smoothScrolling = configuration
.getConfiguration('editor')
.get<boolean>('smoothScrolling', false);
if (this.setScroll && vimState.recordedState.count)
configuration.scroll = vimState.recordedState.count;
const timesToRepeat = (!this.setScroll && vimState.recordedState.count) || 1;
const moveLines = timesToRepeat * this.getNumLines(visibleRanges);
let scrollLines = moveLines;
if (this.to === 'down') {
// This makes <C-d> less wonky when `editor.scrollBeyondLastLine` is enabled
scrollLines = Math.min(
moveLines,
vimState.document.lineCount - 1 - visibleRanges[visibleRanges.length - 1].end.line,
);
}
if (scrollLines > 0) {
const args = {
to: this.to,
by: 'line',
value: scrollLines,
revealCursor: smoothScrolling,
select: isVisualMode(vimState.currentMode),
};
if (smoothScrolling) {
await vscode.commands.executeCommand('editorScroll', args);
} else {
vimState.postponedCodeViewChanges.push({
command: 'editorScroll',
args,
});
}
}
const newPositionLine = clamp(
position.line + (this.to === 'down' ? moveLines : -moveLines),
0,
vimState.document.lineCount - 1,
);
vimState.cursorStopPosition = new Position(
newPositionLine,
vimState.desiredColumn,
).obeyStartOfLine(vimState.document);
}
}
@RegisterAction
class CommandMoveFullPageUp extends CommandScrollAndMoveCursor {
keys = ['<C-b>'];
to: EditorScrollDirection = 'up';
protected getNumLines(visibleRanges: vscode.Range[]) {
return visibleRanges[0].end.line - visibleRanges[0].start.line;
}
}
@RegisterAction
class CommandMoveFullPageDown extends CommandScrollAndMoveCursor {
keys = ['<C-f>'];
to: EditorScrollDirection = 'down';
protected getNumLines(visibleRanges: vscode.Range[]) {
return visibleRanges[0].end.line - visibleRanges[0].start.line;
}
}
@RegisterAction
class CommandCtrlD extends CommandScrollAndMoveCursor {
keys = ['<C-d>'];
to: EditorScrollDirection = 'down';
override setScroll = true;
protected getNumLines(visibleRanges: vscode.Range[]) {
return configuration.getScrollLines(visibleRanges);
}
}
@RegisterAction
class CommandCtrlU extends CommandScrollAndMoveCursor {
keys = ['<C-u>'];
to: EditorScrollDirection = 'up';
override setScroll = true;
protected getNumLines(visibleRanges: vscode.Range[]) {
return configuration.getScrollLines(visibleRanges);
}
}
@RegisterAction
class CommandCenterScroll extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', 'z'];
override preservesDesiredColumn = true;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// In these modes you want to center on the cursor position
vimState.editor.revealRange(
new vscode.Range(vimState.cursorStopPosition, vimState.cursorStopPosition),
vscode.TextEditorRevealType.InCenter,
);
}
}
@RegisterAction
class CommandCenterScrollFirstChar extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', '.'];
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// In these modes you want to center on the cursor position
// This particular one moves cursor to first non blank char though
vimState.editor.revealRange(
new vscode.Range(vimState.cursorStopPosition, vimState.cursorStopPosition),
vscode.TextEditorRevealType.InCenter,
);
// Move cursor to first char of line
vimState.cursorStopPosition = TextEditor.getFirstNonWhitespaceCharOnLine(
vimState.document,
vimState.cursorStopPosition.line,
);
}
}
@RegisterAction
class CommandTopScroll extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', 't'];
override preservesDesiredColumn = true;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'revealLine',
args: {
lineNumber: position.line,
at: 'top',
},
});
}
}
@RegisterAction
class CommandTopScrollFirstChar extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', '\n'];
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// In these modes you want to center on the cursor position
// This particular one moves cursor to first non blank char though
vimState.postponedCodeViewChanges.push({
command: 'revealLine',
args: {
lineNumber: position.line,
at: 'top',
},
});
// Move cursor to first char of line
vimState.cursorStopPosition = TextEditor.getFirstNonWhitespaceCharOnLine(
vimState.document,
vimState.cursorStopPosition.line,
);
}
}
@RegisterAction
class CommandBottomScroll extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', 'b'];
override preservesDesiredColumn = true;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'revealLine',
args: {
lineNumber: position.line,
at: 'bottom',
},
});
}
}
@RegisterAction
class CommandBottomScrollFirstChar extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['z', '-'];
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
// Don't run if there's an operator because the Sneak plugin uses <operator>z
return (
super.doesActionApply(vimState, keysPressed) && vimState.recordedState.operator === undefined
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// In these modes you want to center on the cursor position
// This particular one moves cursor to first non blank char though
vimState.postponedCodeViewChanges.push({
command: 'revealLine',
args: {
lineNumber: position.line,
at: 'bottom',
},
});
// Move cursor to first char of line
vimState.cursorStopPosition = TextEditor.getFirstNonWhitespaceCharOnLine(
vimState.document,
vimState.cursorStopPosition.line,
);
}
}

View File

@ -0,0 +1,313 @@
import * as _ from 'lodash';
import { escapeRegExp } from 'lodash';
import { Position, Selection } from 'vscode';
import { SearchCommandLine } from '../../cmd_line/commandLine';
import { sorted } from '../../common/motion/position';
import { configuration } from '../../configuration/configuration';
import { ErrorCode, VimError } from '../../error';
import { Mode, isVisualMode } from '../../mode/mode';
import { Register } from '../../register/register';
import { globalState } from '../../state/globalState';
import { SearchState } from '../../state/searchState';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { TextEditor } from '../../textEditor';
import { TextObject } from '../../textobject/textobject';
import { reportSearch } from '../../util/statusBarTextUtils';
import { SearchDirection } from '../../vimscript/pattern';
import { BaseCommand, RegisterAction } from '../base';
import { IMovement, failedMovement } from '../baseMotion';
/**
* Search for the word under the cursor; used by [g]* and [g]#
*/
async function searchCurrentWord(
position: Position,
vimState: VimState,
direction: SearchDirection,
isExact: boolean,
): Promise<void> {
let currentWord = TextEditor.getWord(vimState.document, position);
if (currentWord) {
if (/\W/.test(currentWord[0]) || /\W/.test(currentWord[currentWord.length - 1])) {
// TODO: this kind of sucks. JS regex does not consider the boundary between a special
// character and whitespace to be a "word boundary", so we can't easily do an exact search.
isExact = false;
}
if (isExact) {
currentWord = _.escapeRegExp(currentWord);
}
// If the search is going left then use `getWordLeft()` on position to start
// at the beginning of the word. This ensures that any matches happen
// outside of the currently selected word.
const searchStartCursorPosition =
direction === SearchDirection.Backward
? vimState.cursorStopPosition.prevWordStart(vimState.document, { inclusive: true })
: vimState.cursorStopPosition;
await createSearchStateAndMoveToMatch({
needle: currentWord,
vimState,
direction,
isExact,
searchStartCursorPosition,
});
} else {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.NoStringUnderCursor));
}
}
/**
* Search for the word under the cursor; used by [g]* and [g]# in visual mode when `visualstar` is enabled
*/
async function searchCurrentSelection(vimState: VimState, direction: SearchDirection) {
const currentSelection = vimState.document.getText(vimState.editor.selection);
// Go back to Normal mode, otherwise the selection grows to the next match.
await vimState.setCurrentMode(Mode.Normal);
const [start, end] = sorted(vimState.cursorStartPosition, vimState.cursorStopPosition);
// Ensure that any matches happen outside of the currently selected word.
const searchStartCursorPosition =
direction === SearchDirection.Backward ? start.getLeft() : end.getRight();
await createSearchStateAndMoveToMatch({
needle: currentSelection,
vimState,
direction,
isExact: false,
searchStartCursorPosition,
});
}
/**
* Used by [g]* and [g]#
*/
async function createSearchStateAndMoveToMatch(args: {
needle: string;
vimState: VimState;
direction: SearchDirection;
isExact: boolean;
searchStartCursorPosition: Position;
}): Promise<void> {
const { needle, vimState, isExact } = args;
if (needle.length === 0) {
return;
}
const escapedNeedle = escapeRegExp(needle).replace('/', '\\/');
const searchString = isExact ? `\\<${escapedNeedle}\\>` : escapedNeedle;
// Start a search for the given term.
globalState.searchState = new SearchState(
args.direction,
vimState.cursorStopPosition,
searchString,
{ ignoreSmartcase: true },
);
Register.setReadonlyRegister('/', globalState.searchState.searchString);
void SearchCommandLine.addSearchStateToHistory(globalState.searchState);
// Turn one of the highlighting flags back on (turned off with :nohl)
globalState.hl = true;
const nextMatch = globalState.searchState.getNextSearchMatchPosition(
vimState,
args.searchStartCursorPosition,
);
if (nextMatch) {
vimState.cursorStopPosition = nextMatch.pos;
reportSearch(
nextMatch.index,
globalState.searchState.getMatchRanges(vimState).length,
vimState,
);
} else {
StatusBar.displayError(
vimState,
VimError.fromCode(
args.direction === SearchDirection.Forward
? ErrorCode.SearchHitBottom
: ErrorCode.SearchHitTop,
globalState.searchState.searchString,
),
);
}
}
@RegisterAction
class CommandSearchCurrentWordExactForward extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['*'];
override actionType = 'motion' as const;
override runsOnceForEachCountPrefix = true;
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (isVisualMode(vimState.currentMode) && configuration.visualstar) {
await searchCurrentSelection(vimState, SearchDirection.Forward);
} else {
await searchCurrentWord(position, vimState, SearchDirection.Forward, true);
}
}
}
@RegisterAction
class CommandSearchCurrentWordForward extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['g', '*'];
override actionType = 'motion' as const;
override runsOnceForEachCountPrefix = true;
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
await searchCurrentWord(position, vimState, SearchDirection.Forward, false);
}
}
@RegisterAction
class CommandSearchCurrentWordExactBackward extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['#'];
override actionType = 'motion' as const;
override runsOnceForEachCountPrefix = true;
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (isVisualMode(vimState.currentMode) && configuration.visualstar) {
await searchCurrentSelection(vimState, SearchDirection.Backward);
} else {
await searchCurrentWord(position, vimState, SearchDirection.Backward, true);
}
}
}
@RegisterAction
class CommandSearchCurrentWordBackward extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['g', '#'];
override actionType = 'motion' as const;
override runsOnceForEachCountPrefix = true;
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
await searchCurrentWord(position, vimState, SearchDirection.Backward, false);
}
}
@RegisterAction
class CommandSearchForwards extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['/'];
override actionType = 'motion' as const;
override isJump = true;
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
await vimState.setCurrentMode(Mode.SearchInProgressMode);
}
}
@RegisterAction
class CommandSearchBackwards extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
keys = ['?'];
override actionType = 'motion' as const;
override isJump = true;
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// TODO: Better VimState API than this...
await vimState.setModeData({
mode: Mode.SearchInProgressMode,
commandLine: new SearchCommandLine(vimState, '', SearchDirection.Backward),
firstVisibleLineBeforeSearch: vimState.editor.visibleRanges[0].start.line,
});
}
}
abstract class SearchObject extends TextObject {
override modes = [Mode.Normal, Mode.Visual, Mode.VisualBlock];
protected abstract readonly direction: SearchDirection;
public async execAction(position: Position, vimState: VimState): Promise<IMovement> {
const searchState = globalState.searchState;
if (!searchState || searchState.searchString === '') {
return failedMovement(vimState);
}
const newSearchState = new SearchState(
this.direction,
vimState.cursorStopPosition,
searchState.searchString,
{},
);
// At first, try to search for current word, and stop searching if matched.
// Try to search for the next word if not matched or
// if the cursor is at the end of a match string in visual-mode.
let result = newSearchState.findContainingMatchRange(vimState, vimState.cursorStopPosition);
if (
result &&
vimState.currentMode === Mode.Visual &&
vimState.cursorStopPosition.isEqual(result.range.end.getLeftThroughLineBreaks())
) {
result = undefined;
}
if (result === undefined) {
// Try to search for the next word
result = newSearchState.getNextSearchMatchRange(vimState, vimState.cursorStopPosition);
if (result === undefined) {
return failedMovement(vimState);
}
}
reportSearch(result.index, searchState.getMatchRanges(vimState).length, vimState);
const [start, stop] = [
vimState.currentMode === Mode.Normal ? result.range.start : vimState.cursorStopPosition,
result.range.end.getLeftThroughLineBreaks(),
];
// Move the cursor, this is a bit hacky...
vimState.cursorStartPosition = start;
vimState.cursorStopPosition = stop;
vimState.editor.selection = new Selection(start, stop);
await vimState.setCurrentMode(Mode.Visual);
return {
start,
stop,
};
}
public override async execActionForOperator(
position: Position,
vimState: VimState,
): Promise<IMovement> {
return this.execAction(position, vimState);
}
}
@RegisterAction
class SearchObjectForward extends SearchObject {
keys = ['g', 'n'];
direction = SearchDirection.Forward;
}
@RegisterAction
class SearchObjectBackward extends SearchObject {
keys = ['g', 'N'];
direction = SearchDirection.Backward;
}

View File

@ -0,0 +1,220 @@
import { Position } from 'vscode';
import { OnlyCommand } from '../../cmd_line/commands/only';
import { QuitCommand } from '../../cmd_line/commands/quit';
import { Mode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { BaseCommand, RegisterAction } from '../base';
@RegisterAction
class CommandQuit extends BaseCommand {
modes = [Mode.Normal];
keys = [
['<C-w>', 'q'],
['<C-w>', '<C-q>'],
['<C-w>', 'c'],
['<C-w>', '<C-c>'],
];
public override async exec(position: Position, vimState: VimState): Promise<void> {
void new QuitCommand({}).execute(vimState);
}
}
@RegisterAction
class CommandOnly extends BaseCommand {
modes = [Mode.Normal];
keys = [
['<C-w>', 'o'],
['<C-w>', '<C-o>'],
];
public override async exec(position: Position, vimState: VimState): Promise<void> {
void new OnlyCommand().execute(vimState);
}
}
@RegisterAction
class MoveToRightPane extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 'l'],
['<C-w>', '<right>'],
['<C-w>', '<C-l>'],
];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.navigateRight',
args: {},
});
}
}
@RegisterAction
class MoveToLowerPane extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 'j'],
['<C-w>', '<down>'],
['<C-w>', '<C-j>'],
];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.navigateDown',
args: {},
});
}
}
@RegisterAction
class MoveToUpperPane extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 'k'],
['<C-w>', '<up>'],
['<C-w>', '<C-k>'],
];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.navigateUp',
args: {},
});
}
}
@RegisterAction
class MoveToLeftPane extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 'h'],
['<C-w>', '<left>'],
['<C-w>', '<C-h>'],
];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.navigateLeft',
args: {},
});
}
}
@RegisterAction
class CycleThroughPanes extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', '<C-w>'],
['<C-w>', 'w'],
];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.navigateEditorGroups',
args: {},
});
}
}
@RegisterAction
class VerticalSplit extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 'v'],
['<C-w>', '<C-v>'],
];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.splitEditor',
args: {},
});
}
}
@RegisterAction
class OrthogonalSplit extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = [
['<C-w>', 's'],
['<C-w>', '<C-s>'],
];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.splitEditorOrthogonal',
args: {},
});
}
}
@RegisterAction
class EvenPaneWidths extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['<C-w>', '='];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.evenEditorWidths',
args: {},
});
}
}
@RegisterAction
class IncreasePaneWidth extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['<C-w>', '>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.increaseViewWidth',
args: {},
});
}
}
@RegisterAction
class DecreasePaneWidth extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['<C-w>', '<'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.decreaseViewWidth',
args: {},
});
}
}
@RegisterAction
class IncreasePaneHeight extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['<C-w>', '+'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.increaseViewHeight',
args: {},
});
}
}
@RegisterAction
class DecreasePaneHeight extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
keys = ['<C-w>', '-'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
vimState.postponedCodeViewChanges.push({
command: 'workbench.action.decreaseViewHeight',
args: {},
});
}
}

View File

@ -1,145 +0,0 @@
import { Selection, commands, window } from 'vscode';
import { Action } from '../action_types';
import { Mode } from '../modes_types';
import { parseKeysExact } from '../parse_keys';
// https://docs.helix-editor.com/keymap.html#goto-mode
export const gotoActions: Action[] = [
// G actions
parseKeysExact(['g', '.'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.navigateToLastEditLocation');
}),
parseKeysExact(['g', 'e'], [Mode.Normal], () => {
commands.executeCommand('cursorBottom');
}),
parseKeysExact(['g', 'e'], [Mode.Visual], () => {
commands.executeCommand('cursorBottomSelect');
}),
parseKeysExact(['g', 'g'], [Mode.Normal], (helixState, editor) => {
const count = helixState.resolveCount();
if (count !== 1) {
const range = editor.document.lineAt(count - 1).range;
editor.selection = new Selection(range.start, range.end);
editor.revealRange(range);
return;
}
commands.executeCommand('cursorTop');
}),
parseKeysExact(['g', 'g'], [Mode.Visual], (helixState, editor) => {
const count = helixState.resolveCount();
if (count !== 1) {
const position = editor.selection.active;
const range = editor.document.lineAt(count - 1).range;
if (position.isBefore(range.start)) {
editor.selection = new Selection(position, range.end);
} else {
editor.selection = new Selection(position, range.start);
}
return;
}
commands.executeCommand('cursorTopSelect');
}),
parseKeysExact(['g', 'h'], [Mode.Normal], () => {
commands.executeCommand('cursorLineStart');
}),
parseKeysExact(['g', 'h'], [Mode.Visual], () => {
commands.executeCommand('cursorLineStartSelect');
}),
parseKeysExact(['g', 'l'], [Mode.Normal], () => {
commands.executeCommand('cursorLineEnd');
}),
parseKeysExact(['g', 'l'], [Mode.Visual], () => {
commands.executeCommand('cursorLineEndSelect');
}),
parseKeysExact(['g', 's'], [Mode.Normal], () => {
commands.executeCommand('cursorHome');
}),
parseKeysExact(['g', 's'], [Mode.Visual], () => {
commands.executeCommand('cursorHomeSelect');
}),
parseKeysExact(['g', 'd'], [Mode.Normal], () => {
commands.executeCommand('editor.action.revealDefinition');
}),
parseKeysExact(['g', 'y'], [Mode.Normal], () => {
commands.executeCommand('editor.action.goToTypeDefinition');
}),
parseKeysExact(['g', 'r'], [Mode.Normal], () => {
commands.executeCommand('editor.action.goToReferences');
}),
parseKeysExact(['g', 't'], [Mode.Normal], () => {
commands.executeCommand('cursorPageUp');
}),
parseKeysExact(['g', 't'], [Mode.Visual], () => {
commands.executeCommand('cursorPageUpSelect');
}),
parseKeysExact(['g', 'b'], [Mode.Normal], () => {
commands.executeCommand('cursorPageDown');
}),
parseKeysExact(['g', 'b'], [Mode.Visual], () => {
commands.executeCommand('cursorPageDownSelect');
}),
parseKeysExact(['g', 'c'], [Mode.Normal], () => {
commands.executeCommand('cursorMove', {
to: 'viewPortCenter',
});
}),
parseKeysExact(['g', 'c'], [Mode.Visual], () => {
commands.executeCommand('cursorMove', {
to: 'viewPortCenter',
select: true,
});
}),
parseKeysExact(['g', 'k'], [Mode.Normal], () => {
commands.executeCommand('scrollLineUp');
}),
parseKeysExact(['g', 'j'], [Mode.Normal], () => {
commands.executeCommand('scrollLineDown');
}),
parseKeysExact(['g', 'a'], [Mode.Normal], (helixState) => {
// VS Code has no concept of "last accessed file" so instead we'll need to keep track of previous text editors
const editor = helixState.editorState.previousEditor;
if (!editor) return;
window.showTextDocument(editor.document);
}),
parseKeysExact(['g', 'm'], [Mode.Normal], (helixState) => {
// VS Code has no concept of "last accessed file" so instead we'll need to keep track of previous text editors
const document = helixState.editorState.lastModifiedDocument;
if (!document) return;
window.showTextDocument(document);
}),
parseKeysExact(['g', 'n'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.nextEditor');
}),
parseKeysExact(['g', 'p'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.previousEditor');
}),
];

View File

@ -0,0 +1,16 @@
import './base';
import './operator';
import './motion';
import '../textobject/textobject';
// commands
import './commands/insert';
import './commands/replace';
import './commands/actions';
import './commands/commandLine';
import './commands/search';
import './commands/put';
import './commands/digraphs';
import './commands/window';
import './commands/fold';
import './commands/scroll';

View File

@ -0,0 +1,8 @@
// plugin
import './plugins/camelCaseMotion';
import './plugins/easymotion/easymotion.cmd';
import './plugins/easymotion/registerMoveActions';
import './plugins/sneak';
import './plugins/replaceWithRegister';
import './plugins/surround';
import './plugins/targets/targets';

View File

@ -1,6 +0,0 @@
import { Action } from '../action_types'
import { actions as subActions } from './actions'
import { operators } from './operators'
import { motions } from './motions'
export const actions: Action[] = subActions.concat(operators, motions)

View File

@ -1,17 +0,0 @@
export default {
Motions: {
MoveLeft: 'h',
MoveRight: 'l',
MoveDown: 'j',
MoveUp: 'k',
MoveLineEnd: 'o',
MoveLineStart: 'u',
},
Actions: {
InsertMode: 'i',
InsertAtLineStart: 'I',
InsertAtLineEnd: 'A',
NewLineAbove: 'O',
NewLineBelow: 'o',
},
};

View File

@ -0,0 +1,249 @@
import { Position, TextDocument } from 'vscode';
import { VimState } from '../../../state/vimState';
import { RegisterAction } from '../../base';
import { BaseMovement, failedMovement, IMovement } from '../../baseMotion';
type Type = 'function' | 'class';
type Edge = 'start' | 'end';
type Direction = 'next' | 'prev';
interface LineInfo {
line: number;
indentation: number;
text: string;
}
interface StructureElement {
type: Type;
start: Position;
end: Position;
}
// Older browsers don't support lookbehind - in this case, use an inferior regex rather than crashing
let supportsLookbehind = true;
try {
new RegExp('(?<=x)');
} catch {
supportsLookbehind = false;
}
/*
* Utility class used to parse the lines in the document and
* determine class and function boundaries
*
* The class keeps track of two positions: the ORIGINAL and the CURRENT
* using their relative locations to make decisions.
*/
export class PythonDocument {
_document: TextDocument;
structure: StructureElement[];
static readonly reOnlyWhitespace = /\S/;
static readonly reLastNonWhiteSpaceCharacter = supportsLookbehind
? new RegExp('(?<=\\S)\\s*$')
: /(\S)\s*$/;
static readonly reDefOrClass = /^\s*(def|class) /;
constructor(document: TextDocument) {
this._document = document;
const parsed = PythonDocument._parseLines(document);
this.structure = PythonDocument._parseStructure(parsed);
}
/*
* Generator of the lines of text in the document
*/
static *lines(document: TextDocument): Generator<string> {
for (let index = 0; index < document.lineCount; index++) {
yield document.lineAt(index).text;
}
}
/*
* Calculate the indentation of a line of text.
* Lines consisting entirely of whitespace of "starting" with a comment are defined
* to have an indentation of "undefined".
*/
static _indentation(line: string): number | undefined {
const index: number = line.search(PythonDocument.reOnlyWhitespace);
// Return undefined if line is empty, just whitespace, or starts with a comment
if (index === -1 || line[index] === '#') {
return undefined;
}
return index;
}
/*
* Parse a line of text to extract LineInfo
* Return undefined if the line is empty or starts with a comment
*/
static _parseLine(index: number, text: string): LineInfo | undefined {
const indentation = this._indentation(text);
// Since indentation === 0 is a valid result we need to check for undefined explicitly
return indentation !== undefined ? { line: index, indentation, text } : undefined;
}
static _parseLines(document: TextDocument): LineInfo[] {
const lines = [...this.lines(document)]; // convert generator to Array
const infos = lines.map((text, index) => this._parseLine(index, text));
return infos.filter((x) => x) as LineInfo[]; // filter out empty/comment lines (undefined info)
}
static _parseStructure(lines: LineInfo[]): StructureElement[] {
const last = lines.length;
const structure: StructureElement[] = [];
for (let index = 0; index < last; index++) {
const info = lines[index];
const text = info.text;
const match = text.match(PythonDocument.reDefOrClass);
if (match) {
const type = match[1] === 'def' ? 'function' : 'class';
// Find the end of the current function/class
let idx = index + 1;
for (; idx < last; idx++) {
if (lines[idx].indentation <= info.indentation) {
break;
}
}
// Since we stop when we find the first line with a less indentation
// we pull back one line to get to the end of the function/class
idx--;
const endLine = lines[idx];
structure.push({
type,
start: new Position(info.line, info.indentation),
// Calculate position of last non-white character)
end: new Position(
endLine.line,
endLine.text.search(PythonDocument.reLastNonWhiteSpaceCharacter) - 1,
),
});
}
}
return structure;
}
/*
* Find the position of the specified:
* type: function or class
* direction: next or prev
* edge: start or end
*
* With this information one can determine all of the required motions
*/
find(type: Type, direction: Direction, edge: Edge, position: Position): Position | undefined {
// Choose the ordering method name based on direction
const isDirection = direction === 'next' ? 'isAfter' : 'isBefore';
// Filter function for all elements whose "edge" is in the correct "direction"
// relative to the cursor's position
const dir = (element: StructureElement) => element[edge][isDirection](position);
// Filter out elements from structure based on type and direction
const elements = this.structure.filter((elem) => elem.type === type).filter(dir);
if (edge === 'end') {
// When moving to an 'end' the elements should be started by the end position
elements.sort((a, b) => a.end.line - b.end.line);
}
// Return the first match if any exist
if (elements.length) {
// If direction === 'next' return the first element
// otherwise return the last element
const index = direction === 'next' ? 0 : elements.length - 1;
const element = elements[index];
const pos = element[edge];
// execAction MUST return a fully realized Position object created using new
return pos;
}
return undefined;
}
// Use PythonDocument instance to move to specified class boundary
static moveClassBoundary(
document: TextDocument,
position: Position,
vimState: VimState,
forward: boolean,
start: boolean,
): Position | IMovement {
const direction = forward ? 'next' : 'prev';
const edge = start ? 'start' : 'end';
return (
new PythonDocument(document).find('class', direction, edge, position) ??
failedMovement(vimState)
);
}
}
// Uses the specified findFunction to execute the motion coupled to the shortcut (keys)
abstract class BasePythonMovement extends BaseMovement {
abstract type: Type;
abstract direction: Direction;
abstract edge: Edge;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
return (
super.doesActionApply(vimState, keysPressed) && vimState.document.languageId === 'python'
);
}
public override async execAction(
position: Position,
vimState: VimState,
): Promise<Position | IMovement> {
const document = vimState.document;
return (
new PythonDocument(document).find(this.type, this.direction, this.edge, position) ??
failedMovement(vimState)
);
}
}
@RegisterAction
class MovePythonNextFunctionStart extends BasePythonMovement {
keys = [']', 'm'];
type: Type = 'function';
direction: Direction = 'next';
edge: Edge = 'start';
}
@RegisterAction
class MovePythonPrevFunctionStart extends BasePythonMovement {
keys = ['[', 'm'];
type: Type = 'function';
direction: Direction = 'prev';
edge: Edge = 'start';
}
@RegisterAction
class MovePythonNextFunctionEnd extends BasePythonMovement {
keys = [']', 'M'];
type: Type = 'function';
direction: Direction = 'next';
edge: Edge = 'end';
}
@RegisterAction
class MovePythonPrevFunctionEnd extends BasePythonMovement {
keys = ['[', 'M'];
type: Type = 'function';
direction: Direction = 'prev';
edge: Edge = 'end';
}

View File

@ -1,137 +0,0 @@
import * as vscode from 'vscode';
import { Action } from '../action_types';
import { enterInsertMode, setModeCursorStyle } from '../modes';
import { Mode } from '../modes_types';
import { parseKeysExact, parseKeysRegex } from '../parse_keys';
import { searchBackwardBracket, searchForwardBracket } from '../search_utils';
import { removeTypeSubscription } from '../type_subscription';
import { delete_, yank } from './operators';
export const matchActions: Action[] = [
// Implemenent jump to bracket
parseKeysExact(['m', 'm'], [Mode.Normal], () => {
vscode.commands.executeCommand('editor.action.jumpToBracket');
}),
parseKeysExact(['m', 'm'], [Mode.Visual], () => {
vscode.commands.executeCommand('editor.action.selectToBracket');
}),
// Delete match
parseKeysExact(['d'], [Mode.Normal, Mode.Visual], (helixState, editor) => {
const ranges = editor.selections.map((selection) => selection.with());
yank(helixState, editor, ranges, false);
delete_(editor, ranges, false);
}),
// edit match
parseKeysExact(['c'], [Mode.Normal, Mode.Visual], (helixState, editor) => {
const ranges = editor.selections.map((selection) => selection.with());
delete_(editor, ranges, false);
enterInsertMode(helixState);
setModeCursorStyle(helixState.mode, editor);
removeTypeSubscription(helixState);
}),
// implement match add to selection
parseKeysRegex(/^ms(.)$/, /^ms/, [Mode.Normal, Mode.Visual], (helixState, editor, match) => {
const char = match[1];
const [startChar, endChar] = getMatchPairs(char);
// Add char to both ends of each selection
editor.edit((editBuilder) => {
// Add char to both ends of each selection
editor.selections.forEach((selection) => {
const start = selection.start;
const end = selection.end;
editBuilder.insert(start, startChar);
editBuilder.insert(end, endChar);
});
});
}),
// implement match replace to selection
parseKeysRegex(/^mr(.)(.)$/, /^mr(.)?/, [Mode.Normal, Mode.Visual], (helixState, editor, match) => {
const original = match[1];
const replacement = match[2];
const [startCharOrig, endCharOrig] = getMatchPairs(original);
const [startCharNew, endCharNew] = getMatchPairs(replacement);
const num = helixState.resolveCount();
const forwardPosition = searchForwardBracket(
editor.document,
startCharOrig,
endCharOrig,
editor.selection.active,
num,
);
const backwardPosition = searchBackwardBracket(
editor.document,
startCharOrig,
endCharOrig,
editor.selection.active,
num,
);
if (forwardPosition === undefined || backwardPosition === undefined) return;
// Add char to both ends of each selection
editor.edit((editBuilder) => {
// Add char to both ends of each selection
editBuilder.replace(
new vscode.Range(forwardPosition, forwardPosition.with({ character: forwardPosition.character + 1 })),
endCharNew,
);
editBuilder.replace(
new vscode.Range(backwardPosition, backwardPosition.with({ character: backwardPosition.character + 1 })),
startCharNew,
);
});
}),
// implement match delete character
parseKeysRegex(/^md(.)$/, /^md/, [Mode.Normal, Mode.Visual], (helixState, editor, match) => {
const char = match[1];
const [startChar, endChar] = getMatchPairs(char);
const num = helixState.resolveCount();
const forwardPosition = searchForwardBracket(editor.document, startChar, endChar, editor.selection.active, num);
const backwardPosition = searchBackwardBracket(editor.document, startChar, endChar, editor.selection.active, num);
if (forwardPosition === undefined || backwardPosition === undefined) return;
// Add char to both ends of each selection
editor.edit((editBuilder) => {
// Add char to both ends of each selection
editBuilder.delete(
new vscode.Range(forwardPosition, forwardPosition.with({ character: forwardPosition.character + 1 })),
);
editBuilder.delete(
new vscode.Range(backwardPosition, backwardPosition.with({ character: backwardPosition.character + 1 })),
);
});
}),
];
const getMatchPairs = (char: string) => {
let startChar: string;
let endChar: string;
if (['{', '}'].includes(char)) {
startChar = '{';
endChar = '}';
} else if (['[', ']'].includes(char)) {
startChar = '[';
endChar = ']';
} else if (['(', ')'].includes(char)) {
startChar = '(';
endChar = ')';
} else if (['<', '>'].includes(char)) {
startChar = '<';
endChar = '>';
} else {
// Otherwise, startChar and endChar should be the same character
startChar = char;
endChar = char;
}
return [startChar, endChar];
};

2355
src/actions/motion.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,486 +0,0 @@
import * as vscode from 'vscode';
import { Action } from '../action_types';
import { HelixState } from '../helix_state_types';
import { Mode } from '../modes_types';
import { paragraphBackward, paragraphForward } from '../paragraph_utils';
import { parseKeysExact, parseKeysRegex } from '../parse_keys';
import * as positionUtils from '../position_utils';
import { searchBackward, searchForward } from '../search_utils';
import {
vimToVscodeVisualLineSelection,
vimToVscodeVisualSelection,
vscodeToVimVisualLineSelection,
vscodeToVimVisualSelection,
} from '../selection_utils';
import { setVisualLineSelections } from '../visual_line_utils';
import { setVisualSelections } from '../visual_utils';
import { whitespaceWordRanges, wordRanges } from '../word_utils';
import KeyMap from './keymaps';
export const motions: Action[] = [
parseKeysExact([KeyMap.Motions.MoveRight], [Mode.Visual], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
return positionUtils.rightNormal(document, position, vimState.resolveCount());
});
}),
parseKeysExact([KeyMap.Motions.MoveLeft], [Mode.Visual], (vimState, editor) => {
execMotion(vimState, editor, ({ position }) => {
return positionUtils.left(position, vimState.resolveCount());
});
}),
parseKeysExact([KeyMap.Motions.MoveRight], [Mode.Normal], () => {
vscode.commands.executeCommand('cursorRight');
}),
parseKeysExact([KeyMap.Motions.MoveLeft], [Mode.Normal], () => {
vscode.commands.executeCommand('cursorLeft');
}),
parseKeysExact([KeyMap.Motions.MoveUp], [Mode.Normal], (_vimState, _editor) => {
vscode.commands.executeCommand('cursorMove', {
to: 'up',
by: 'wrappedLine',
value: _vimState.resolveCount(),
});
}),
parseKeysExact([KeyMap.Motions.MoveUp], [Mode.Visual], (vimState, editor) => {
const originalSelections = editor.selections;
vscode.commands
.executeCommand('cursorMove', {
to: 'up',
by: 'wrappedLine',
select: true,
value: vimState.resolveCount(),
})
.then(() => {
setVisualSelections(editor, originalSelections);
});
}),
parseKeysExact([KeyMap.Motions.MoveUp], [Mode.VisualLine], (vimState, editor) => {
vscode.commands
.executeCommand('cursorMove', { to: 'up', by: 'line', select: true, value: vimState.resolveCount() })
.then(() => {
setVisualLineSelections(editor);
});
}),
parseKeysExact([KeyMap.Motions.MoveDown], [Mode.Normal], (_vimState, _editor) => {
vscode.commands.executeCommand('cursorMove', {
to: 'down',
by: 'wrappedLine',
value: _vimState.resolveCount(),
});
}),
parseKeysExact([KeyMap.Motions.MoveDown], [Mode.Visual], (vimState, editor) => {
const originalSelections = editor.selections;
vscode.commands
.executeCommand('cursorMove', {
to: 'down',
by: 'wrappedLine',
select: true,
value: vimState.resolveCount(),
})
.then(() => {
setVisualSelections(editor, originalSelections);
});
}),
parseKeysExact([KeyMap.Motions.MoveDown], [Mode.VisualLine], (vimState, editor) => {
vscode.commands.executeCommand('cursorMove', { to: 'down', by: 'line', select: true }).then(() => {
setVisualLineSelections(editor);
});
}),
parseKeysExact(['w'], [Mode.Normal, Mode.Visual], createWordForwardHandler(wordRanges)),
parseKeysExact(['W'], [Mode.Normal, Mode.Visual], createWordForwardHandler(whitespaceWordRanges)),
parseKeysExact(['b'], [Mode.Normal, Mode.Visual], createWordBackwardHandler(wordRanges)),
parseKeysExact(['B'], [Mode.Normal, Mode.Visual], createWordBackwardHandler(whitespaceWordRanges)),
parseKeysExact(['e'], [Mode.Normal, Mode.Visual], createWordEndHandler(wordRanges)),
parseKeysExact(['E'], [Mode.Normal, Mode.Visual], createWordEndHandler(whitespaceWordRanges)),
parseKeysRegex(/^f(.)$/, /^(f|f.)$/, [Mode.Normal, Mode.Visual], (vimState, editor, match) => {
findForward(vimState, editor, match);
vimState.repeatLastMotion = (innerVimState, innerEditor) => {
findForward(innerVimState, innerEditor, match);
};
}),
parseKeysRegex(/^F(.)$/, /^(F|F.)$/, [Mode.Normal, Mode.Visual], (vimState, editor, match) => {
findBackward(vimState, editor, match);
vimState.repeatLastMotion = (innerVimState, innerEditor) => {
findBackward(innerVimState, innerEditor, match);
};
}),
parseKeysRegex(/^t(.)$/, /^t$/, [Mode.Normal, Mode.Visual], (vimState, editor, match) => {
tillForward(vimState, editor, match);
vimState.repeatLastMotion = (innerVimState, innerEditor) => {
tillForward(innerVimState, innerEditor, match);
};
}),
parseKeysRegex(/^T(.)$/, /^T$/, [Mode.Normal, Mode.Visual], (vimState, editor, match) => {
tillBackward(vimState, editor, match);
vimState.repeatLastMotion = (innerVimState, innerEditor) => {
tillBackward(innerVimState, innerEditor, match);
};
}),
parseKeysExact(['}'], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
return new vscode.Position(paragraphForward(document, position.line), 0);
});
}),
parseKeysExact([']', 'p'], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
return new vscode.Position(paragraphForward(document, position.line), 0);
});
}),
parseKeysExact(['{'], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
return new vscode.Position(paragraphBackward(document, position.line), 0);
});
}),
parseKeysExact(['[', 'p'], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
return new vscode.Position(paragraphBackward(document, position.line), 0);
});
}),
parseKeysExact([KeyMap.Motions.MoveLineEnd], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
const lineLength = document.lineAt(position.line).text.length;
return position.with({ character: Math.max(lineLength - 1, 0) });
});
}),
parseKeysExact([KeyMap.Motions.MoveLineStart], [Mode.Normal, Mode.Visual, Mode.VisualLine], (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
const line = document.lineAt(position.line);
return position.with({
character: line.firstNonWhitespaceCharacterIndex,
});
});
}),
parseKeysExact(['H'], [Mode.Normal], (_vimState, _editor) => {
vscode.commands.executeCommand('cursorMove', {
to: 'viewPortTop',
by: 'line',
});
}),
parseKeysExact(['H'], [Mode.Visual], (vimState, editor) => {
const originalSelections = editor.selections;
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortTop',
by: 'line',
select: true,
})
.then(() => {
setVisualSelections(editor, originalSelections);
});
}),
parseKeysExact(['H'], [Mode.VisualLine], (vimState, editor) => {
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortTop',
by: 'line',
select: true,
})
.then(() => {
setVisualLineSelections(editor);
});
}),
parseKeysExact(['M'], [Mode.Normal], (_vimState, _editor) => {
vscode.commands.executeCommand('cursorMove', {
to: 'viewPortCenter',
by: 'line',
});
}),
parseKeysExact(['M'], [Mode.Visual], (vimState, editor) => {
const originalSelections = editor.selections;
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortCenter',
by: 'line',
select: true,
})
.then(() => {
setVisualSelections(editor, originalSelections);
});
}),
parseKeysExact(['M'], [Mode.VisualLine], (vimState, editor) => {
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortCenter',
by: 'line',
select: true,
})
.then(() => {
setVisualLineSelections(editor);
});
}),
parseKeysExact(['L'], [Mode.Normal], (_vimState, _editor) => {
vscode.commands.executeCommand('cursorMove', {
to: 'viewPortBottom',
by: 'line',
});
}),
parseKeysExact(['L'], [Mode.Visual], (vimState, editor) => {
const originalSelections = editor.selections;
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortBottom',
by: 'line',
select: true,
})
.then(() => {
setVisualSelections(editor, originalSelections);
});
}),
parseKeysExact(['L'], [Mode.VisualLine], (vimState, editor) => {
vscode.commands
.executeCommand('cursorMove', {
to: 'viewPortBottom',
by: 'line',
select: true,
})
.then(() => {
setVisualLineSelections(editor);
});
}),
];
type MotionArgs = {
document: vscode.TextDocument;
position: vscode.Position;
selectionIndex: number;
vimState: HelixState;
};
type RegexMotionArgs = {
document: vscode.TextDocument;
position: vscode.Position;
selectionIndex: number;
vimState: HelixState;
match: RegExpMatchArray;
};
function execRegexMotion(
vimState: HelixState,
editor: vscode.TextEditor,
match: RegExpMatchArray,
regexMotion: (args: RegexMotionArgs) => vscode.Position,
) {
return execMotion(vimState, editor, (motionArgs) => {
return regexMotion({
...motionArgs,
match: match,
});
});
}
function execMotion(vimState: HelixState, editor: vscode.TextEditor, motion: (args: MotionArgs) => vscode.Position) {
const document = editor.document;
const newSelections = editor.selections.map((selection, i) => {
if (vimState.mode === Mode.Normal) {
const newPosition = motion({
document: document,
position: selection.active,
selectionIndex: i,
vimState: vimState,
});
return new vscode.Selection(selection.active, newPosition);
} else if (vimState.mode === Mode.Visual) {
const vimSelection = vscodeToVimVisualSelection(document, selection);
const motionPosition = motion({
document: document,
position: vimSelection.active,
selectionIndex: i,
vimState: vimState,
});
return vimToVscodeVisualSelection(document, new vscode.Selection(vimSelection.anchor, motionPosition));
} else if (vimState.mode === Mode.VisualLine) {
const vimSelection = vscodeToVimVisualLineSelection(document, selection);
const motionPosition = motion({
document: document,
position: vimSelection.active,
selectionIndex: i,
vimState: vimState,
});
return vimToVscodeVisualLineSelection(document, new vscode.Selection(vimSelection.anchor, motionPosition));
} else {
return selection;
}
});
editor.selections = newSelections;
editor.revealRange(
new vscode.Range(newSelections[0].active, newSelections[0].active),
vscode.TextEditorRevealType.InCenterIfOutsideViewport,
);
}
function findForward(vimState: HelixState, editor: vscode.TextEditor, outerMatch: RegExpMatchArray): void {
execRegexMotion(vimState, editor, outerMatch, ({ document, position, match }) => {
const fromPosition = position.with({ character: position.character + 1 });
const result = searchForward(document, match[1], fromPosition);
if (result) {
return result.with({ character: result.character + 1 });
} else {
return position;
}
});
}
function findBackward(vimState: HelixState, editor: vscode.TextEditor, outerMatch: RegExpMatchArray): void {
execRegexMotion(vimState, editor, outerMatch, ({ document, position, match }) => {
const fromPosition = positionLeftWrap(document, position);
const result = searchBackward(document, match[1], fromPosition);
if (result) {
return result;
} else {
return position;
}
});
}
function tillForward(vimState: HelixState, editor: vscode.TextEditor, outerMatch: RegExpMatchArray): void {
execRegexMotion(vimState, editor, outerMatch, ({ document, position, match }) => {
const fromPosition = position.with({ character: position.character + 1 });
const result = searchForward(document, match[1], fromPosition);
if (result) {
return result.with({ character: result.character });
} else {
return position;
}
});
}
function tillBackward(vimState: HelixState, editor: vscode.TextEditor, outerMatch: RegExpMatchArray): void {
execRegexMotion(vimState, editor, outerMatch, ({ document, position, match }) => {
const fromPosition = positionLeftWrap(document, position);
const result = searchBackward(document, match[1], fromPosition);
if (result) {
return result;
} else {
return position;
}
});
}
function positionLeftWrap(document: vscode.TextDocument, position: vscode.Position): vscode.Position {
if (position.character === 0) {
if (position.line === 0) {
return position;
} else {
const lineLength = document.lineAt(position.line - 1).text.length;
return new vscode.Position(position.line - 1, lineLength);
}
} else {
return position.with({ character: position.character - 1 });
}
}
function createWordForwardHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, editor: vscode.TextEditor) => void {
return (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
let character = position.character;
// Try the current line and if we're at the end go to the next line
// This way we're only keeping one line of text in memory at a time
// i is representing the relative line number we're on from where we started
for (let i = 0; i < document.lineCount; i++) {
const lineText = document.lineAt(position.line + i).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.find((x) => x.start > character);
if (result) {
return position.with({ character: result.start, line: position.line + i });
}
// If we don't find anything on this line, search the next and reset the character to 0
character = 0;
}
// We may be at the end of the document or nothing else matches
return position;
});
};
}
function createWordBackwardHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, editor: vscode.TextEditor) => void {
return (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
let character = position.character;
// Try the current line and if we're at the end go to the next line
// This way we're only keeping one line of text in memory at a time
// i is representing the relative line number we're on from where we started
for (let i = position.line; i >= 0; i--) {
const lineText = document.lineAt(i).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.reverse().find((x) => x.start < character);
if (result) {
return position.with({ character: result.start, line: i });
}
// If we don't find anything on this line, search the next and reset the character to 0
character = Infinity;
}
// We may be at the end of the document or nothing else matches
return position;
});
};
}
function createWordEndHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, editor: vscode.TextEditor) => void {
return (vimState, editor) => {
execMotion(vimState, editor, ({ document, position }) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.find((x) => x.end > position.character);
if (result) {
return position.with({ character: result.end });
} else {
return position;
}
});
};
}

1110
src/actions/operator.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,588 +0,0 @@
import * as vscode from 'vscode';
import { arrayFindLast } from '../array_utils';
import { blockRange } from '../block_utils';
import { HelixState } from '../helix_state_types';
import { indentLevelRange } from '../indent_utils';
import { paragraphBackward, paragraphForward, paragraphRangeInner, paragraphRangeOuter } from '../paragraph_utils';
import { createOperatorRangeExactKeys, createOperatorRangeRegex } from '../parse_keys';
import { OperatorRange } from '../parse_keys_types';
import * as positionUtils from '../position_utils';
import { findQuoteRange, quoteRanges } from '../quote_utils';
import { searchBackward, searchBackwardBracket, searchForward, searchForwardBracket } from '../search_utils';
import { getTags } from '../tag_utils';
import { whitespaceWordRanges, wordRanges } from '../word_utils';
// import KeyMap from "./keymap";
export const operatorRanges: OperatorRange[] = [
// createOperatorRangeExactKeys(
// [KeyMap.Motions.MoveRight],
// false,
// (vimState, document, position) => {
// const right = positionUtils.right(document, position);
// if (right.isEqual(position)) {
// return undefined;
// } else {
// return new vscode.Range(position, right);
// }
// }
// ),
// createOperatorRangeExactKeys(
// [KeyMap.Motions.MoveLeft],
// false,
// (vimState, document, position) => {
// const left = positionUtils.left(position);
// if (left.isEqual(position)) {
// return undefined;
// } else {
// return new vscode.Range(position, left);
// }
// }
// ),
// createOperatorRangeExactKeys(
// [KeyMap.Motions.MoveUp],
// true,
// (vimState, document, position) => {
// if (position.line === 0) {
// return new vscode.Range(
// new vscode.Position(0, 0),
// positionUtils.lineEnd(document, position)
// );
// } else {
// return new vscode.Range(
// new vscode.Position(position.line - 1, 0),
// positionUtils.lineEnd(document, position)
// );
// }
// }
// ),
// createOperatorRangeExactKeys(
// [KeyMap.Motions.MoveDown],
// true,
// (vimState, document, position) => {
// if (position.line === document.lineCount - 1) {
// return new vscode.Range(
// new vscode.Position(position.line, 0),
// positionUtils.lineEnd(document, position)
// );
// } else {
// return new vscode.Range(
// new vscode.Position(position.line, 0),
// positionUtils.lineEnd(
// document,
// position.with({ line: position.line + 1 })
// )
// );
// }
// }
// ),
createOperatorRangeExactKeys(['w'], false, createWordForwardHandler(wordRanges)),
createOperatorRangeExactKeys(['W'], false, createWordForwardHandler(whitespaceWordRanges)),
createOperatorRangeExactKeys(['b'], false, createWordBackwardHandler(wordRanges)),
createOperatorRangeExactKeys(['B'], false, createWordBackwardHandler(whitespaceWordRanges)),
createOperatorRangeExactKeys(['e'], false, createWordEndHandler(wordRanges)),
createOperatorRangeExactKeys(['E'], false, createWordEndHandler(whitespaceWordRanges)),
createOperatorRangeExactKeys(['i', 'w'], false, createInnerWordHandler(wordRanges)),
createOperatorRangeExactKeys(['i', 'W'], false, createInnerWordHandler(whitespaceWordRanges)),
createOperatorRangeExactKeys(['a', 'w'], false, createOuterWordHandler(wordRanges)),
createOperatorRangeExactKeys(['a', 'W'], false, createOuterWordHandler(whitespaceWordRanges)),
createOperatorRangeRegex(/^f(..)$/, /^(f|f.)$/, false, (vimState, document, position, match) => {
const fromPosition = position.with({ character: position.character + 1 });
const result = searchForward(document, match[1], fromPosition);
if (result) {
return new vscode.Range(position, result);
} else {
return undefined;
}
}),
createOperatorRangeRegex(/^F(..)$/, /^(F|F.)$/, false, (vimState, document, position, match) => {
const fromPosition = position.with({ character: position.character - 1 });
const result = searchBackward(document, match[1], fromPosition);
if (result) {
return new vscode.Range(position, result);
} else {
return undefined;
}
}),
createOperatorRangeRegex(/^t(.)$/, /^t$/, false, (vimState, document, position, match) => {
const lineText = document.lineAt(position.line).text;
const result = lineText.indexOf(match[1], position.character + 1);
if (result >= 0) {
return new vscode.Range(position, position.with({ character: result }));
} else {
return undefined;
}
}),
createOperatorRangeRegex(/^T(.)$/, /^T$/, false, (vimState, document, position, match) => {
const lineText = document.lineAt(position.line).text;
const result = lineText.lastIndexOf(match[1], position.character - 1);
if (result >= 0) {
const newPosition = positionUtils.right(document, position.with({ character: result }));
return new vscode.Range(newPosition, position);
} else {
return undefined;
}
}),
createOperatorRangeExactKeys(['g', 'g'], true, (vimState, document, position) => {
const lineLength = document.lineAt(position.line).text.length;
return new vscode.Range(new vscode.Position(0, 0), position.with({ character: lineLength }));
}),
createOperatorRangeExactKeys(['G'], true, (vimState, document, position) => {
const lineLength = document.lineAt(document.lineCount - 1).text.length;
return new vscode.Range(position.with({ character: 0 }), new vscode.Position(document.lineCount - 1, lineLength));
}),
// TODO: return undefined?
createOperatorRangeExactKeys(['}'], true, (vimState, document, position) => {
return new vscode.Range(
position.with({ character: 0 }),
new vscode.Position(paragraphForward(document, position.line), 0),
);
}),
// TODO: return undefined?
createOperatorRangeExactKeys(['{'], true, (vimState, document, position) => {
return new vscode.Range(
new vscode.Position(paragraphBackward(document, position.line), 0),
position.with({ character: 0 }),
);
}),
createOperatorRangeExactKeys(['i', 'p'], true, (vimState, document, position) => {
const result = paragraphRangeInner(document, position.line);
if (result) {
return new vscode.Range(
new vscode.Position(result.start, 0),
new vscode.Position(result.end, document.lineAt(result.end).text.length),
);
} else {
return undefined;
}
}),
createOperatorRangeExactKeys(['a', 'p'], true, (vimState, document, position) => {
const result = paragraphRangeOuter(document, position.line);
if (result) {
return new vscode.Range(
new vscode.Position(result.start, 0),
new vscode.Position(result.end, document.lineAt(result.end).text.length),
);
} else {
return undefined;
}
}),
createOperatorRangeExactKeys(['i', "'"], false, createInnerQuoteHandler("'")),
createOperatorRangeExactKeys(['a', "'"], false, createOuterQuoteHandler("'")),
createOperatorRangeExactKeys(['i', '"'], false, createInnerQuoteHandler('"')),
createOperatorRangeExactKeys(['a', '"'], false, createOuterQuoteHandler('"')),
createOperatorRangeExactKeys(['i', '`'], false, createInnerQuoteHandler('`')),
createOperatorRangeExactKeys(['a', '`'], false, createOuterQuoteHandler('`')),
createOperatorRangeExactKeys(['i', '('], false, createInnerBracketHandler('(', ')')),
createOperatorRangeExactKeys(['a', '('], false, createOuterBracketHandler('(', ')')),
createOperatorRangeExactKeys(['i', '{'], false, createInnerBracketHandler('{', '}')),
createOperatorRangeExactKeys(['a', '{'], false, createOuterBracketHandler('{', '}')),
createOperatorRangeExactKeys(['i', '['], false, createInnerBracketHandler('[', ']')),
createOperatorRangeExactKeys(['a', '['], false, createOuterBracketHandler('[', ']')),
createOperatorRangeExactKeys(['i', '<'], false, createInnerBracketHandler('<', '>')),
createOperatorRangeExactKeys(['a', '<'], false, createOuterBracketHandler('<', '>')),
createOperatorRangeExactKeys(['i', 'm'], false, createInnerMatchHandler()),
createOperatorRangeExactKeys(['a', 'm'], false, createOuterMatchHandler()),
createOperatorRangeExactKeys(['i', 'f'], false, createInnerFunctionHandler()),
createOperatorRangeExactKeys(['a', 'f'], false, createInnerFunctionHandler()),
createOperatorRangeExactKeys(['i', 't'], false, (vimState, document, position) => {
const tags = getTags(document);
const closestTag = arrayFindLast(tags, (tag) => {
if (tag.closing) {
return position.isAfterOrEqual(tag.opening.start) && position.isBeforeOrEqual(tag.closing.end);
} else {
// Self-closing tags have no inside
return false;
}
});
if (closestTag) {
if (closestTag.closing) {
return new vscode.Range(
closestTag.opening.end.with({
character: closestTag.opening.end.character + 1,
}),
closestTag.closing.start,
);
} else {
throw new Error('We should have already filtered out self-closing tags above');
}
} else {
return undefined;
}
}),
createOperatorRangeExactKeys(['a', 't'], false, (vimState, document, position) => {
const tags = getTags(document);
const closestTag = arrayFindLast(tags, (tag) => {
const afterStart = position.isAfterOrEqual(tag.opening.start);
if (tag.closing) {
return afterStart && position.isBeforeOrEqual(tag.closing.end);
} else {
return afterStart && position.isBeforeOrEqual(tag.opening.end);
}
});
if (closestTag) {
if (closestTag.closing) {
return new vscode.Range(
closestTag.opening.start,
closestTag.closing.end.with({
character: closestTag.closing.end.character + 1,
}),
);
} else {
return new vscode.Range(
closestTag.opening.start,
closestTag.opening.end.with({
character: closestTag.opening.end.character + 1,
}),
);
}
} else {
return undefined;
}
}),
// TODO: return undefined?
createOperatorRangeExactKeys(['i', 'i'], true, (vimState, document, position) => {
const simpleRange = indentLevelRange(document, position.line);
return new vscode.Range(
new vscode.Position(simpleRange.start, 0),
new vscode.Position(simpleRange.end, document.lineAt(simpleRange.end).text.length),
);
}),
createOperatorRangeExactKeys(['a', 'b'], true, (vimState, document, position) => {
const range = blockRange(document, position);
return range;
}),
];
function createInnerBracketHandler(
openingChar: string,
closingChar: string,
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (helixState, document, position) => {
const count = helixState.resolveCount();
const bracketRange = getBracketRange(document, position, openingChar, closingChar, count);
if (bracketRange) {
return new vscode.Range(
bracketRange.start.with({
character: bracketRange.start.character + 1,
}),
bracketRange.end,
);
} else {
return undefined;
}
};
}
function createOuterBracketHandler(
openingChar: string,
closingChar: string,
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (helixState, document, position) => {
const count = helixState.resolveCount();
const bracketRange = getBracketRange(document, position, openingChar, closingChar, count);
if (bracketRange) {
return new vscode.Range(bracketRange.start, bracketRange.end.with({ character: bracketRange.end.character + 1 }));
} else {
return undefined;
}
};
}
function getBracketRange(
document: vscode.TextDocument,
position: vscode.Position,
openingChar: string,
closingChar: string,
offset?: number,
): vscode.Range | undefined {
const lineText = document.lineAt(position.line).text;
const currentChar = lineText[position.character];
let start;
let end;
if (currentChar === openingChar) {
start = position;
end = searchForwardBracket(document, openingChar, closingChar, positionUtils.rightWrap(document, position), offset);
} else if (currentChar === closingChar) {
start = searchBackwardBracket(
document,
openingChar,
closingChar,
positionUtils.leftWrap(document, position),
offset,
);
end = position;
} else {
start = searchBackwardBracket(document, openingChar, closingChar, position, offset);
end = searchForwardBracket(document, openingChar, closingChar, position, offset);
}
if (start && end) {
return new vscode.Range(start, end);
} else {
return undefined;
}
}
function createInnerQuoteHandler(
quoteChar: string,
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = quoteRanges(quoteChar, lineText);
const result = findQuoteRange(ranges, position);
if (result) {
return new vscode.Range(position.with({ character: result.start + 1 }), position.with({ character: result.end }));
} else {
return undefined;
}
};
}
function createOuterQuoteHandler(
quoteChar: string,
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = quoteRanges(quoteChar, lineText);
const result = findQuoteRange(ranges, position);
if (result) {
return new vscode.Range(position.with({ character: result.start }), position.with({ character: result.end + 1 }));
} else {
return undefined;
}
};
}
function createWordForwardHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.find((x) => x.start > position.character);
if (result) {
return new vscode.Range(position, position.with({ character: result.start }));
} else {
return new vscode.Range(position, position.with({ character: lineText.length }));
}
};
}
function createWordBackwardHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.reverse().find((x) => x.start < position.character);
if (result) {
return new vscode.Range(position.with({ character: result.start }), position);
} else {
return undefined;
}
};
}
function createWordEndHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.find((x) => x.end > position.character);
if (result) {
return new vscode.Range(position, positionUtils.right(document, position.with({ character: result.end })));
} else {
return undefined;
}
};
}
function createInnerWordHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
const result = ranges.find((x) => x.start <= position.character && position.character <= x.end);
if (result) {
return new vscode.Range(
position.with({ character: result.start }),
positionUtils.right(document, position.with({ character: result.end })),
);
} else {
return undefined;
}
};
}
function createOuterWordHandler(
wordRangesFunction: (text: string) => { start: number; end: number }[],
): (vimState: HelixState, document: vscode.TextDocument, position: vscode.Position) => vscode.Range | undefined {
return (vimState, document, position) => {
const lineText = document.lineAt(position.line).text;
const ranges = wordRangesFunction(lineText);
for (let i = 0; i < ranges.length; ++i) {
const range = ranges[i];
if (range.start <= position.character && position.character <= range.end) {
if (i < ranges.length - 1) {
return new vscode.Range(
position.with({ character: range.start }),
position.with({ character: ranges[i + 1].start }),
);
} else if (i > 0) {
return new vscode.Range(
positionUtils.right(document, position.with({ character: ranges[i - 1].end })),
positionUtils.right(document, position.with({ character: range.end })),
);
} else {
return new vscode.Range(
position.with({ character: range.start }),
positionUtils.right(document, position.with({ character: range.end })),
);
}
}
}
return undefined;
};
}
/*
* Implements going to nearest matching brackets from the cursor.
* This will need to call the other `createInnerBracketHandler` functions and get the smallest range from them.
* This should ensure that we're fetching the nearest bracket pair.
**/
function createInnerMatchHandler(): (
helixState: HelixState,
document: vscode.TextDocument,
position: vscode.Position,
) => vscode.Range | undefined {
return (helixState, document, position) => {
const count = helixState.resolveCount();
// Get all ranges from our position then reduce down to the shortest one
const bracketRange = [
getBracketRange(document, position, '(', ')', count),
getBracketRange(document, position, '{', '}', count),
getBracketRange(document, position, '<', '>', count),
getBracketRange(document, position, '[', ']', count),
].reduce((acc, range) => {
if (range) {
if (!acc) {
return range;
} else {
return range.contains(acc) ? acc : range;
}
} else {
return acc;
}
}, undefined);
return bracketRange?.with(new vscode.Position(bracketRange.start.line, bracketRange.start.character + 1));
};
}
/*
* Implements going to nearest matching brackets from the cursor.
* This will need to call the other `createInnerBracketHandler` functions and get the smallest range from them.
* This should ensure that we're fetching the nearest bracket pair.
**/
function createOuterMatchHandler(): (
vimState: HelixState,
document: vscode.TextDocument,
position: vscode.Position,
) => vscode.Range | undefined {
return (_, document, position) => {
// Get all ranges from our position then reduce down to the shortest one
const bracketRange = [
getBracketRange(document, position, '(', ')'),
getBracketRange(document, position, '{', '}'),
getBracketRange(document, position, '<', '>'),
getBracketRange(document, position, '[', ']'),
].reduce((acc, range) => {
if (range) {
if (!acc) {
return range;
} else {
return range.contains(acc) ? acc : range;
}
} else {
return acc;
}
}, undefined);
return bracketRange?.with(undefined, new vscode.Position(bracketRange.end.line, bracketRange.end.character + 1));
};
}
function createInnerFunctionHandler(): (
helixState: HelixState,
document: vscode.TextDocument,
position: vscode.Position,
) => vscode.Range | undefined {
return (helixState, _, position) => {
return helixState.symbolProvider.getContainingSymbolRange(position);
};
}

View File

@ -1,152 +0,0 @@
import * as vscode from 'vscode';
import { Action } from '../action_types';
import { HelixState } from '../helix_state_types';
import { enterNormalMode, setModeCursorStyle } from '../modes';
import { Mode } from '../modes_types';
import { parseKeysOperator } from '../parse_keys';
import { operatorRanges } from './operator_ranges';
export const operators: Action[] = [
parseKeysOperator(['d'], operatorRanges, (vimState, editor, ranges, linewise) => {
if (ranges.every((x) => x === undefined)) return;
cursorsToRangesStart(editor, ranges);
delete_(editor, ranges, linewise);
if (vimState.mode === Mode.Visual || vimState.mode === Mode.VisualLine) {
enterNormalMode(vimState);
setModeCursorStyle(vimState.mode, editor);
}
}),
// Match Mode
parseKeysOperator(['m'], operatorRanges, (vimState, editor, ranges) => {
if (ranges.every((x) => x === undefined)) {
return;
}
editor.selections = ranges.map((range, i) => {
if (range) {
const start = range.start;
const end = range.end;
return new vscode.Selection(start, end);
} else {
return editor.selections[i];
}
});
setModeCursorStyle(vimState.mode, editor);
}),
parseKeysOperator(['q'], operatorRanges, (vimState, editor, ranges, _linewise) => {
if (ranges.every((x) => x === undefined) || vimState.mode === Mode.Visual || vimState.mode === Mode.VisualLine) {
return;
}
editor.selections = ranges.map((range, i) => {
if (range) {
const start = range.start;
const end = range.end;
return new vscode.Selection(start, end);
} else {
return editor.selections[i];
}
});
vscode.commands.executeCommand('editor.action.copyLinesDownAction');
}),
];
function cursorsToRangesStart(editor: vscode.TextEditor, ranges: readonly (vscode.Range | undefined)[]) {
editor.selections = editor.selections.map((selection, i) => {
const range = ranges[i];
if (range) {
const newPosition = range.start;
return new vscode.Selection(newPosition, newPosition);
} else {
return selection;
}
});
}
export function delete_(editor: vscode.TextEditor, ranges: (vscode.Range | undefined)[], linewise: boolean) {
if (ranges.length === 1 && ranges[0] && isEmptyRange(ranges[0])) {
vscode.commands.executeCommand('deleteRight');
return;
}
editor
.edit((editBuilder) => {
ranges.forEach((range) => {
if (!range) return;
let deleteRange = range;
if (linewise) {
const start = range.start;
const end = range.end;
if (end.line === editor.document.lineCount - 1) {
if (start.line === 0) {
deleteRange = new vscode.Range(start.with({ character: 0 }), end);
} else {
deleteRange = new vscode.Range(
new vscode.Position(start.line - 1, editor.document.lineAt(start.line - 1).text.length),
end,
);
}
} else {
deleteRange = new vscode.Range(range.start, new vscode.Position(end.line + 1, 0));
}
}
editBuilder.delete(deleteRange);
});
})
.then(() => {
// For linewise deletions, make sure cursor is at beginning of line
editor.selections = editor.selections.map((selection, i) => {
const range = ranges[i];
if (range && linewise) {
const newPosition = selection.start.with({ character: 0 });
return new vscode.Selection(newPosition, newPosition);
} else {
return selection;
}
});
});
}
export function yank(
vimState: HelixState,
editor: vscode.TextEditor,
ranges: (vscode.Range | undefined)[],
linewise: boolean,
) {
vimState.registers = {
contentsList: ranges.map((range, i) => {
if (range) {
return editor.document.getText(range);
} else {
return vimState.registers.contentsList[i];
}
}),
linewise: linewise,
};
}
// detect if a range is covering just a single character
function isEmptyRange(range: vscode.Range) {
return range.start.line === range.end.line && range.start.character === range.end.character;
}
// detect if the range spans a whole line and only one line
// Theres a weird issue where the cursor jumps to the next line when doing expand line selection
// https://github.com/microsoft/vscode/issues/118015#issuecomment-854964022
export function isSingleLineRange(range: vscode.Range): boolean {
return range.start.line === range.end.line && range.start.character === 0 && range.end.character === 0;
}

View File

@ -0,0 +1,133 @@
import { TextObject } from '../../textobject/textobject';
import { RegisterAction } from '../base';
import { Mode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { IMovement, BaseMovement } from '../baseMotion';
import { configuration } from '../../configuration/configuration';
import { ChangeOperator } from '../operator';
import { WordType } from '../../textobject/word';
import { Position } from 'vscode';
abstract class CamelCaseBaseMovement extends BaseMovement {
public override doesActionApply(vimState: VimState, keysPressed: string[]) {
return configuration.camelCaseMotion.enable && super.doesActionApply(vimState, keysPressed);
}
public override couldActionApply(vimState: VimState, keysPressed: string[]) {
return configuration.camelCaseMotion.enable && super.couldActionApply(vimState, keysPressed);
}
}
abstract class CamelCaseTextObjectMovement extends TextObject {
public override doesActionApply(vimState: VimState, keysPressed: string[]) {
return configuration.camelCaseMotion.enable && super.doesActionApply(vimState, keysPressed);
}
public override couldActionApply(vimState: VimState, keysPressed: string[]) {
return configuration.camelCaseMotion.enable && super.couldActionApply(vimState, keysPressed);
}
}
// based off of `MoveWordBegin`
@RegisterAction
class MoveCamelCaseWordBegin extends CamelCaseBaseMovement {
keys = ['<leader>', 'w'];
public override async execAction(position: Position, vimState: VimState): Promise<Position> {
if (
!configuration.changeWordIncludesWhitespace &&
vimState.recordedState.operator instanceof ChangeOperator
) {
// TODO use execForOperator? Or maybe dont?
// See note for w
return position.nextWordEnd(vimState.document, { wordType: WordType.CamelCase }).getRight();
} else {
return position.nextWordStart(vimState.document, { wordType: WordType.CamelCase });
}
}
}
// based off of `MoveWordEnd`
@RegisterAction
class MoveCamelCaseWordEnd extends CamelCaseBaseMovement {
keys = ['<leader>', 'e'];
public override async execAction(position: Position, vimState: VimState): Promise<Position> {
return position.nextWordEnd(vimState.document, { wordType: WordType.CamelCase });
}
public override async execActionForOperator(
position: Position,
vimState: VimState,
): Promise<Position> {
const end = position.nextWordEnd(vimState.document, { wordType: WordType.CamelCase });
return new Position(end.line, end.character + 1);
}
}
// based off of `MoveBeginningWord`
@RegisterAction
class MoveBeginningCamelCaseWord extends CamelCaseBaseMovement {
keys = ['<leader>', 'b'];
public override async execAction(position: Position, vimState: VimState): Promise<Position> {
return position.prevWordStart(vimState.document, { wordType: WordType.CamelCase });
}
}
// based off of `SelectInnerWord`
@RegisterAction
class SelectInnerCamelCaseWord extends CamelCaseTextObjectMovement {
override modes = [Mode.Normal, Mode.Visual];
keys = ['i', '<leader>', 'w'];
public async execAction(position: Position, vimState: VimState): Promise<IMovement> {
let start: Position;
let stop: Position;
const currentChar = vimState.document.lineAt(position).text[position.character];
if (/\s/.test(currentChar)) {
start = position.prevWordEnd(vimState.document, { wordType: WordType.CamelCase }).getRight();
stop = position
.nextWordStart(vimState.document, { wordType: WordType.CamelCase })
.getLeftThroughLineBreaks();
} else {
start = position.prevWordStart(vimState.document, {
wordType: WordType.CamelCase,
inclusive: true,
});
stop = position.nextWordEnd(vimState.document, {
wordType: WordType.CamelCase,
inclusive: true,
});
}
if (
vimState.currentMode === Mode.Visual &&
!vimState.cursorStopPosition.isEqual(vimState.cursorStartPosition)
) {
start = vimState.cursorStartPosition;
if (vimState.cursorStopPosition.isBefore(vimState.cursorStartPosition)) {
// If current cursor postion is before cursor start position, we are selecting words in reverser order.
if (/\s/.test(currentChar)) {
stop = position
.prevWordEnd(vimState.document, { wordType: WordType.CamelCase })
.getRight();
} else {
stop = position.prevWordStart(vimState.document, {
wordType: WordType.CamelCase,
inclusive: true,
});
}
}
}
return {
start,
stop,
};
}
}

View File

@ -0,0 +1,377 @@
import { VimState } from '../../../state/vimState';
import { configuration } from './../../../configuration/configuration';
import { Mode, isVisualMode } from './../../../mode/mode';
import { RegisterAction, BaseCommand } from './../../base';
import { EasyMotion } from './easymotion';
import {
EasyMotionCharMoveOpions,
EasyMotionMoveOptionsBase,
EasyMotionWordMoveOpions,
EasyMotionSearchAction,
Match,
SearchOptions,
} from './types';
import { globalState } from '../../../state/globalState';
import { TextEditor } from '../../../textEditor';
import { MarkerGenerator } from './markerGenerator';
import { Position } from 'vscode';
export interface EasymotionTrigger {
key: string;
leaderCount?: number;
}
export function buildTriggerKeys(trigger: EasymotionTrigger) {
return [
...Array.from({ length: trigger.leaderCount || 2 }, () => '<leader>'),
...trigger.key.split(''),
];
}
abstract class BaseEasyMotionCommand extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
private _baseOptions: EasyMotionMoveOptionsBase;
public abstract getMatches(position: Position, vimState: VimState): Match[];
constructor(baseOptions: EasyMotionMoveOptionsBase) {
super();
this._baseOptions = baseOptions;
}
public abstract resolveMatchPosition(match: Match): Position;
public processMarkers(matches: Match[], cursorPosition: Position, vimState: VimState) {
// Clear existing markers, just in case
vimState.easyMotion.clearMarkers();
let index = 0;
const markerGenerator = new MarkerGenerator(matches.length);
for (const match of matches) {
const matchPosition = this.resolveMatchPosition(match);
// Skip if the match position equals to cursor position
if (!matchPosition.isEqual(cursorPosition)) {
const marker = markerGenerator.generateMarker(index++, matchPosition);
if (marker) {
vimState.easyMotion.addMarker(marker);
}
}
}
}
protected searchOptions(position: Position): SearchOptions {
switch (this._baseOptions.searchOptions) {
case 'min':
return { min: position };
case 'max':
return { max: position };
default:
return {};
}
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// Only execute the action if the configuration is set
if (configuration.easymotion) {
// Search all occurences of the character pressed
const matches = this.getMatches(position, vimState);
// Stop if there are no matches
if (matches.length > 0) {
vimState.easyMotion = new EasyMotion();
this.processMarkers(matches, position, vimState);
if (matches.length === 1) {
// Only one found, navigate to it
const marker = vimState.easyMotion.markers[0];
// Set cursor position based on marker entered
vimState.cursorStopPosition = marker.position;
vimState.easyMotion.clearDecorations(vimState.editor);
} else {
// Store mode to return to after performing easy motion
vimState.easyMotion.previousMode = vimState.currentMode;
// Enter the EasyMotion mode and await further keys
await vimState.setCurrentMode(Mode.EasyMotionMode);
}
}
}
}
}
function getMatchesForString(
position: Position,
vimState: VimState,
searchString: string,
options?: SearchOptions,
): Match[] {
switch (searchString) {
case '':
return [];
case ' ':
// Searching for space should only find the first space
return vimState.easyMotion.sortedSearch(
vimState.document,
position,
new RegExp(' {1,}', 'g'),
options,
);
default:
// Search all occurences of the character pressed
// If the input is not a letter, treating it as regex can cause issues
if (!/[a-zA-Z]/.test(searchString)) {
return vimState.easyMotion.sortedSearch(vimState.document, position, searchString, options);
}
const ignorecase =
configuration.ignorecase && !(configuration.smartcase && /[A-Z]/.test(searchString));
const regexFlags = ignorecase ? 'gi' : 'g';
return vimState.easyMotion.sortedSearch(
vimState.document,
position,
new RegExp(searchString, regexFlags),
options,
);
}
}
export class SearchByCharCommand extends BaseEasyMotionCommand implements EasyMotionSearchAction {
keys = [];
public searchString: string = '';
private _options: EasyMotionCharMoveOpions;
get searchCharCount() {
return this._options.charCount;
}
constructor(options: EasyMotionCharMoveOpions) {
super(options);
this._options = options;
}
public getMatches(position: Position, vimState: VimState): Match[] {
return getMatchesForString(position, vimState, this.searchString, this.searchOptions(position));
}
public shouldFire() {
const charCount = this._options.charCount;
return charCount ? this.searchString.length >= charCount : true;
}
public async fire(position: Position, vimState: VimState): Promise<void> {
await this.exec(position, vimState);
}
public resolveMatchPosition(match: Match): Position {
const { line, character } = match.position;
switch (this._options.labelPosition) {
case 'after':
return new Position(line, character + this._options.charCount);
case 'before':
return new Position(line, Math.max(0, character - 1));
default:
return match.position;
}
}
}
export class SearchByNCharCommand extends BaseEasyMotionCommand implements EasyMotionSearchAction {
keys = [];
public searchString: string = '';
get searchCharCount() {
return -1;
}
constructor() {
super({});
}
public resolveMatchPosition(match: Match): Position {
return match.position;
}
public getMatches(position: Position, vimState: VimState): Match[] {
return getMatchesForString(
position,
vimState,
this.removeTrailingLineBreak(this.searchString),
{},
);
}
private removeTrailingLineBreak(s: string) {
return s.replace(new RegExp('\n+$', 'g'), '');
}
public shouldFire() {
// Fire when <CR> typed
return this.searchString.endsWith('\n');
}
public async fire(position: Position, vimState: VimState): Promise<void> {
if (this.removeTrailingLineBreak(this.searchString) !== '') {
await this.exec(position, vimState);
}
}
}
export abstract class EasyMotionCharMoveCommandBase extends BaseCommand {
modes = [Mode.Normal, Mode.Visual, Mode.VisualLine, Mode.VisualBlock];
private _action: EasyMotionSearchAction;
constructor(action: EasyMotionSearchAction) {
super();
this._action = action;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
// Only execute the action if easymotion is enabled
if (configuration.easymotion) {
vimState.easyMotion = new EasyMotion();
vimState.easyMotion.previousMode = vimState.currentMode;
vimState.easyMotion.searchAction = this._action;
globalState.hl = true;
await vimState.setCurrentMode(Mode.EasyMotionInputMode);
}
}
}
export abstract class EasyMotionWordMoveCommandBase extends BaseEasyMotionCommand {
private _options: EasyMotionWordMoveOpions;
constructor(options: EasyMotionWordMoveOpions = {}) {
super(options);
this._options = options;
}
public getMatches(position: Position, vimState: VimState): Match[] {
return this.getMatchesForWord(position, vimState, this.searchOptions(position));
}
public resolveMatchPosition(match: Match): Position {
const { line, character } = match.position;
switch (this._options.labelPosition) {
case 'after':
return new Position(line, character + match.text.length - 1);
default:
return match.position;
}
}
private getMatchesForWord(
position: Position,
vimState: VimState,
options?: SearchOptions,
): Match[] {
const regex = this._options.jumpToAnywhere
? new RegExp(configuration.easymotionJumpToAnywhereRegex, 'g')
: new RegExp('\\w{1,}', 'g');
return vimState.easyMotion.sortedSearch(vimState.document, position, regex, options);
}
}
export abstract class EasyMotionLineMoveCommandBase extends BaseEasyMotionCommand {
private _options: EasyMotionMoveOptionsBase;
constructor(options: EasyMotionMoveOptionsBase = {}) {
super(options);
this._options = options;
}
public resolveMatchPosition(match: Match): Position {
return match.position;
}
public getMatches(position: Position, vimState: VimState): Match[] {
return this.getMatchesForLineStart(position, vimState, this.searchOptions(position));
}
private getMatchesForLineStart(
position: Position,
vimState: VimState,
options?: SearchOptions,
): Match[] {
// Search for the beginning of all non whitespace chars on each line before the cursor
const matches = vimState.easyMotion.sortedSearch(
vimState.document,
position,
new RegExp('^.', 'gm'),
options,
);
for (const match of matches) {
match.position = TextEditor.getFirstNonWhitespaceCharOnLine(
vimState.document,
match.position.line,
);
}
return matches;
}
}
@RegisterAction
class EasyMotionCharInputMode extends BaseCommand {
modes = [Mode.EasyMotionInputMode];
keys = ['<character>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
const key = this.keysPressed[0];
const action = vimState.easyMotion.searchAction;
action.searchString =
key === '<BS>' || key === '<S-BS>'
? action.searchString.slice(0, -1)
: action.searchString + key;
if (action.shouldFire()) {
// Skip Easymotion input mode to make sure not to back to it
await vimState.setCurrentMode(vimState.easyMotion.previousMode);
await action.fire(vimState.cursorStopPosition, vimState);
}
}
}
@RegisterAction
class CommandEscEasyMotionCharInputMode extends BaseCommand {
modes = [Mode.EasyMotionInputMode];
keys = ['<Esc>'];
public override async exec(position: Position, vimState: VimState): Promise<void> {
await vimState.setCurrentMode(Mode.Normal);
}
}
@RegisterAction
class MoveEasyMotion extends BaseCommand {
modes = [Mode.EasyMotionMode];
keys = ['<character>'];
override isJump = true;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const key = this.keysPressed[0];
if (key) {
// "nail" refers to the accumulated depth keys
const nail = vimState.easyMotion.accumulation + key;
vimState.easyMotion.accumulation = nail;
// Find markers starting with "nail"
const markers = vimState.easyMotion.findMarkers(nail, true);
if (markers.length === 1) {
// Only one found, navigate to it
const marker = markers[0];
vimState.easyMotion.clearDecorations(vimState.editor);
// Restore the mode from before easy motion
await vimState.setCurrentMode(vimState.easyMotion.previousMode);
// Set cursor position based on marker entered
vimState.cursorStopPosition = marker.position;
} else if (markers.length === 0) {
// None found, exit mode
vimState.easyMotion.clearDecorations(vimState.editor);
await vimState.setCurrentMode(vimState.easyMotion.previousMode);
}
}
}
}

View File

@ -0,0 +1,427 @@
import * as vscode from 'vscode';
import { Position } from 'vscode';
import { Mode } from '../../../mode/mode';
import { configuration } from './../../../configuration/configuration';
import { TextEditor } from './../../../textEditor';
import { EasyMotionSearchAction, IEasyMotion, Marker, Match, SearchOptions } from './types';
export class EasyMotion implements IEasyMotion {
/**
* Refers to the accumulated keys for depth navigation
*/
public accumulation = '';
// TODO: is this actually always set?
public searchAction!: EasyMotionSearchAction;
/**
* Array of all markers and decorations
*/
public readonly markers: Marker[];
private visibleMarkers: Marker[]; // Array of currently showing markers
private decorations: vscode.DecorationOptions[][];
private static readonly fade = vscode.window.createTextEditorDecorationType({
color: configuration.easymotionDimColor,
});
private static readonly hide = vscode.window.createTextEditorDecorationType({
color: 'transparent',
});
/**
* TODO: For future motions
*/
private static specialCharactersRegex: RegExp = /[\-\[\]{}()*+?.,\\\^$|#\s]/g;
/**
* Caches for decorations
*/
private static decorationTypeCache: vscode.TextEditorDecorationType[] = [];
/**
* Mode to return to after attempting easymotion
*/
// TODO: make this optional (in some circumstances it isn't actually set)
public previousMode!: Mode;
constructor() {
this.markers = [];
this.visibleMarkers = [];
this.decorations = [];
}
/**
* Create and cache decoration types for different marker lengths
*/
public static getDecorationType(
length: number,
decorations?: vscode.DecorationRenderOptions,
): vscode.TextEditorDecorationType {
const cache = this.decorationTypeCache[length];
if (cache) {
return cache;
} else {
const type = vscode.window.createTextEditorDecorationType(decorations || {});
this.decorationTypeCache[length] = type;
return type;
}
}
/**
* Clear all decorations
*/
public clearDecorations(editor: vscode.TextEditor) {
for (let i = 1; i <= this.decorations.length; i++) {
editor.setDecorations(EasyMotion.getDecorationType(i), []);
}
editor.setDecorations(EasyMotion.fade, []);
editor.setDecorations(EasyMotion.hide, []);
}
/**
* Clear all markers
*/
public clearMarkers() {
while (this.markers.length) {
this.markers.pop();
}
this.visibleMarkers = [];
}
public addMarker(marker: Marker) {
this.markers.push(marker);
}
/**
* Find markers beginning with a string
*/
public findMarkers(nail: string, onlyVisible: boolean): Marker[] {
const markers = onlyVisible ? this.visibleMarkers : this.markers;
return markers.filter((marker) => marker.name.startsWith(nail));
}
/**
* Search and sort using the index of a match compared to the index of position (usually the cursor)
*/
public sortedSearch(
document: vscode.TextDocument,
position: Position,
search: string | RegExp = '',
options: SearchOptions = {},
): Match[] {
const regex =
typeof search === 'string'
? new RegExp(search.replace(EasyMotion.specialCharactersRegex, '\\$&'), 'g')
: search;
const matches: Match[] = [];
// Cursor index refers to the index of the marker that is on or to the right of the cursor
let cursorIndex = position.character;
let prevMatch: Match | undefined;
// Calculate the min/max bounds for the search
const lineCount = document.lineCount;
const lineMin = options.min ? Math.max(options.min.line, 0) : 0;
const lineMax = options.max ? Math.min(options.max.line + 1, lineCount) : lineCount;
outer: for (let lineIdx = lineMin; lineIdx < lineMax; lineIdx++) {
const line = document.lineAt(lineIdx).text;
let result = regex.exec(line);
while (result) {
if (matches.length >= 1000) {
break outer;
} else {
const pos = new Position(lineIdx, result.index);
// Check if match is within bounds
if (
(options.min && pos.isBefore(options.min)) ||
(options.max && pos.isAfter(options.max)) ||
Math.abs(pos.line - position.line) > 100
) {
// Stop searching after 100 lines in both directions
result = regex.exec(line);
} else {
// Update cursor index to the marker on the right side of the cursor
if (!prevMatch || prevMatch.position.isBefore(position)) {
cursorIndex = matches.length;
}
// Matches on the cursor position should be ignored
if (pos.isEqual(position)) {
result = regex.exec(line);
} else {
prevMatch = new Match(pos, result[0], matches.length);
matches.push(prevMatch);
result = regex.exec(line);
}
}
}
}
}
// Sort by the index distance from the cursor index
matches.sort((a: Match, b: Match): number => {
const computeAboluteDiff = (matchIndex: number) => {
const absDiff = Math.abs(cursorIndex - matchIndex);
// Prioritize the matches on the right side of the cursor index
return matchIndex < cursorIndex ? absDiff - 0.5 : absDiff;
};
const absDiffA = computeAboluteDiff(a.index);
const absDiffB = computeAboluteDiff(b.index);
return absDiffA - absDiffB;
});
return matches;
}
private getMarkerColor(
customizedValue: string,
themeColorId: string,
): string | vscode.ThemeColor {
if (customizedValue) {
return customizedValue;
} else if (!themeColorId.startsWith('#')) {
return new vscode.ThemeColor(themeColorId);
} else {
return themeColorId;
}
}
private getEasymotionMarkerBackgroundColor() {
return this.getMarkerColor(configuration.easymotionMarkerBackgroundColor, '#0000');
}
private getEasymotionMarkerForegroundColorOneChar() {
return this.getMarkerColor(configuration.easymotionMarkerForegroundColorOneChar, '#ff0000');
}
private getEasymotionMarkerForegroundColorTwoCharFirst() {
return this.getMarkerColor(
configuration.easymotionMarkerForegroundColorTwoCharFirst,
'#ffb400',
);
}
private getEasymotionMarkerForegroundColorTwoCharSecond() {
return this.getMarkerColor(
configuration.easymotionMarkerForegroundColorTwoCharSecond,
'#b98300',
);
}
private getEasymotionDimColor() {
return this.getMarkerColor(configuration.easymotionDimColor, '#777777');
}
public updateDecorations(editor: vscode.TextEditor) {
this.clearDecorations(editor);
this.visibleMarkers = [];
this.decorations = [];
// Set the decorations for all the different marker lengths
const dimmingZones: vscode.DecorationOptions[] = [];
const dimmingRenderOptions: vscode.ThemableDecorationRenderOptions = {
// we update the color here again in case the configuration has changed
color: this.getEasymotionDimColor(),
};
// Why this instead of `background-color` on the marker?
// The easy fix would've been to let the user set the marker background to the same
// color as the editor so it would hide the character behind, However this would require
// the user to do more work, with this solution we temporarily hide the marked character
// so no user specific setting is needed
const hiddenChars: vscode.Range[] = [];
const markers = this.markers
.filter((m) => m.name.startsWith(this.accumulation))
.sort((a, b) => (a.position.isBefore(b.position) ? -1 : 1));
// Ignore markers that do not start with the accumulated depth level
for (const marker of markers) {
const pos = marker.position;
// Get keys after the depth we're at
const keystroke = marker.name.substr(this.accumulation.length);
if (!this.decorations[keystroke.length]) {
this.decorations[keystroke.length] = [];
}
// #region Hack (remove once backend handles this)
/*
This hack is here because the backend for easy motion reports two adjacent
2 char markers resulting in a 4 char wide markers, this isn't what happens in
original easymotion for instance: for doom
- original reports d[m][m2]m where [m] is a marker and [m2] is secondary
- here it reports d[m][m][m][m]m
The reason this won't work with current impl is that it overflows resulting in
one extra hidden character, hence the check below (until backend truely mimics original)
if two consecutive 2 char markers, we only use the first char from the current marker
and reduce the char substitution by 1. Once backend properly reports adjacent markers
all instances of `trim` can be removed
*/
let trim = 0;
const next = markers[markers.indexOf(marker) + 1];
if (
next &&
next.position.character - pos.character === 1 &&
next.position.line === pos.line
) {
const nextKeystroke = next.name.substr(this.accumulation.length);
if (keystroke.length > 1 && nextKeystroke.length > 1) {
trim = -1;
}
}
// #endregion
// First Char/One Char decoration
const firstCharFontColor =
keystroke.length > 1
? this.getEasymotionMarkerForegroundColorTwoCharFirst()
: this.getEasymotionMarkerForegroundColorOneChar();
const backgroundColor = this.getEasymotionMarkerBackgroundColor();
const firstCharRange = new vscode.Range(pos.line, pos.character, pos.line, pos.character);
const firstCharRenderOptions: vscode.ThemableDecorationInstanceRenderOptions = {
before: {
contentText: keystroke.substring(0, 1),
backgroundColor,
color: firstCharFontColor,
margin: `0 -1ch 0 0;
position: absolute;
font-weight: ${configuration.easymotionMarkerFontWeight};`,
height: '100%',
},
};
this.decorations[keystroke.length].push({
range: firstCharRange,
renderOptions: {
dark: firstCharRenderOptions,
light: firstCharRenderOptions,
},
});
// Second Char decoration
if (keystroke.length + trim > 1) {
const secondCharFontColor = this.getEasymotionMarkerForegroundColorTwoCharSecond();
const secondCharRange = new vscode.Range(
pos.line,
pos.character + 1,
pos.line,
pos.character + 1,
);
const secondCharRenderOptions: vscode.ThemableDecorationInstanceRenderOptions = {
before: {
contentText: keystroke.slice(1),
backgroundColor,
color: secondCharFontColor,
margin: `0 -1ch 0 0;
position: absolute;
font-weight: ${configuration.easymotionMarkerFontWeight};`,
height: '100%',
},
};
this.decorations[keystroke.length].push({
range: secondCharRange,
renderOptions: {
dark: secondCharRenderOptions,
light: secondCharRenderOptions,
},
});
}
hiddenChars.push(
new vscode.Range(
pos.line,
pos.character,
pos.line,
pos.character + keystroke.length + trim,
),
);
if (configuration.easymotionDimBackground) {
// This excludes markers from the dimming ranges by using them as anchors
// each marker adds the range between it and previous marker to the dimming zone
// except last marker after which the rest of document is dimmed
//
// example [m1] text that has multiple [m2] marks
// |<------ |<---------------------- ---->|
if (dimmingZones.length === 0) {
dimmingZones.push({
range: new vscode.Range(0, 0, pos.line, pos.character),
renderOptions: dimmingRenderOptions,
});
} else {
const prevMarker = markers[markers.indexOf(marker) - 1];
const prevKeystroke = prevMarker.name.substring(this.accumulation.length);
const prevDimPos = prevMarker.position;
const offsetPrevDimPos = prevDimPos.withColumn(
prevDimPos.character + prevKeystroke.length,
);
// Don't create dimming ranges in between consecutive markers (the 'after' is in the cases
// where you have 2 char consecutive markers where the first one only shows the first char.
// since we don't take that into account when creating 'offsetPrevDimPos' it will be after
// the current marker position which means we are in the middle of two consecutive markers.
// See the hack region above.)
if (!offsetPrevDimPos.isAfterOrEqual(pos)) {
dimmingZones.push({
range: new vscode.Range(
offsetPrevDimPos.line,
offsetPrevDimPos.character,
pos.line,
pos.character,
),
renderOptions: dimmingRenderOptions,
});
}
}
}
this.visibleMarkers.push(marker);
}
// for the last marker dim till document end
if (configuration.easymotionDimBackground && markers.length > 0) {
const prevMarker = markers[markers.length - 1];
const prevKeystroke = prevMarker.name.substring(this.accumulation.length);
const prevDimPos = dimmingZones[dimmingZones.length - 1].range.end;
const offsetPrevDimPos = prevDimPos.withColumn(prevDimPos.character + prevKeystroke.length);
// Don't create any more dimming ranges when the last marker is at document end
if (!offsetPrevDimPos.isEqual(TextEditor.getDocumentEnd(editor.document))) {
dimmingZones.push({
range: new vscode.Range(
offsetPrevDimPos,
new Position(editor.document.lineCount, Number.MAX_VALUE),
),
renderOptions: dimmingRenderOptions,
});
}
}
for (let j = 1; j < this.decorations.length; j++) {
if (this.decorations[j]) {
editor.setDecorations(EasyMotion.getDecorationType(j), this.decorations[j]);
}
}
editor.setDecorations(EasyMotion.hide, hiddenChars);
if (configuration.easymotionDimBackground) {
editor.setDecorations(EasyMotion.fade, dimmingZones);
}
}
}

View File

@ -0,0 +1,58 @@
import { Position } from 'vscode';
import { configuration } from './../../../configuration/configuration';
import { Marker } from './types';
export class MarkerGenerator {
private matchesCount: number;
private keyTable: string[];
private prefixKeyTable: string[];
constructor(matchesCount: number) {
this.matchesCount = matchesCount;
this.keyTable = this.getKeyTable();
this.prefixKeyTable = this.createPrefixKeyTable();
}
public generateMarker(index: number, markerPosition: Position): Marker | null {
const { keyTable, prefixKeyTable } = this;
if (index >= keyTable.length - prefixKeyTable.length) {
const remainder = index - (keyTable.length - prefixKeyTable.length);
const currentStep = Math.floor(remainder / keyTable.length) + 1;
if (currentStep > prefixKeyTable.length) {
return null;
} else {
const prefix = prefixKeyTable[currentStep - 1];
const label = keyTable[remainder % keyTable.length];
return {
name: prefix + label,
position: markerPosition,
};
}
} else {
return {
name: keyTable[index],
position: markerPosition,
};
}
}
private createPrefixKeyTable(): string[] {
const totalRemainder = Math.max(this.matchesCount - this.keyTable.length, 0);
const totalSteps = Math.ceil(totalRemainder / this.keyTable.length);
const reversed = this.keyTable.slice().reverse();
const count = Math.min(totalSteps, reversed.length);
return reversed.slice(0, count);
}
/**
* The key sequence for marker name generation
*/
private getKeyTable(): string[] {
if (configuration.easymotionKeys) {
return configuration.easymotionKeys.split('');
} else {
return 'hklyuiopnm,qwertzxcvbasdgjf;'.split('');
}
}
}

View File

@ -0,0 +1,246 @@
import { RegisterAction } from './../../base';
import {
buildTriggerKeys,
EasyMotionCharMoveCommandBase,
EasyMotionLineMoveCommandBase,
EasyMotionWordMoveCommandBase,
SearchByCharCommand,
SearchByNCharCommand,
} from './easymotion.cmd';
// EasyMotion n-char-move command
@RegisterAction
class EasyMotionNCharSearchCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '/' });
constructor() {
super(new SearchByNCharCommand());
}
}
// EasyMotion char-move commands
@RegisterAction
class EasyMotionTwoCharSearchCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '2s' });
constructor() {
super(new SearchByCharCommand({ charCount: 2 }));
}
}
@RegisterAction
class EasyMotionTwoCharFindForwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '2f' });
constructor() {
super(new SearchByCharCommand({ charCount: 2, searchOptions: 'min' }));
}
}
@RegisterAction
class EasyMotionTwoCharFindBackwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '2F' });
constructor() {
super(new SearchByCharCommand({ charCount: 2, searchOptions: 'max' }));
}
}
@RegisterAction
class EasyMotionTwoCharTilCharacterForwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '2t' });
constructor() {
super(new SearchByCharCommand({ charCount: 2, searchOptions: 'min', labelPosition: 'before' }));
}
}
// easymotion-bd-t2
@RegisterAction
class EasyMotionTwoCharTilCharacterBidirectionalCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 'bd2t', leaderCount: 3 });
constructor() {
super(new SearchByCharCommand({ charCount: 2, labelPosition: 'before' }));
}
}
@RegisterAction
class EasyMotionTwoCharTilBackwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: '2T' });
constructor() {
super(new SearchByCharCommand({ charCount: 2, searchOptions: 'max', labelPosition: 'after' }));
}
}
@RegisterAction
class EasyMotionSearchCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 's' });
constructor() {
super(new SearchByCharCommand({ charCount: 1 }));
}
}
@RegisterAction
class EasyMotionFindForwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 'f' });
constructor() {
super(new SearchByCharCommand({ charCount: 1, searchOptions: 'min' }));
}
}
@RegisterAction
class EasyMotionFindBackwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 'F' });
constructor() {
super(new SearchByCharCommand({ charCount: 1, searchOptions: 'max' }));
}
}
@RegisterAction
class EasyMotionTilCharacterForwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 't' });
constructor() {
super(new SearchByCharCommand({ charCount: 1, searchOptions: 'min', labelPosition: 'before' }));
}
}
// easymotion-bd-t
@RegisterAction
class EasyMotionTilCharacterBidirectionalCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 'bdt', leaderCount: 3 });
constructor() {
super(new SearchByCharCommand({ charCount: 1, labelPosition: 'before' }));
}
}
@RegisterAction
class EasyMotionTilBackwardCommand extends EasyMotionCharMoveCommandBase {
keys = buildTriggerKeys({ key: 'T' });
constructor() {
super(new SearchByCharCommand({ charCount: 1, searchOptions: 'max', labelPosition: 'after' }));
}
}
// EasyMotion word-move commands
@RegisterAction
class EasyMotionStartOfWordForwardsCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'w' });
constructor() {
super({ searchOptions: 'min' });
}
}
// easymotion-bd-w
@RegisterAction
class EasyMotionStartOfWordBidirectionalCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'bdw', leaderCount: 3 });
}
@RegisterAction
class EasyMotionLineForward extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'l' });
constructor() {
super({ jumpToAnywhere: true, searchOptions: 'min', labelPosition: 'after' });
}
}
@RegisterAction
class EasyMotionLineBackward extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'h' });
constructor() {
super({ jumpToAnywhere: true, searchOptions: 'max', labelPosition: 'after' });
}
}
// easymotion "JumpToAnywhere" motion
@RegisterAction
class EasyMotionJumpToAnywhereCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'j', leaderCount: 3 });
constructor() {
super({ jumpToAnywhere: true, labelPosition: 'after' });
}
}
@RegisterAction
class EasyMotionEndOfWordForwardsCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'e' });
constructor() {
super({ searchOptions: 'min', labelPosition: 'after' });
}
}
// easymotion-bd-e
@RegisterAction
class EasyMotionEndOfWordBidirectionalCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'bde', leaderCount: 3 });
constructor() {
super({ labelPosition: 'after' });
}
}
@RegisterAction
class EasyMotionBeginningWordCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'b' });
constructor() {
super({ searchOptions: 'max' });
}
}
@RegisterAction
class EasyMotionEndBackwardCommand extends EasyMotionWordMoveCommandBase {
keys = buildTriggerKeys({ key: 'ge' });
constructor() {
super({ searchOptions: 'max', labelPosition: 'after' });
}
}
// EasyMotion line-move commands
@RegisterAction
class EasyMotionStartOfLineForwardsCommand extends EasyMotionLineMoveCommandBase {
keys = buildTriggerKeys({ key: 'j' });
constructor() {
super({ searchOptions: 'min' });
}
}
@RegisterAction
class EasyMotionStartOfLineBackwordsCommand extends EasyMotionLineMoveCommandBase {
keys = buildTriggerKeys({ key: 'k' });
constructor() {
super({ searchOptions: 'max' });
}
}
// easymotion-bd-jk
@RegisterAction
class EasyMotionStartOfLineBidirectionalCommand extends EasyMotionLineMoveCommandBase {
keys = buildTriggerKeys({ key: 'bdjk', leaderCount: 3 });
}

View File

@ -0,0 +1,89 @@
import * as vscode from 'vscode';
import { Position } from 'vscode';
import { Mode } from '../../../mode/mode';
import type { VimState } from '../../../state/vimState';
export type LabelPosition = 'after' | 'before';
export type JumpToAnywhere = true | false;
export interface EasyMotionMoveOptionsBase {
searchOptions?: 'min' | 'max';
}
export interface EasyMotionCharMoveOpions extends EasyMotionMoveOptionsBase {
charCount: number;
labelPosition?: LabelPosition;
}
export interface EasyMotionWordMoveOpions extends EasyMotionMoveOptionsBase {
labelPosition?: LabelPosition;
jumpToAnywhere?: JumpToAnywhere;
}
export interface Marker {
name: string;
position: Position;
}
export class Match {
public position: Position;
public readonly text: string;
public readonly index: number;
constructor(position: Position, text: string, index: number) {
this.position = position;
this.text = text;
this.index = index;
}
public toRange(): vscode.Range {
return new vscode.Range(this.position, this.position.translate(0, this.text.length));
}
}
export interface SearchOptions {
/**
* The minimum bound of the search
*/
min?: Position;
/**
* The maximum bound of the search
*/
max?: Position;
}
export interface EasyMotionSearchAction {
searchString: string;
/**
* True if it should go to Easymotion mode
*/
shouldFire(): boolean;
/**
* Command to execute when it should fire
*/
fire(position: Position, vimState: VimState): Promise<void>;
getMatches(position: Position, vimState: VimState): Match[];
readonly searchCharCount: number;
}
export interface IEasyMotion {
accumulation: string;
previousMode: Mode;
markers: Marker[];
searchAction: EasyMotionSearchAction;
addMarker(marker: Marker): void;
findMarkers(nail: string, onlyVisible: boolean): Marker[];
sortedSearch(
document: vscode.TextDocument,
position: Position,
search?: string | RegExp,
options?: SearchOptions,
): Match[];
updateDecorations(editor: vscode.TextEditor): void;
clearMarkers(): void;
clearDecorations(editor: vscode.TextEditor): void;
}

View File

@ -0,0 +1,92 @@
import { Logger } from '../../util/logger';
import { Mode } from '../../mode/mode';
import { configuration } from '../../configuration/configuration';
import { exec } from 'child_process';
/**
* This function executes a shell command and returns the standard output as a string.
*/
function executeShell(cmd: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
try {
exec(cmd, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve(stdout);
}
});
} catch (error) {
reject(error);
}
});
}
/**
* InputMethodSwitcher changes input method when mode changed
*/
export class InputMethodSwitcher {
private execute: (cmd: string) => Promise<string>;
private savedIMKey = '';
constructor(execute: (cmd: string) => Promise<string> = executeShell) {
this.execute = execute;
}
public async switchInputMethod(prevMode: Mode, newMode: Mode) {
if (configuration.autoSwitchInputMethod.enable !== true) {
return;
}
// when you exit from insert-like mode, save origin input method and set it to default
const isPrevModeInsertLike = this.isInsertLikeMode(prevMode);
const isNewModeInsertLike = this.isInsertLikeMode(newMode);
if (isPrevModeInsertLike !== isNewModeInsertLike) {
if (isNewModeInsertLike) {
await this.resumeIM();
} else {
await this.switchToDefaultIM();
}
}
}
// save origin input method and set input method to default
private async switchToDefaultIM() {
const obtainIMCmd = configuration.autoSwitchInputMethod.obtainIMCmd;
try {
const insertIMKey = await this.execute(obtainIMCmd);
if (insertIMKey !== undefined) {
this.savedIMKey = insertIMKey.trim();
}
} catch (e) {
Logger.error(`Error switching to default IM. err=${e}`);
}
const defaultIMKey = configuration.autoSwitchInputMethod.defaultIM;
if (defaultIMKey !== this.savedIMKey) {
await this.switchToIM(defaultIMKey);
}
}
// resume origin inputmethod
private async resumeIM() {
if (this.savedIMKey !== configuration.autoSwitchInputMethod.defaultIM) {
await this.switchToIM(this.savedIMKey);
}
}
private async switchToIM(imKey: string) {
let switchIMCmd = configuration.autoSwitchInputMethod.switchIMCmd;
if (imKey !== '' && imKey !== undefined) {
switchIMCmd = switchIMCmd.replace('{im}', imKey);
try {
await this.execute(switchIMCmd);
} catch (e) {
Logger.error(`Error switching to IM. err=${e}`);
}
}
}
private isInsertLikeMode(mode: Mode): boolean {
return [Mode.Insert, Mode.Replace, Mode.SurroundInputMode].includes(mode);
}
}

View File

@ -0,0 +1,38 @@
import { IConfiguration, IKeyRemapping } from '../../configuration/iconfiguration';
export class PluginDefaultMappings {
// plugin authers may add entries here
private static defaultMappings: Array<{
mode: string;
configSwitch: string;
mapping: IKeyRemapping;
}> = [
// default maps for surround
{
mode: 'normalModeKeyBindingsNonRecursive',
configSwitch: 'surround',
mapping: { before: ['y', 's'], after: ['<plugys>'] },
},
{
mode: 'normalModeKeyBindingsNonRecursive',
configSwitch: 'surround',
mapping: { before: ['y', 's', 's'], after: ['<plugys>', '<plugys>'] },
},
{
mode: 'normalModeKeyBindingsNonRecursive',
configSwitch: 'surround',
mapping: { before: ['c', 's'], after: ['<plugcs>'] },
},
{
mode: 'normalModeKeyBindingsNonRecursive',
configSwitch: 'surround',
mapping: { before: ['d', 's'], after: ['<plugds>'] },
},
];
public static getPluginDefaultMappings(mode: string, config: IConfiguration): IKeyRemapping[] {
return this.defaultMappings
.filter((m) => m.mode === mode && config[m.configSwitch])
.map((m) => m.mapping);
}
}

View File

@ -0,0 +1,71 @@
import { configuration } from '../../configuration/configuration';
import { Mode } from '../../mode/mode';
import { Register, RegisterMode } from '../../register/register';
import { VimState } from '../../state/vimState';
import { BaseOperator } from '../operator';
import { RegisterAction } from './../base';
import { StatusBar } from '../../statusBar';
import { VimError, ErrorCode } from '../../error';
import { Position, Range } from 'vscode';
import { PositionDiff } from '../../common/motion/position';
@RegisterAction
class ReplaceOperator extends BaseOperator {
public keys = ['g', 'r'];
public modes = [Mode.Normal, Mode.Visual, Mode.VisualLine];
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
return configuration.replaceWithRegister && super.doesActionApply(vimState, keysPressed);
}
public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean {
return configuration.replaceWithRegister && super.doesActionApply(vimState, keysPressed);
}
public async run(vimState: VimState, start: Position, end: Position): Promise<void> {
const range =
vimState.currentRegisterMode === RegisterMode.LineWise
? new Range(start.getLineBegin(), end.getLineEndIncludingEOL())
: new Range(start, end.getRight());
const register = await Register.get(vimState.recordedState.registerName, this.multicursorIndex);
if (register === undefined) {
StatusBar.displayError(
vimState,
VimError.fromCode(ErrorCode.NothingInRegister, vimState.recordedState.registerName),
);
return;
}
const replaceWith = register.text as string;
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
range,
text: replaceWith,
diff: PositionDiff.exactPosition(getCursorPosition(vimState, range, replaceWith)),
});
await vimState.setCurrentMode(Mode.Normal);
}
}
const getCursorPosition = (vimState: VimState, range: Range, replaceWith: string): Position => {
const {
recordedState: { actionKeys },
} = vimState;
const lines = replaceWith.split('\n');
const wasRunAsLineAction = actionKeys.indexOf('r') === 0 && actionKeys.length === 1; // ie. grr
const registerAndRangeAreSingleLines = lines.length === 1 && range.isSingleLine;
const singleLineAction = registerAndRangeAreSingleLines && !wasRunAsLineAction;
return singleLineAction
? cursorAtEndOfReplacement(range, replaceWith)
: cursorAtFirstNonBlankCharOfLine(range.start.line, lines[0]);
};
const cursorAtEndOfReplacement = (range: Range, replacement: string) =>
new Position(range.start.line, Math.max(0, range.start.character + replacement.length - 1));
const cursorAtFirstNonBlankCharOfLine = (line: number, text: string) =>
new Position(line, text.match(/\S/)?.index ?? 0);

View File

@ -0,0 +1,138 @@
import { VimState } from '../../state/vimState';
import { configuration } from './../../configuration/configuration';
import { RegisterAction } from './../base';
import { BaseMovement, IMovement } from '../baseMotion';
import { Position } from 'vscode';
@RegisterAction
export class SneakForward extends BaseMovement {
keys = [
['s', '<character>', '<character>'],
['z', '<character>', '<character>'],
];
override isJump = true;
public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean {
const startingLetter = vimState.recordedState.operator === undefined ? 's' : 'z';
return (
configuration.sneak &&
super.couldActionApply(vimState, keysPressed) &&
keysPressed[0] === startingLetter
);
}
public override async execAction(
position: Position,
vimState: VimState,
): Promise<Position | IMovement> {
if (!this.isRepeat) {
vimState.lastSemicolonRepeatableMovement = new SneakForward(this.keysPressed, true);
vimState.lastCommaRepeatableMovement = new SneakBackward(this.keysPressed, true);
}
if (this.keysPressed[2] === '\n') {
// Single key sneak
this.keysPressed[2] = '';
}
const searchString = this.keysPressed[1] + this.keysPressed[2];
const document = vimState.document;
const lineCount = document.lineCount;
for (let i = position.line; i < lineCount; ++i) {
const lineText = document.lineAt(i).text;
// Start searching after the current character so we don't find the same match twice
const fromIndex = i === position.line ? position.character + 1 : 0;
let matchIndex = -1;
const ignorecase =
configuration.sneakUseIgnorecaseAndSmartcase &&
configuration.ignorecase &&
!(configuration.smartcase && /[A-Z]/.test(searchString));
// Check for matches
if (ignorecase) {
matchIndex = lineText
.toLocaleLowerCase()
.indexOf(searchString.toLocaleLowerCase(), fromIndex);
} else {
matchIndex = lineText.indexOf(searchString, fromIndex);
}
if (matchIndex >= 0) {
return new Position(i, matchIndex);
}
}
return position;
}
}
@RegisterAction
export class SneakBackward extends BaseMovement {
keys = [
['S', '<character>', '<character>'],
['Z', '<character>', '<character>'],
];
override isJump = true;
public override couldActionApply(vimState: VimState, keysPressed: string[]): boolean {
const startingLetter = vimState.recordedState.operator === undefined ? 'S' : 'Z';
return (
configuration.sneak &&
super.couldActionApply(vimState, keysPressed) &&
keysPressed[0] === startingLetter
);
}
public override async execAction(
position: Position,
vimState: VimState,
): Promise<Position | IMovement> {
if (!this.isRepeat) {
vimState.lastSemicolonRepeatableMovement = new SneakBackward(this.keysPressed, true);
vimState.lastCommaRepeatableMovement = new SneakForward(this.keysPressed, true);
}
if (this.keysPressed[2] === '\n') {
// Single key sneak
this.keysPressed[2] = '';
}
const searchString = this.keysPressed[1] + this.keysPressed[2];
const document = vimState.document;
for (let i = position.line; i >= 0; --i) {
const lineText = document.lineAt(i).text;
// Start searching before the current character so we don't find the same match twice
const fromIndex = i === position.line ? position.character - 1 : +Infinity;
let matchIndex = -1;
const ignorecase =
configuration.sneakUseIgnorecaseAndSmartcase &&
configuration.ignorecase &&
!(configuration.smartcase && /[A-Z]/.test(searchString));
// Check for matches
if (ignorecase) {
matchIndex = lineText
.toLocaleLowerCase()
.lastIndexOf(searchString.toLocaleLowerCase(), fromIndex);
} else {
matchIndex = lineText.lastIndexOf(searchString, fromIndex);
}
if (matchIndex >= 0) {
return new Position(i, matchIndex);
}
}
return position;
}
}

View File

@ -0,0 +1,683 @@
import { Position, Range, window } from 'vscode';
import { VimState } from '../../state/vimState';
import {
SelectABigWord,
SelectInnerWord,
SelectWord,
TextObject,
} from '../../textobject/textobject';
import { isIMovement } from '../baseMotion';
import {
MoveAroundBacktick,
MoveAroundCaret,
MoveAroundCurlyBrace,
MoveAroundDoubleQuotes,
MoveAroundParentheses,
MoveAroundSingleQuotes,
MoveAroundSquareBracket,
MoveAroundTag,
MoveFullWordBegin,
MoveInsideCharacter,
MoveInsideTag,
MoveQuoteMatch,
MoveWordBegin,
} from '../motion';
import { PositionDiff, sorted } from './../../common/motion/position';
import { configuration } from './../../configuration/configuration';
import { Mode } from './../../mode/mode';
import { BaseCommand, RegisterAction } from './../base';
import { BaseOperator } from './../operator';
type SurroundEdge = {
leftEdge: Range;
rightEdge: Range;
/** we need to pass this with transformations */
cursorIndex: number;
/** to support changing a tag, cstt */
leftTagName?: Range;
rightTagName?: Range;
};
type TagReplacement = {
tag: string;
/** when changing tag to tag, do we keep attributes? default: yes */
keepAttributes: boolean;
};
export interface SurroundState {
/** The operator paired with the surround action. "yank" is really "add", but it uses 'y' */
operator: 'change' | 'delete' | 'yank';
/** target of surround op: X in csXy and dsX */
target: string | undefined;
/** the added surrounding, like ",',(). t = tag */
replacement: string;
/** name of tag */
tag?: TagReplacement;
/** name of function */
function?: string;
/** for visual line mode */
addNewline?: boolean;
edges: SurroundEdge[];
/** The mode before surround was triggered */
previousMode: Mode;
}
abstract class SurroundOperator extends BaseOperator {
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
return configuration.surround && super.doesActionApply(vimState, keysPressed);
}
}
@RegisterAction
class YankSurroundOperator extends SurroundOperator {
// needs: nnoremap ys <plugys>. we leave it to Remapper to figure out y vs ys.
public keys = ['<plugys>'];
public modes = [Mode.Normal];
public async run(vimState: VimState, start: Position, end: Position): Promise<void> {
// reset surround state when run for first cursor
if (!this.multicursorIndex) {
vimState.surround = {
operator: 'yank',
target: undefined,
replacement: '',
edges: [],
previousMode: vimState.currentMode,
};
}
const getYankRanges = (): SurroundEdge => {
// for special handling for w motion.
// with "|surroundme ZONK" it will jump to Z, but we just want surroundme
const endPlus1 = new Range(end.getRight(), end.getRight());
const prevWordEnd = end.getRight().prevWordEnd(vimState.document);
const endW = new Range(prevWordEnd.getRight(), prevWordEnd.getRight());
const lastMotion =
vimState.recordedState.actionsRun[vimState.recordedState.actionsRun.length - 1];
const ranWwMotion =
lastMotion instanceof MoveWordBegin ||
lastMotion instanceof MoveFullWordBegin ||
lastMotion instanceof SelectABigWord ||
lastMotion instanceof SelectWord;
const rightEdge = ranWwMotion ? endW : endPlus1;
return {
leftEdge: new Range(start, start),
rightEdge,
cursorIndex: multicursorIndex,
};
};
// then collect ranges for all cursors
const multicursorIndex = this.multicursorIndex ?? 0;
vimState.surround!.edges.push(getYankRanges());
vimState.cursorStartPosition = start;
// when called from visual operator, use end for stop to keep visual selection
vimState.cursorStopPosition = vimState.currentMode === Mode.Visual ? end : start;
await vimState.setCurrentMode(Mode.SurroundInputMode);
}
public override async runRepeat(
vimState: VimState,
position: Position,
count: number,
): Promise<void> {
// we want to act on range: first non whitespace to last non whitespace
await this.run(
vimState,
position.getLineBeginRespectingIndent(vimState.document),
position
.getDown(Math.max(0, count - 1))
.getLineEnd()
.prevWordEnd(vimState.document),
);
}
}
@RegisterAction
class CommandSurroundModeStartVisual extends SurroundOperator {
modes = [Mode.Visual];
keys = ['S'];
public async run(vimState: VimState, start: Position, end: Position): Promise<void> {
[start, end] = sorted(start, end);
await new YankSurroundOperator(this.multicursorIndex).run(vimState, start, end);
return;
}
}
@RegisterAction
class CommandSurroundModeStartVisualLine extends SurroundOperator {
modes = [Mode.VisualLine];
keys = ['S'];
public async run(vimState: VimState, start: Position, end: Position): Promise<void> {
[start, end] = sorted(start.getLineBegin(), end.getLineEnd());
// reset surround state when run for first cursor
if (!this.multicursorIndex) {
vimState.surround = {
target: undefined,
operator: 'yank',
replacement: '',
addNewline: true,
edges: [],
previousMode: vimState.currentMode,
};
}
// collect ranges for all cursors
vimState.surround?.edges.push({
leftEdge: new Range(start, start),
rightEdge: new Range(end, end),
cursorIndex: this.multicursorIndex ?? 0,
});
vimState.cursorStartPosition = start;
vimState.cursorStopPosition = end;
await vimState.setCurrentMode(Mode.SurroundInputMode);
return;
}
}
abstract class CommandSurround extends BaseCommand {
modes = [Mode.Normal];
override createsUndoPoint = true;
override runsOnceForEveryCursor() {
return true;
}
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
const target = keysPressed[keysPressed.length - 1];
return (
configuration.surround &&
super.doesActionApply(vimState, keysPressed) &&
SurroundHelper.edgePairings[target] !== undefined
);
}
}
@RegisterAction
class CommandSurroundDeleteSurround extends CommandSurround {
keys = ['<plugds>', '<any>'];
keysHasCnt = false;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const target = this.keysPressed[this.keysPressed.length - 1];
// for derived class, support ds2X
if (this.keysHasCnt) {
const cntKey = this.keysPressed[this.keysPressed.length - 2];
// eslint-disable-next-line radix
vimState.recordedState.count = parseInt(cntKey, undefined);
}
// for this operator, we set surround state and execute for each cursor one at a time
vimState.surround = {
operator: 'delete',
target,
replacement: '',
edges: [],
previousMode: Mode.Normal,
};
// we need surround state initiated for this call
const replaceRanges = await SurroundHelper.getReplaceRanges(
vimState,
position,
this.multicursorIndex ?? 0,
);
if (replaceRanges) {
vimState.surround.edges = [replaceRanges];
await SurroundHelper.ExecuteSurround(vimState);
}
}
}
@RegisterAction
class CommandSurroundDeleteSurroundCnt extends CommandSurroundDeleteSurround {
// supports cnt up to 9, should be enough
override keys = ['<plugds>', '<number>', '<any>'];
override keysHasCnt = true;
}
@RegisterAction
class CommandSurroundChangeSurround extends CommandSurround {
keys = ['<plugcs>', '<any>'];
override isCompleteAction = false;
keysHasCnt = false;
public override async exec(position: Position, vimState: VimState): Promise<void> {
const target = this.keysPressed[this.keysPressed.length - 1];
// for derived class, support ds2X
if (this.keysHasCnt) {
const cntKey = this.keysPressed[this.keysPressed.length - 2];
// eslint-disable-next-line radix
vimState.recordedState.count = parseInt(cntKey, undefined);
}
// reset surround state when run for first cursor
if (!this.multicursorIndex) {
vimState.surround = {
operator: 'change',
target,
replacement: '',
edges: [],
previousMode: Mode.Normal,
};
}
// we need state surround initiated for this call
const replaceRanges = await SurroundHelper.getReplaceRanges(
vimState,
position,
this.multicursorIndex ?? 0,
);
// collect ranges for all cursors
if (replaceRanges) {
vimState.surround!.edges.push(replaceRanges);
}
await vimState.setCurrentMode(Mode.SurroundInputMode);
}
}
@RegisterAction
class CommandSurroundChangeSurroundCnt extends CommandSurroundChangeSurround {
// supports cnt up to 9, should be enough
override keys = ['<plugcs>', '<number>', '<any>'];
override keysHasCnt = true;
}
@RegisterAction
class CommandSurroundAddSurrounding extends BaseCommand {
modes = [Mode.SurroundInputMode];
// add surrounding / read X when: ys + motion + X. or csYX
keys = ['<any>'];
override isCompleteAction = true;
override runsOnceForEveryCursor() {
return false;
}
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
const replacement = keysPressed[keysPressed.length - 1];
return (
configuration.surround &&
super.doesActionApply(vimState, keysPressed) &&
replacement !== 't' && // do not run this for surrounding with a tag
replacement !== '<' &&
replacement !== 'f' && // or for surrounding with a function
replacement !== 'F' &&
replacement !== '<C-f>'
);
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
const replacement = this.keysPressed[this.keysPressed.length - 1];
if (!vimState.surround || !SurroundHelper.edgePairings[replacement]) {
// cant surround, abort.
// this typically handles, when last keypress was wrong and not a valid surrounding
vimState.surround = undefined;
await vimState.setCurrentMode(Mode.Normal);
return;
}
vimState.surround.replacement = replacement;
await SurroundHelper.ExecuteSurround(vimState);
}
}
@RegisterAction
export class CommandSurroundAddSurroundingTag extends BaseCommand {
modes = [Mode.SurroundInputMode];
// add surrounding / read X when: ys + motion + X
keys = [['<'], ['t']];
override isCompleteAction = true;
recordedTag = ''; // to save for repeat
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (!vimState.surround) {
return;
}
vimState.surround.replacement = 't';
const tagInput =
vimState.isRunningDotCommand || vimState.isReplayingMacro
? this.recordedTag
: await this.readTag();
if (!tagInput) {
vimState.surround = undefined;
await vimState.setCurrentMode(Mode.Normal);
return;
}
// record tag for repeat. this works because recordedState will store the actual objects
this.recordedTag = tagInput;
// local helper
const checkReplaceAttributes = (tag: string) => {
return tag.substring(tag.length - 1) === '>'
? { tag: tag.substring(0, tag.length - 1), keepAttributes: false }
: { tag, keepAttributes: true };
};
// check as special case (set by >) if we want to replace the attributes on tag or keep them (default)
vimState.surround.tag = checkReplaceAttributes(tagInput);
// finally, we can exec surround
await SurroundHelper.ExecuteSurround(vimState);
}
private async readTag(): Promise<string | undefined> {
return window.showInputBox({
prompt: 'Enter tag',
ignoreFocusOut: true,
});
}
}
@RegisterAction
export class CommandSurroundAddSurroundingFunction extends BaseCommand {
modes = [Mode.SurroundInputMode];
// add surrounding / read X when: ys + motion + X
keys = [['f'], ['F'], ['<C-f>']];
override isCompleteAction = true;
recordedFunction = ''; // to save for repeat
override runsOnceForEveryCursor() {
return false;
}
public override async exec(position: Position, vimState: VimState): Promise<void> {
if (!vimState.surround) {
return;
}
// reuse the spacing logic from the parentheses
// for the right side of the replacement
vimState.surround.replacement =
this.keysPressed[this.keysPressed.length - 1] === 'F' ? '(' : ')';
const functionInput =
vimState.isRunningDotCommand || vimState.isReplayingMacro
? this.recordedFunction
: await this.readFunction();
if (!functionInput) {
vimState.surround = undefined;
await vimState.setCurrentMode(Mode.Normal);
return;
}
// record function for repeat.
this.recordedFunction = functionInput;
// format the left side of the replacement based on the key pressed
vimState.surround.function = this.formatFunction(functionInput);
await SurroundHelper.ExecuteSurround(vimState);
}
private async readFunction(): Promise<string | undefined> {
return window.showInputBox({
prompt: 'Enter function',
ignoreFocusOut: true,
});
}
private formatFunction(fn: string): string {
switch (this.keysPressed[this.keysPressed.length - 1]) {
case 'f':
return fn + '(';
case 'F':
return fn + '( ';
case '<C-f>':
default:
return '(' + fn + ' ';
}
}
}
// following are static internal helper functions
// top level helper is ExecuteSurround, which is called from exec and does the actual text transformations
class SurroundHelper {
/** a map which holds for each target key: inserted text + implementation helper */
static edgePairings: {
[key: string]: {
left: string;
right: string;
/** do we consume space on the edges? "(" vs ")" */
removeSpace: boolean;
movement: () => MoveInsideCharacter | MoveQuoteMatch | MoveAroundTag | TextObject;
/** typically to extend an inner word. with *foo*, from "foo" to "*foo*" */
extraChars?: number;
};
} = {
// helpful linter is helpful :-D
'(': {
left: '( ',
right: ' )',
removeSpace: true,
movement: () => new MoveAroundParentheses(),
},
')': { left: '(', right: ')', removeSpace: false, movement: () => new MoveAroundParentheses() },
'[': {
left: '[ ',
right: ' ]',
removeSpace: true,
movement: () => new MoveAroundSquareBracket(),
},
']': {
left: '[',
right: ']',
removeSpace: false,
movement: () => new MoveAroundSquareBracket(),
},
'{': { left: '{ ', right: ' }', removeSpace: true, movement: () => new MoveAroundCurlyBrace() },
'}': { left: '{', right: '}', removeSpace: false, movement: () => new MoveAroundCurlyBrace() },
'>': { left: '<', right: '>', removeSpace: false, movement: () => new MoveAroundCaret() },
'"': {
left: '"',
right: '"',
removeSpace: false,
movement: () => new MoveAroundDoubleQuotes(false),
},
"'": {
left: "'",
right: "'",
removeSpace: false,
movement: () => new MoveAroundSingleQuotes(false),
},
'`': {
left: '`',
right: '`',
removeSpace: false,
movement: () => new MoveAroundBacktick(false),
},
'<': { left: '', right: '', removeSpace: false, movement: () => new MoveAroundTag() },
'*': {
left: '*',
right: '*',
removeSpace: false,
movement: () => new SelectInnerWord(),
extraChars: 1,
},
// aliases
b: { left: '(', right: ')', removeSpace: false, movement: () => new MoveAroundParentheses() },
r: { left: '[', right: ']', removeSpace: false, movement: () => new MoveAroundSquareBracket() },
B: { left: '{', right: '}', removeSpace: false, movement: () => new MoveAroundCurlyBrace() },
a: { left: '<', right: '>', removeSpace: false, movement: () => new MoveAroundCaret() },
t: { left: '', right: '', removeSpace: false, movement: () => new MoveAroundTag() },
_: { left: '_', right: '_', removeSpace: false, movement: () => new SelectInnerWord() },
};
/** returns two ranges (for left and right replacement) for our surround target (X in dsX, csXy) relative to position */
public static async getReplaceRanges(
vimState: VimState,
position: Position,
multicursorIndex: number,
): Promise<SurroundEdge | undefined> {
/* so this method is a bit of a dumpster for edge cases and ugly details
the main idea is this:
1. from position, we execute a textobject movement to get the total range of our surround target
2. from there, we derive two ranges (left and right), where to apply delete/change
3. that our result to return
*/
// input verification
if (!vimState.surround || !vimState.surround.target) {
return undefined;
}
const target = this.edgePairings[vimState.surround.target];
if (!target) {
return undefined;
}
// we want start, end of executing movement for surround target count times from position
const { removeSpace, movement } = target;
vimState.cursorStartPosition = position; // some textobj (MoveInsideCharacter) expect this
const count = vimState.recordedState.count || 1;
const targetMovement = await movement().execActionWithCount(position, vimState, count);
if (!isIMovement(targetMovement) || !!targetMovement.failed) {
// we want as result an IMovement, that did not fail.
return undefined;
}
let rangeStart = targetMovement.start;
let rangeEnd = targetMovement.stop;
// some local helpers
const getAdjustedRanges = (): SurroundEdge => {
if (movement() instanceof MoveInsideCharacter) {
// for parens, brackets, curly ... we have to adjust the right range
// there seems to be inconsistency between MoveInsideCharacter and MoveQuoteMatch
rangeEnd = rangeEnd.getLeft();
}
if (target.extraChars) {
rangeStart = rangeStart.getLeft(target.extraChars);
rangeEnd = rangeEnd.getRight(target.extraChars);
}
// now start and end are on ()
// next, check if there is space to remove (foo) vs ( bar )
const delSpace = checkRemoveSpace(); // 0 or 1
return {
leftEdge: new Range(rangeStart, rangeStart.getRight(1 + delSpace)),
rightEdge: new Range(rangeEnd.getLeft(delSpace), rangeEnd.getRight()),
cursorIndex: multicursorIndex,
};
};
const checkRemoveSpace = (): number => {
// capiche?
const leftSpace = vimState.editor.document.getText(
new Range(rangeStart.getRight(), rangeStart.getRight(2)),
);
const rightSpace = vimState.editor.document.getText(new Range(rangeEnd.getLeft(), rangeEnd));
return removeSpace && leftSpace === ' ' && rightSpace === ' ' ? 1 : 0;
};
const getAdjustedRangesForTag = async (): Promise<SurroundEdge | undefined> => {
// we are on start of opening tag and end of closing tag
// return ranges from there to the other side
// start -> <foo>bar</foo> <-- stop
const openTagNameStart = rangeStart.getRight();
const openTagNameEnd = openTagNameStart
.nextWordEnd(vimState.document, { inclusive: true })
.getRight();
const closeTagNameStart = rangeEnd
.getLeft(2)
.prevWordStart(vimState.document, { inclusive: true });
const closeTagNameEnd = rangeEnd.getLeft();
vimState.cursorStartPosition = position; // some textobj (MoveInsideCharacter) expect this
vimState.cursorStopPosition = position;
const innerTag =
count === 1
? await new MoveInsideTag().execActionWithCount(position, vimState, 1)
: await new MoveAroundTag().execActionWithCount(position, vimState, count - 1);
if (!isIMovement(innerTag) || !!innerTag.failed) {
return undefined;
} else {
return {
leftEdge: new Range(rangeStart, innerTag.start),
// maybe there is a small bug with cstt for multicursor, 2nd+ cursors
rightEdge: new Range(innerTag.stop, rangeEnd),
leftTagName: new Range(openTagNameStart, openTagNameEnd),
rightTagName: new Range(closeTagNameStart, closeTagNameEnd),
cursorIndex: multicursorIndex,
};
}
};
// good to go, now we can calculate our ranges based on rangeStart and rangeEnd
return vimState.surround.target === 't' ? getAdjustedRangesForTag() : getAdjustedRanges();
}
/** executes our prepared surround changes */
public static async ExecuteSurround(vimState: VimState): Promise<void> {
const surroundState = vimState.surround;
if (!surroundState || !surroundState.edges) {
return;
}
const replacement = this.edgePairings[surroundState.replacement];
// undefined allowed only for delete operator
if (!replacement && surroundState.operator !== 'delete') {
throw new Error('replacement missing in pairs');
}
// handle special case: cstt, replace only tag name
if (surroundState.target === 't' && surroundState.tag && surroundState.tag.keepAttributes) {
for (const { leftTagName, rightTagName } of surroundState.edges) {
if (!surroundState.tag || !leftTagName || !rightTagName) {
// throw ?
continue;
}
vimState.recordedState.transformer.replace(leftTagName, surroundState.tag.tag);
vimState.recordedState.transformer.replace(rightTagName, surroundState.tag.tag);
}
}
// all other cases: ys, ds, cs
else {
const optNewline = surroundState.addNewline ? '\n' : '';
const leftFixed =
surroundState.operator === 'delete'
? ''
: surroundState.tag
? '<' + surroundState.tag.tag + '>' + optNewline
: surroundState.function
? surroundState.function + optNewline
: replacement.left + optNewline;
const rightFixed =
surroundState.operator === 'delete'
? ''
: surroundState.tag
? optNewline + '</' + surroundState.tag.tag + '>'
: optNewline + replacement.right;
for (const { leftEdge, rightEdge, cursorIndex } of surroundState.edges) {
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
text: leftFixed,
range: leftEdge,
cursorIndex,
// keep cursor on left edge / start. todo: not completly correct vor visual S
diff:
surroundState.operator === 'yank'
? PositionDiff.offset({ character: -leftFixed.length })
: undefined,
});
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
text: rightFixed,
range: rightEdge,
});
}
}
// finish / cleanup. sql-koala was here :D
await vimState.setCurrentMode(Mode.Normal);
}
}

View File

@ -0,0 +1,129 @@
import { Position } from 'vscode';
import { isVisualMode } from '../../../mode/mode';
import { VimState } from '../../../state/vimState';
import { Logger } from '../../../util/logger';
import { BaseMovement, failedMovement, IMovement } from '../../baseMotion';
import { MoveInsideCharacter } from '../../motion';
import { searchPosition } from './searchUtils';
import { bracketObjectsEnabled } from './targetsConfig';
/*
* This function creates a last/next movement based on an existing one.
* It works by searching for a next/last character, and then applying the given action in its position.
* For examples of how to use it, see src/actions/plugins/targets/lastNextObjects.ts.
*/
function LastNextObject<T extends MoveInsideCharacter>(type: new () => T, which: 'l' | 'n') {
abstract class NextHandlerClass extends BaseMovement {
public override readonly keys: readonly string[] | readonly string[][];
override isJump = true;
// actual action (e.g. `i(` )
private readonly actual: T;
readonly secondKey: 'l' | 'n' = which;
// character to search forward/backward for next/last (e.g. `(` for next parenthesis)
abstract readonly charToFind: string;
// this is just to make sure we won't register anything that we can't handle. see constructor.
readonly valid: boolean;
public override doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
return this.valid && bracketObjectsEnabled() && super.doesActionApply(vimState, keysPressed);
}
constructor() {
super();
this.actual = new type();
const secondKey = this.secondKey;
const withWhichKey = (keys: string[]): string[] | undefined => {
if (keys.length === 2) {
return [keys[0], secondKey, keys[1]];
} else {
return undefined;
}
};
// we want fail without throwing an exception, but with log, to not break the Vim
const errMsg = `failed to register ${which === 'l' ? 'last' : 'next'} for ${type.name}`;
// failed, but it should never happen
if (this.actual.keys.length < 1) {
this.valid = false;
this.keys = [];
Logger.error(errMsg);
return;
}
if (typeof this.actual.keys[0] === 'string') {
const keys = withWhichKey(this.actual.keys as string[]);
// failed
if (keys === undefined) {
this.valid = false;
this.keys = [];
Logger.error(errMsg);
return;
} else {
this.keys = keys;
}
} else {
const keys = this.actual.keys.map((k) => withWhichKey(k as string[]));
// failed
if (!keys.every((p) => p !== undefined)) {
this.valid = false;
this.keys = [];
Logger.error(errMsg);
return;
} else {
this.keys = keys as string[][];
}
}
this.valid = true;
}
public override async execAction(
position: Position,
vimState: VimState,
firstIteration: boolean,
lastIteration: boolean,
): Promise<IMovement> {
const maybePosition = searchPosition(this.charToFind, vimState.document, position, {
direction: which === 'l' ? '<' : '>',
includeCursor: false,
throughLineBreaks: true,
});
if (maybePosition === undefined) {
return failedMovement(vimState);
}
vimState.cursorStartPosition = maybePosition;
vimState.cursorStopPosition = maybePosition;
const movement = await this.actual.execAction(
maybePosition,
vimState,
firstIteration,
lastIteration,
);
if (movement.failed) {
return movement;
}
const { start, stop } = movement;
if (!isVisualMode(vimState.currentMode) && position.isBefore(start)) {
vimState.recordedState.operatorPositionDiff = start.subtract(position);
} else if (!isVisualMode(vimState.currentMode) && position.isAfter(stop)) {
if (position.line === stop.line) {
vimState.recordedState.operatorPositionDiff = stop.subtract(position);
} else {
vimState.recordedState.operatorPositionDiff = start.subtract(position);
}
}
vimState.cursorStartPosition = start;
vimState.cursorStopPosition = stop;
return movement;
}
}
return NextHandlerClass;
}
export function LastObject<T extends MoveInsideCharacter>(type: new () => T) {
return LastNextObject(type, 'l');
}
export function NextObject<T extends MoveInsideCharacter>(type: new () => T) {
return LastNextObject(type, 'n');
}

View File

@ -0,0 +1,92 @@
import { RegisterAction } from '../../base';
import {
MoveAroundCaret,
MoveAroundCurlyBrace,
MoveAroundParentheses,
MoveAroundSquareBracket,
MoveInsideCaret,
MoveInsideCurlyBrace,
MoveInsideParentheses,
MoveInsideSquareBracket,
} from '../../motion';
import { LastObject, NextObject } from './lastNextObjectHelper';
@RegisterAction
class MoveInsideNextParentheses extends NextObject(MoveInsideParentheses) {
override readonly charToFind: string = '(';
}
@RegisterAction
class MoveInsideLastParentheses extends LastObject(MoveInsideParentheses) {
override readonly charToFind: string = ')';
}
@RegisterAction
class MoveAroundNextParentheses extends NextObject(MoveAroundParentheses) {
override readonly charToFind: string = '(';
}
@RegisterAction
class MoveAroundLastParentheses extends LastObject(MoveAroundParentheses) {
override readonly charToFind: string = ')';
}
@RegisterAction
class MoveInsideNextCurlyBrace extends NextObject(MoveInsideCurlyBrace) {
override readonly charToFind: string = '{';
}
@RegisterAction
class MoveInsideLastCurlyBrace extends LastObject(MoveInsideCurlyBrace) {
override readonly charToFind: string = '}';
}
@RegisterAction
class MoveAroundNextCurlyBrace extends NextObject(MoveAroundCurlyBrace) {
override readonly charToFind: string = '{';
}
@RegisterAction
class MoveAroundLastCurlyBrace extends LastObject(MoveAroundCurlyBrace) {
override readonly charToFind: string = '}';
}
@RegisterAction
class MoveInsideNextSquareBracket extends NextObject(MoveInsideSquareBracket) {
override readonly charToFind: string = '[';
}
@RegisterAction
class MoveInsideLastSquareBracket extends LastObject(MoveInsideSquareBracket) {
override readonly charToFind: string = ']';
}
@RegisterAction
class MoveAroundNextSquareBracket extends NextObject(MoveAroundSquareBracket) {
override readonly charToFind: string = '[';
}
@RegisterAction
class MoveAroundLastSquareBracket extends LastObject(MoveAroundSquareBracket) {
override readonly charToFind: string = ']';
}
@RegisterAction
class MoveInsideNextCaret extends NextObject(MoveInsideCaret) {
override readonly charToFind: string = '<';
}
@RegisterAction
class MoveInsideLastCaret extends LastObject(MoveInsideCaret) {
override readonly charToFind: string = '>';
}
@RegisterAction
class MoveAroundNextCaret extends NextObject(MoveAroundCaret) {
override readonly charToFind: string = '<';
}
@RegisterAction
class MoveAroundLastCaret extends LastObject(MoveAroundCaret) {
override readonly charToFind: string = '>';
}

View File

@ -0,0 +1,115 @@
import { Position, TextDocument } from 'vscode';
export interface SearchFlags {
direction?: '<' | '>';
includeCursor?: boolean;
throughLineBreaks?: boolean;
}
function searchForward(
str: string,
document: TextDocument,
start: Position,
flags: {
throughLineBreaks?: boolean;
} = {
throughLineBreaks: false,
},
): Position | undefined {
let position = start;
for (
let line = position.line;
line < document.lineCount && (flags.throughLineBreaks || line === start.line);
line++
) {
position = document.validatePosition(position.with({ line }));
const text = document.lineAt(position).text;
const index = text.indexOf(str, position.character);
if (index >= 0) {
return position.with({ character: index });
}
position = position.with({ character: 0 }); // set at line begin for next iteration
}
return undefined;
}
function searchBackward(
str: string,
document: TextDocument,
start: Position,
flags: {
throughLineBreaks?: boolean;
} = {
throughLineBreaks: false,
},
): Position | undefined {
let position = start;
for (
let line = position.line;
line >= 0 && (flags.throughLineBreaks || line === start.line);
line--
) {
position = document.validatePosition(position.with({ line }));
const text = document.lineAt(position).text;
const index = text.lastIndexOf(str, position.character);
if (index >= 0) {
return position.with({ character: index });
}
position = position.with({ character: +Infinity }); // set at line end for next iteration
}
return undefined;
}
export function maybeGetLeft(
position: Position,
{
count = 1,
throughLineBreaks,
dontMove,
}: { count?: number; throughLineBreaks?: boolean; dontMove?: boolean },
) {
return dontMove
? position
: throughLineBreaks
? position.getOffsetThroughLineBreaks(-count)
: position.getLeft(count);
}
export function maybeGetRight(
position: Position,
{
count = 1,
throughLineBreaks,
dontMove,
}: { count?: number; throughLineBreaks?: boolean; dontMove?: boolean },
) {
return dontMove
? position
: throughLineBreaks
? position.getOffsetThroughLineBreaks(count)
: position.getRight(count);
}
export function searchPosition(
str: string,
document: TextDocument,
start: Position,
flags: SearchFlags = {
direction: '>',
includeCursor: true,
throughLineBreaks: false,
},
): Position | undefined {
if (flags.direction === '<') {
start = maybeGetLeft(start, {
dontMove: flags.includeCursor,
throughLineBreaks: flags.throughLineBreaks,
});
return searchBackward(str, document, start, flags);
} else {
start = maybeGetRight(start, {
dontMove: flags.includeCursor,
throughLineBreaks: flags.throughLineBreaks,
});
return searchForward(str, document, start, flags);
}
}

View File

@ -0,0 +1,155 @@
import { RegisterAction } from '../../base';
import { Mode } from '../../../mode/mode';
import { MoveQuoteMatch } from '../../motion';
abstract class SmartQuotes extends MoveQuoteMatch {
override modes = [Mode.Normal, Mode.Visual, Mode.VisualBlock];
}
@RegisterAction
export class MoveAroundNextSingleQuotes extends SmartQuotes {
keys = ['a', 'n', "'"];
readonly charToMatch = "'";
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextSingleQuotes extends SmartQuotes {
keys = ['i', 'n', "'"];
readonly charToMatch = "'";
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastSingleQuotes extends SmartQuotes {
keys = ['a', 'l', "'"];
readonly charToMatch = "'";
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastSingleQuotes extends SmartQuotes {
keys = ['i', 'l', "'"];
readonly charToMatch = "'";
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextDoubleQuotes extends SmartQuotes {
keys = ['a', 'n', '"'];
readonly charToMatch = '"';
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextDoubleQuotes extends SmartQuotes {
keys = ['i', 'n', '"'];
readonly charToMatch = '"';
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastDoubleQuotes extends SmartQuotes {
keys = ['a', 'l', '"'];
readonly charToMatch = '"';
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastDoubleQuotes extends SmartQuotes {
keys = ['i', 'l', '"'];
readonly charToMatch = '"';
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextBacktick extends SmartQuotes {
keys = ['a', 'n', '`'];
readonly charToMatch = '`';
override readonly which = 'next';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextBacktick extends SmartQuotes {
keys = ['i', 'n', '`'];
readonly charToMatch = '`';
override readonly which = 'next';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastBacktick extends SmartQuotes {
keys = ['a', 'l', '`'];
readonly charToMatch = '`';
override readonly which = 'last';
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastBacktick extends SmartQuotes {
keys = ['i', 'l', '`'];
readonly charToMatch = '`';
override readonly which = 'last';
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundQuote extends SmartQuotes {
keys = ['a', 'q'];
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideQuote extends SmartQuotes {
keys = ['i', 'q'];
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundNextQuote extends SmartQuotes {
keys = ['a', 'n', 'q'];
override readonly which = 'next';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideNextQuote extends SmartQuotes {
keys = ['i', 'n', 'q'];
override readonly which = 'next';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}
@RegisterAction
export class MoveAroundLastQuote extends SmartQuotes {
keys = ['a', 'l', 'q'];
override readonly which = 'last';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = true;
}
@RegisterAction
export class MoveInsideLastQuote extends SmartQuotes {
keys = ['i', 'l', 'q'];
override readonly which = 'last';
override readonly anyQuote = true;
readonly charToMatch = '"'; // it is not in use, because anyQuote is true.
override includeQuotes = false;
}

View File

@ -0,0 +1,367 @@
import { Position } from 'vscode';
import { TextDocument } from 'vscode';
import { configuration } from '../../../configuration/configuration';
type Quote = '"' | "'" | '`';
enum QuoteMatch {
Opening,
Closing,
}
export type WhichQuotes = 'current' | 'next' | 'last';
type Dir = '>' | '<';
type SearchAction = {
first: Dir;
second: Dir;
includeCurrent: boolean;
};
type QuotesAction = {
search: SearchAction | undefined;
skipToLeft: number; // for last quotes, how many quotes need to skip while searching
skipToRight: number; // for next quotes, how many quotes need to skip while searching
};
/**
* This mapping is used to give a way to identify which action we need to take when operating on a line.
* The keys here are, in some sense, the number of quotes in the line, in the format of `lcr`, where:
* `l` means left of the cursor, `c` whether the cursor is on a quote, and `r` is right of the cursor.
*
* It is based on the ideas used in `targets.vim`. For each line & cursor position, we count the number of quotes
* left (#L) and right (#R) of the cursor. Using those numbers and whether the cursor it on a quote, we know
* what action to make.
*
* For each entry we have an example of a line & position.
*/
const quoteDirs: Record<string, QuotesAction> = {
'002': {
// | "a" "b" "c"
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 0,
skipToRight: 1,
},
'012': {
// |"a" "b" "c" "
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 0,
skipToRight: 2,
},
'102': {
// "a" "|b" "c" "
search: { first: '<', second: '>', includeCurrent: false },
skipToLeft: 2,
skipToRight: 2,
},
'112': {
// "a" "b|" "c"
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 1,
},
'202': {
// "a"| "b" "c"
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 1,
skipToRight: 1,
},
'211': {
// "a" |"b" "c"
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 1,
skipToRight: 2,
},
'101': {
// "a" "|b" "c"
search: { first: '<', second: '>', includeCurrent: false },
skipToLeft: 2,
skipToRight: 2,
},
'011': {
// |"a" "b" "c"
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 0,
skipToRight: 2,
},
'110': {
// "a" "b" "c|"
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 0,
},
'212': {
// "a" |"b" "c" "
search: { first: '>', second: '>', includeCurrent: true },
skipToLeft: 1,
skipToRight: 2,
},
'111': {
// "a" "b|" "c" "
search: { first: '<', second: '<', includeCurrent: true },
skipToLeft: 2,
skipToRight: 1,
},
'200': {
// "a" "b" "c"|
search: { first: '<', second: '<', includeCurrent: false },
skipToLeft: 1,
skipToRight: 0,
},
'201': {
// "a" "b" "c"| "
// "a"| "b" "c" "
search: { first: '>', second: '>', includeCurrent: false },
skipToLeft: 1,
skipToRight: 1,
},
'210': {
// "a" "b" "c" |"
search: undefined,
skipToLeft: 1,
skipToRight: 0,
},
'001': {
// | "a" "b" "c" "
search: undefined,
skipToLeft: 0,
skipToRight: 1,
},
'010': {
// a|"b
search: undefined,
skipToLeft: 0,
skipToRight: 0,
},
'100': {
// "a" "b" "c" "|
search: undefined,
skipToLeft: 2,
skipToRight: 0,
},
'000': {
// |ab
search: undefined,
skipToLeft: 0,
skipToRight: 0,
},
};
export class SmartQuoteMatcher {
static readonly escapeChar = '\\';
private document: TextDocument;
private quote: Quote | 'any';
constructor(quote: Quote | 'any', document: TextDocument) {
this.quote = quote;
this.document = document;
}
private buildQuoteMap(text: string) {
const quoteMap: QuoteMatch[] = [];
let openingQuote = true;
// Loop over text, marking quotes and respecting escape characters.
for (let i = 0; i < text.length; i++) {
if (text[i] === SmartQuoteMatcher.escapeChar) {
i += 1;
continue;
}
if (
(this.quote === 'any' && (text[i] === '"' || text[i] === "'" || text[i] === '`')) ||
text[i] === this.quote
) {
quoteMap[i] = openingQuote ? QuoteMatch.Opening : QuoteMatch.Closing;
openingQuote = !openingQuote;
}
}
return quoteMap;
}
private static lineSearchAction(cursorIndex: number, quoteMap: QuoteMatch[]) {
// base on ideas from targets.vim
// cut line in left of, on and right of cursor
const left = Array.from(quoteMap.entries()).slice(undefined, cursorIndex);
const cursor = quoteMap[cursorIndex];
const right = Array.from(quoteMap.entries()).slice(cursorIndex + 1, undefined);
// how many delimiters left, on and right of cursor
const lc = left.filter(([_, v]) => v !== undefined).length;
const cc = cursor !== undefined ? 1 : 0;
const rc = right.filter(([_, v]) => v !== undefined).length;
// truncate counts
const lct = lc === 0 ? 0 : lc % 2 === 0 ? 2 : 1;
const rct = rc === 0 ? 0 : rc >= 2 ? 2 : 1;
const key = `${lct}${cc}${rct}`;
const act = quoteDirs[key];
return act;
}
public smartSurroundingQuotes(
position: Position,
which: WhichQuotes,
): { start: Position; stop: Position; lineText: string } | undefined {
position = this.document.validatePosition(position);
const cursorIndex = position.character;
const lineText = this.document.lineAt(position).text;
const quoteMap = this.buildQuoteMap(lineText);
const act = SmartQuoteMatcher.lineSearchAction(cursorIndex, quoteMap);
if (which === 'current') {
if (act.search) {
const searchRes = this.smartSearch(cursorIndex, act.search, quoteMap);
return searchRes
? {
start: position.with({ character: searchRes[0] }),
stop: position.with({ character: searchRes[1] }),
lineText,
}
: undefined;
} else {
return undefined;
}
} else if (which === 'next') {
// search quote in current line
const right = Array.from(quoteMap.entries()).slice(cursorIndex + 1, undefined);
const [index, found] = right.filter(([i, v]) => v !== undefined)[act.skipToRight] ?? [
+Infinity,
undefined,
];
// find next position for surrounding quotes, possibly breaking through lines
let nextPos;
position = position.with({ character: index });
if (found === undefined && configuration.targets.smartQuotes.breakThroughLines) {
// nextPos = State.evalGenerator(this.getNextQuoteThroughLineBreaks(), position);
nextPos = this.getNextQuoteThroughLineBreaks(position);
} else {
nextPos = found !== undefined ? position : undefined;
}
// find surrounding with new position
if (nextPos) {
return this.smartSurroundingQuotes(nextPos, 'current');
} else {
return undefined;
}
} else if (which === 'last') {
// search quote in current line
const left = Array.from(quoteMap.entries()).slice(undefined, cursorIndex);
const [index, found] = left.reverse().filter(([i, v]) => v !== undefined)[act.skipToLeft] ?? [
0,
undefined,
];
// find last position for surrounding quotes, possibly breaking through lines
let lastPos;
position = position.with({ character: index });
if (found === undefined && configuration.targets.smartQuotes.breakThroughLines) {
position = position.getLeftThroughLineBreaks();
lastPos = this.getLastQuoteThroughLineBreaks(position);
} else {
lastPos = found !== undefined ? position : undefined;
}
// find surrounding with new position
if (lastPos) {
return this.smartSurroundingQuotes(lastPos, 'current');
} else {
return undefined;
}
} else {
return undefined;
}
}
private smartSearch(
start: number,
action: SearchAction,
quoteMap: QuoteMatch[],
): [number, number] | undefined {
const offset = action.includeCurrent ? 1 : 0;
let cursorPos: number | undefined = start;
let fst: number | undefined;
let snd: number | undefined;
if (action.first === '>') {
cursorPos = fst = this.getNextQuote(cursorPos - offset, quoteMap);
} else {
// dir === '<'
cursorPos = fst = this.getPrevQuote(cursorPos + offset, quoteMap);
}
if (cursorPos === undefined) return undefined;
if (action.second === '>') {
snd = this.getNextQuote(cursorPos, quoteMap);
} else {
// dir === '<'
snd = this.getPrevQuote(cursorPos, quoteMap);
}
if (fst === undefined || snd === undefined) return undefined;
if (fst < snd) return [fst, snd];
else return [snd, fst];
}
private getNextQuoteThroughLineBreaks(position: Position): Position | undefined {
for (let line = position.line; line < this.document.lineCount; line++) {
position = this.document.validatePosition(position.with({ line }));
const text = this.document.lineAt(position).text;
if (this.quote === 'any') {
for (let i = position.character; i < text.length; i++) {
if (text[i] === '"' || text[i] === "'" || text[i] === '`') {
return position.with({ character: i });
}
}
} else {
const index = text.indexOf(this.quote, position.character);
if (index >= 0) {
return position.with({ character: index });
}
}
position = position.with({ character: 0 }); // set at line begin for next iteration
}
return undefined;
}
private getLastQuoteThroughLineBreaks(position: Position): Position | undefined {
for (let line = position.line; line >= 0; line--) {
position = this.document.validatePosition(position.with({ line }));
const text = this.document.lineAt(position).text;
if (this.quote === 'any') {
for (let i = position.character; i >= 0; i--) {
if (text[i] === '"' || text[i] === "'" || text[i] === '`') {
return position.with({ character: i });
}
}
} else {
const index = text.lastIndexOf(this.quote, position.character);
if (index >= 0) {
return position.with({ character: index });
}
}
position = position.with({ character: +Infinity }); // set at line end for next iteration
}
return undefined;
}
private getNextQuote(start: number, quoteMap: QuoteMatch[]): number | undefined {
for (let i = start + 1; i < quoteMap.length; i++) {
if (quoteMap[i] !== undefined) {
return i;
}
}
return undefined;
}
private getPrevQuote(start: number, quoteMap: QuoteMatch[]): number | undefined {
for (let i = start - 1; i >= 0; i--) {
if (quoteMap[i] !== undefined) {
return i;
}
}
return undefined;
}
}

View File

@ -0,0 +1,3 @@
// targets sub-plugins
import './smartQuotes';
import './lastNextObjects';

View File

@ -0,0 +1,18 @@
import { configuration } from '../../../configuration/configuration';
export function useSmartQuotes(): boolean {
return (
(configuration.targets.enable === true && configuration.targets.smartQuotes.enable !== false) ||
(configuration.targets.enable === undefined &&
configuration.targets.smartQuotes.enable === true)
);
}
export function bracketObjectsEnabled(): boolean {
return (
(configuration.targets.enable === true &&
configuration.targets.bracketObjects.enable !== false) ||
(configuration.targets.enable === undefined &&
configuration.targets.bracketObjects.enable === true)
);
}

View File

@ -1,65 +0,0 @@
import { commands } from 'vscode';
import { Action } from '../action_types';
import { enterWindowMode } from '../modes';
import { Mode } from '../modes_types';
import { parseKeysExact } from '../parse_keys';
// https://docs.helix-editor.com/keymap.html#space-mode
export const spaceActions: Action[] = [
// Open File Picker
parseKeysExact([' ', 'f'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.quickOpen');
}),
parseKeysExact([' ', 'g'], [Mode.Normal], () => {
commands.executeCommand('workbench.debug.action.focusBreakpointsView');
}),
parseKeysExact([' ', 'k'], [Mode.Normal], () => {
commands.executeCommand('editor.action.showHover');
}),
parseKeysExact([' ', 's'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.gotoSymbol');
}),
parseKeysExact([' ', 'S'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.showAllSymbols');
}),
// View problems in current file
parseKeysExact([' ', 'd'], [Mode.Normal], () => {
commands.executeCommand('workbench.actions.view.problems');
// It's not possible to set active file on and off, you can only toggle it, which makes implementing this difficult
// For now both d and D will do the same thing and search all of the workspace
// Leaving this here for future reference
// commands.executeCommand('workbench.actions.workbench.panel.markers.view.toggleActiveFile');
}),
// View problems in workspace
parseKeysExact([' ', 'D'], [Mode.Normal], () => {
// alias of 'd'. See above
commands.executeCommand('workbench.actions.view.problems');
}),
parseKeysExact([' ', 'r'], [Mode.Normal], () => {
commands.executeCommand('editor.action.rename');
}),
parseKeysExact([' ', 'a'], [Mode.Normal], () => {
commands.executeCommand('editor.action.quickFix');
}),
parseKeysExact([' ', 'w'], [Mode.Normal], (helixState) => {
enterWindowMode(helixState);
}),
parseKeysExact([' ', '/'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.findInFiles');
}),
parseKeysExact([' ', '?'], [Mode.Normal], () => {
commands.executeCommand('workbench.action.showCommands');
}),
];

25
src/actions/types.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
import type { Position } from 'vscode';
import type { VimState } from '../state/vimState';
export type ActionType = 'command' | 'motion' | 'operator' | 'number';
export interface IBaseAction {
name: string;
readonly actionType: ActionType;
readonly isJump: boolean;
readonly createsUndoPoint: boolean;
keysPressed: string[];
multicursorIndex: number | undefined;
readonly preservesDesiredColumn: boolean;
}
export interface IBaseCommand extends IBaseAction {
exec(position: Position, vimState: VimState): Promise<void>;
}
export interface IBaseOperator extends IBaseAction {
run(vimState: VimState, start: Position, stop: Position): Promise<void>;
runRepeat(vimState: VimState, position: Position, count: number): Promise<void>;
}

View File

@ -1,50 +0,0 @@
import { Selection, TextEditorRevealType, commands } from 'vscode';
import { Action } from '../action_types';
import { Mode } from '../modes_types';
import { parseKeysExact } from '../parse_keys';
export const unimparedActions: Action[] = [
parseKeysExact([']', 'D'], [Mode.Normal], () => {
commands.executeCommand('editor.action.marker.next');
}),
parseKeysExact(['[', 'D'], [Mode.Normal], () => {
commands.executeCommand('editor.action.marker.prev');
}),
parseKeysExact([']', 'd'], [Mode.Normal], () => {
commands.executeCommand('editor.action.marker.nextInFiles');
}),
parseKeysExact(['[', 'd'], [Mode.Normal], () => {
commands.executeCommand('editor.action.marker.prevInFiles');
}),
parseKeysExact(['[', 'g'], [Mode.Normal], () => {
// There is no way to check if we're in compare editor mode or not so i need to call both commands
commands.executeCommand('workbench.action.compareEditor.previousChange');
commands.executeCommand('workbench.action.editor.previousChange');
}),
parseKeysExact([']', 'g'], [Mode.Normal], () => {
// There is no way to check if we're in compare editor mode or not so i need to call both commands
commands.executeCommand('workbench.action.compareEditor.nextChange');
commands.executeCommand('workbench.action.editor.nextChange');
}),
parseKeysExact([']', 'f'], [Mode.Normal], (helixState, editor) => {
const range = helixState.symbolProvider.getNextFunctionRange(editor);
if (range) {
editor.revealRange(range, TextEditorRevealType.InCenter);
editor.selection = new Selection(range.start, range.end);
}
}),
parseKeysExact(['[', 'f'], [Mode.Normal], (helixState, editor) => {
const range = helixState.symbolProvider.getPreviousFunctionRange(editor);
if (range) {
editor.revealRange(range, TextEditorRevealType.InCenter);
editor.selection = new Selection(range.start, range.end);
}
}),
];

View File

@ -1,101 +0,0 @@
import { commands } from 'vscode';
import { Action } from '../action_types';
import { enterViewMode } from '../modes';
import { Mode } from '../modes_types';
import { parseKeysExact } from '../parse_keys';
// https://docs.helix-editor.com/keymap.html#view-mode
export const viewActions: Action[] = [
parseKeysExact(['Z'], [Mode.Normal, Mode.Visual], (helixState) => {
enterViewMode(helixState);
}),
// align view center
parseKeysExact(['z', 'c'], [Mode.Normal, Mode.Visual], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'center',
});
}),
parseKeysExact(['c'], [Mode.View], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'center',
});
}),
// align view top
parseKeysExact(['z', 't'], [Mode.Normal, Mode.Visual], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'top',
});
}),
parseKeysExact(['t'], [Mode.View], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'top',
});
}),
// align view bottom
parseKeysExact(['z', 'b'], [Mode.Normal, Mode.Visual], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'bottom',
});
}),
parseKeysExact(['b'], [Mode.View], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'bottom',
});
}),
parseKeysExact(['z', 't'], [Mode.Normal, Mode.Visual], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'top',
});
}),
parseKeysExact(['t'], [Mode.View], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'top',
});
}),
parseKeysExact(['z', 'j'], [Mode.Normal, Mode.Visual], () => {
commands.executeCommand('scrollLineDown');
}),
parseKeysExact(['j'], [Mode.View], () => {
commands.executeCommand('scrollLineDown');
}),
parseKeysExact(['z', 'k'], [Mode.Normal, Mode.Visual], () => {
commands.executeCommand('scrollLineUp');
}),
parseKeysExact(['k'], [Mode.View], () => {
commands.executeCommand('scrollLineUp');
}),
parseKeysExact(['z', 'z'], [Mode.Normal, Mode.Visual], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'center',
});
}),
parseKeysExact(['z'], [Mode.View], (_, editor) => {
commands.executeCommand('revealLine', {
lineNumber: editor.selection.active.line,
at: 'center',
});
}),
];

View File

@ -1,116 +0,0 @@
import { commands } from 'vscode';
import { Action } from '../action_types';
import { enterNormalMode } from '../modes';
import { Mode } from '../modes_types';
import { parseKeysExact } from '../parse_keys';
// https://docs.helix-editor.com/keymap.html#window-mode
export const windowActions: Action[] = [
// New window modes (moving existing windows)
parseKeysExact(['m', 'v'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveEditorToNextGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['m', 's'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveEditorToBelowGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['m', 'p'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveEditorToPreviousGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['m', 'w'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveEditorToNewWindow');
enterNormalMode(helixState);
}),
parseKeysExact(['m', 'j'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.restoreEditorsToMainWindow');
enterNormalMode(helixState);
}),
// Crtl+w actions
parseKeysExact(['w'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.navigateEditorGroups');
enterNormalMode(helixState);
}),
parseKeysExact(['v'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.splitEditor');
enterNormalMode(helixState);
}),
parseKeysExact(['s'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.splitEditorDown');
enterNormalMode(helixState);
}),
parseKeysExact(['F'], [Mode.Window], (helixState) => {
commands.executeCommand('editor.action.revealDefinitionAside');
enterNormalMode(helixState);
}),
parseKeysExact(['f'], [Mode.Window], (helixState) => {
commands.executeCommand('editor.action.revealDefinitionAside');
enterNormalMode(helixState);
}),
parseKeysExact(['h'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.focusLeftGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['l'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.focusRightGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['j'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.focusBelowGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['k'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.focusAboveGroup');
enterNormalMode(helixState);
}),
parseKeysExact(['q'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.closeActiveEditor');
enterNormalMode(helixState);
}),
// Alias q (for vim compatibility)
parseKeysExact(['c'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.closeActiveEditor');
enterNormalMode(helixState);
}),
parseKeysExact(['o'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.closeOtherEditors');
enterNormalMode(helixState);
}),
parseKeysExact(['H'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveActiveEditorGroupLeft');
enterNormalMode(helixState);
}),
parseKeysExact(['L'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.moveActiveEditorGroupRight');
enterNormalMode(helixState);
}),
parseKeysExact(['n'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.files.newUntitledFile');
enterNormalMode(helixState);
}),
parseKeysExact(['b'], [Mode.Window], (helixState) => {
commands.executeCommand('workbench.action.toggleSidebarVisibility');
enterNormalMode(helixState);
}),
];

25
src/actions/wrapping.ts Normal file
View File

@ -0,0 +1,25 @@
import { configuration } from './../configuration/configuration';
import { Mode } from './../mode/mode';
/**
* See https://vimhelp.org/options.txt.html#%27whichwrap%27
*
* @returns true if the given key should cause the cursor to wrap around line boundary
*/
export const shouldWrapKey = (mode: Mode, key: string): boolean => {
let k: string;
if (key === '<left>') {
k = [Mode.Insert, Mode.Replace].includes(mode) ? '[' : '<';
} else if (key === '<right>') {
k = [Mode.Insert, Mode.Replace].includes(mode) ? ']' : '>';
} else if (['<BS>', '<C-BS>', '<S-BS>'].includes(key)) {
k = 'b';
} else if (key === ' ') {
k = 's';
} else if (['h', 'l', '~'].includes(key)) {
k = key;
} else {
throw new Error(`shouldWrapKey called with unexpected key='${key}'`);
}
return configuration.whichwrap.split(',').includes(k);
};

View File

@ -1,9 +0,0 @@
export function arrayFindLast<T>(xs: T[], p: (x: T) => boolean): T | undefined {
const filtered = xs.filter(p)
if (filtered.length === 0) {
return undefined
} else {
return filtered[filtered.length - 1]
}
}

View File

@ -1,129 +0,0 @@
import * as vscode from 'vscode'
enum MatchType {
start = 'start',
end = 'end',
}
interface BlockMatch {
type: MatchType
match: RegExpMatchArray
}
const startRegex = (startWords: string[]) => RegExp(`(^|\\s)(${startWords.join('|')})($|\\s)`, 'g')
const endRegex = (endWords: string[]) => RegExp(`(^|\\s)(${endWords.join('|')})($|\\W)`, 'g')
export function blockRange(document: vscode.TextDocument, position: vscode.Position): vscode.Range {
let startWords: string[] = []
let endWords: string[] = []
console.log(`LanguageID=${document.languageId}`)
if (document.languageId === 'elixir') {
startWords = ['case', 'cond', 'fn', 'def']
endWords = ['end']
} else {
console.log(`Unsupported language: ${document.languageId}`)
return new vscode.Range(position, position)
}
const start = findBlockStart(document, position, startWords, endWords)
const end = findBlockEnd(document, position, startWords, endWords)
if (start && end) {
return new vscode.Range(start, end)
}
return new vscode.Range(position, position)
}
function findBlockStart(
document: vscode.TextDocument,
position: vscode.Position,
startWords: string[],
endWords: string[],
): vscode.Position | undefined {
const closedBlocks: boolean[] = []
for (let i = position.line; i >= 0; --i) {
const lineText =
i === position.line
? document.lineAt(i).text.substr(position.character)
: document.lineAt(i).text
let blockMatches: BlockMatch[] = []
for (const m of lineText.matchAll(startRegex(startWords))) {
blockMatches.push({ type: MatchType.start, match: m })
}
for (const m of lineText.matchAll(endRegex(endWords))) {
blockMatches.push({ type: MatchType.end, match: m })
}
blockMatches = blockMatches.sort((a, b) =>
(a.match.index as number) > (b.match.index as number) ? 1 : -1,
)
for (let idx = 0; idx < blockMatches.length; idx++) {
const blockMatch = blockMatches[idx]
if (blockMatch.type === MatchType.end) {
closedBlocks.push(true)
} else if (blockMatch.type === MatchType.start) {
if (closedBlocks.length === 0) {
const [fullText, , matchText] = blockMatch.match
const offset = fullText.indexOf(matchText)
return new vscode.Position(i, (blockMatch.match.index as number) + offset)
} else {
closedBlocks.pop()
}
}
}
}
return undefined
}
function findBlockEnd(
document: vscode.TextDocument,
position: vscode.Position,
startWords: string[],
endWords: string[],
): vscode.Position | undefined {
const openedBlocks: boolean[] = [true]
for (let i = position.line; i < document.lineCount; ++i) {
const lineText =
i === position.line
? document.lineAt(i).text.substr(position.character)
: document.lineAt(i).text
let blockMatches: BlockMatch[] = []
for (const m of lineText.matchAll(startRegex(startWords))) {
blockMatches.push({ type: MatchType.start, match: m })
}
for (const m of lineText.matchAll(endRegex(endWords))) {
blockMatches.push({ type: MatchType.end, match: m })
}
blockMatches = blockMatches.sort((a, b) =>
(a.match.index as number) > (b.match.index as number) ? 1 : -1,
)
for (let idx = 0; idx < blockMatches.length; idx++) {
const blockMatch = blockMatches[idx]
if (blockMatch.type === MatchType.start) {
openedBlocks.push(true)
} else if (blockMatch.type === MatchType.end) {
openedBlocks.pop()
if (openedBlocks.length === 0) {
const [fullText, , matchText] = blockMatch.match
const offset = fullText.indexOf(matchText)
return new vscode.Position(
i,
(blockMatch.match.index as number) + offset + matchText.length,
)
}
}
}
}
return undefined
}

549
src/cmd_line/commandLine.ts Normal file
View File

@ -0,0 +1,549 @@
import { Parser } from 'parsimmon';
import { ExtensionContext, Position, window } from 'vscode';
import { configuration } from '../configuration/configuration';
import { ErrorCode, VimError } from '../error';
import { CommandLineHistory, HistoryFile, SearchHistory } from '../history/historyFile';
import { Register } from '../register/register';
import { globalState } from '../state/globalState';
import { RecordedState } from '../state/recordedState';
import { IndexedPosition, IndexedRange, SearchState } from '../state/searchState';
import { VimState } from '../state/vimState';
import { StatusBar } from '../statusBar';
import { WordType, getWordLeftInText, getWordRightInText } from '../textobject/word';
import { SearchDecorations, getDecorationsForSearchMatchRanges } from '../util/decorationUtils';
import { Logger } from '../util/logger';
import { escapeCSSIcons, reportSearch } from '../util/statusBarTextUtils';
import { scrollView } from '../util/util';
import { ExCommand } from '../vimscript/exCommand';
import { LineRange } from '../vimscript/lineRange';
import { SearchDirection } from '../vimscript/pattern';
import { Mode } from './../mode/mode';
import { RegisterCommand } from './commands/register';
import { SubstituteCommand } from './commands/substitute';
export abstract class CommandLine {
public cursorIndex: number;
public previousMode: Mode;
protected historyIndex: number | undefined;
private savedText: string;
constructor(text: string, previousMode: Mode) {
this.cursorIndex = text.length;
this.historyIndex = this.getHistory().get().length;
this.previousMode = previousMode;
this.savedText = text;
}
/**
* @returns the text to be displayed in the status bar
*/
public abstract display(cursorChar: string): string;
/**
* What the user has typed, minus any prefix, etc.
*/
public abstract get text(): string;
public abstract set text(text: string);
/**
* @returns the SearchState associated with this CommandLine, if one exists
*
* This applies to `/`, `:s`, `:g`, `:v`, etc.
*/
public abstract getSearchState(): SearchState | undefined;
public abstract getHistory(): HistoryFile;
public abstract getDecorations(vimState: VimState): SearchDecorations | undefined;
/**
* Called when `<Enter>` is pressed
*/
public abstract run(vimState: VimState): Promise<void>;
/**
* Called when `<Esc>` is pressed
*/
public abstract escape(vimState: VimState): Promise<void>;
/**
* Called when `<C-f>` is pressed
*/
public abstract ctrlF(vimState: VimState): Promise<void>;
public async historyBack(): Promise<void> {
if (this.historyIndex === 0) {
return;
}
const historyEntries = this.getHistory().get();
if (this.historyIndex === undefined) {
this.historyIndex = historyEntries.length - 1;
this.savedText = this.text;
} else if (this.historyIndex > 0) {
this.historyIndex--;
}
this.text = historyEntries[this.historyIndex];
this.cursorIndex = this.text.length;
}
public async historyForward(): Promise<void> {
if (this.historyIndex === undefined) {
return;
}
const historyEntries = this.getHistory().get();
if (this.historyIndex === historyEntries.length - 1) {
this.historyIndex = undefined;
this.text = this.savedText;
} else if (this.historyIndex < historyEntries.length - 1) {
this.historyIndex++;
this.text = historyEntries[this.historyIndex];
}
this.cursorIndex = this.text.length;
}
/**
* Called when `<BS>` is pressed
*/
public async backspace(vimState: VimState): Promise<void> {
if (this.cursorIndex === 0) {
if (this.text.length === 0) {
await this.escape(vimState);
}
return;
}
this.text = this.text.slice(0, this.cursorIndex - 1) + this.text.slice(this.cursorIndex);
this.cursorIndex = Math.max(this.cursorIndex - 1, 0);
}
/**
* Called when `<Del>` is pressed
*/
public async delete(vimState: VimState): Promise<void> {
if (this.cursorIndex === this.text.length) {
return this.backspace(vimState);
}
this.text = this.text.slice(0, this.cursorIndex) + this.text.slice(this.cursorIndex + 1);
}
/**
* Called when `<Home>` is pressed
*/
public async home(): Promise<void> {
this.cursorIndex = 0;
}
/**
* Called when `<End>` is pressed
*/
public async end(): Promise<void> {
this.cursorIndex = this.text.length;
}
/**
* Called when `<C-Left>` is pressed
*/
public async wordLeft(): Promise<void> {
this.cursorIndex = getWordLeftInText(this.text, this.cursorIndex, WordType.Big) ?? 0;
}
/**
* Called when `<C-Right>` is pressed
*/
public async wordRight(): Promise<void> {
this.cursorIndex =
getWordRightInText(this.text, this.cursorIndex, WordType.Big) ?? this.text.length;
}
/**
* Called when `<C-BS>` is pressed
*/
public async deleteWord(): Promise<void> {
const wordStart = getWordLeftInText(this.text, this.cursorIndex, WordType.Normal);
if (wordStart !== undefined) {
this.text = this.text.substring(0, wordStart).concat(this.text.slice(this.cursorIndex));
this.cursorIndex = this.cursorIndex - (this.cursorIndex - wordStart);
}
}
/**
* Called when `<C-BS>` is pressed
*/
public async deleteToBeginning(): Promise<void> {
this.text = this.text.slice(this.cursorIndex);
this.cursorIndex = 0;
}
public async typeCharacter(char: string): Promise<void> {
const modifiedString = this.text.split('');
modifiedString.splice(this.cursorIndex, 0, char);
this.text = modifiedString.join('');
this.cursorIndex += char.length;
}
}
export class ExCommandLine extends CommandLine {
static history: CommandLineHistory;
static parser: Parser<{ lineRange: LineRange | undefined; command: ExCommand }>;
static onSearch: (vimState: VimState) => Promise<void>;
public static async loadHistory(context: ExtensionContext): Promise<void> {
ExCommandLine.history = new CommandLineHistory(context);
await ExCommandLine.history.load();
}
// TODO: Make this stuff private?
public autoCompleteIndex = 0;
public autoCompleteItems: string[] = [];
public preCompleteCharacterPos = 0;
public preCompleteCommand = '';
private commandText: string;
private lineRange: LineRange | undefined;
private command: ExCommand | undefined;
constructor(commandText: string, previousMode: Mode) {
super(commandText, previousMode);
this.commandText = commandText;
this.text = commandText;
this.previousMode = previousMode;
}
public display(cursorChar: string): string {
return escapeCSSIcons(
`:${this.text.substring(0, this.cursorIndex)}${cursorChar}${this.text.substring(
this.cursorIndex,
)}`,
);
}
public get text(): string {
return this.commandText;
}
public set text(text: string) {
this.commandText = text;
try {
// TODO: This eager parsing is costly, and if it's not `:s` or similar, don't need to parse the args at all
const { lineRange, command } = ExCommandLine.parser.tryParse(this.commandText);
this.lineRange = lineRange;
this.command = command;
} catch (err) {
this.lineRange = undefined;
this.command = undefined;
}
}
public getSearchState(): SearchState | undefined {
return undefined;
}
public getDecorations(vimState: VimState): SearchDecorations | undefined {
return this.command instanceof SubstituteCommand &&
vimState.currentMode === Mode.CommandlineInProgress
? this.command.getSubstitutionDecorations(vimState, this.lineRange)
: undefined;
}
public getHistory(): HistoryFile {
return ExCommandLine.history;
}
public async run(vimState: VimState): Promise<void> {
Logger.info(`Executing :${this.text}`);
void ExCommandLine.history.add(this.text);
this.historyIndex = ExCommandLine.history.get().length;
if (!(this.command instanceof RegisterCommand)) {
// TODO(jfields): Wait...why are we saving the `:` register as a RecordedState?
const recState = new RecordedState();
recState.registerName = ':';
recState.commandList = this.text.split('');
Register.setReadonlyRegister(':', recState);
}
try {
if (this.command === undefined) {
// TODO: A bit gross:
ExCommandLine.parser.tryParse(this.text);
throw new Error(`Expected parsing ExCommand '${this.text}' to fail`);
}
const useNeovim = configuration.enableNeovim && this.command.neovimCapable();
if (useNeovim && vimState.nvim) {
const { statusBarText, error } = await vimState.nvim.run(vimState, this.text);
StatusBar.setText(vimState, statusBarText, error);
} else {
if (this.lineRange) {
await this.command.executeWithRange(vimState, this.lineRange);
} else {
await this.command.execute(vimState);
}
}
} catch (e) {
if (e instanceof VimError) {
if (
e.code === ErrorCode.NotAnEditorCommand &&
configuration.enableNeovim &&
vimState.nvim
) {
const { statusBarText } = await vimState.nvim.run(vimState, this.text);
StatusBar.setText(vimState, statusBarText, true);
} else {
StatusBar.setText(vimState, e.toString(), true);
}
} else {
Logger.error(`Error executing cmd=${this.text}. err=${e}.`);
}
}
// Update state if this command is repeatable via dot command.
vimState.lastCommandDotRepeatable = this.command?.isRepeatableWithDot ?? false;
}
public async escape(vimState: VimState): Promise<void> {
await vimState.setCurrentMode(Mode.Normal);
if (this.text.length > 0) {
void ExCommandLine.history.add(this.text);
}
}
public async ctrlF(vimState: VimState): Promise<void> {
void ExCommandLine.onSearch(vimState);
}
}
export class SearchCommandLine extends CommandLine {
public static history: SearchHistory;
public static readonly previousSearchStates: SearchState[] = [];
public static onSearch: (vimState: VimState, direction: SearchDirection) => Promise<void>;
/**
* Shows the search history as a QuickPick (popup list)
*
* @returns The SearchState that was selected by the user, if there was one.
*/
public static async showSearchHistory(): Promise<SearchState | undefined> {
const items = SearchCommandLine.previousSearchStates
.slice()
.reverse()
.map((searchState) => {
return {
label: searchState.searchString,
searchState,
};
});
const item = await window.showQuickPick(items, {
placeHolder: 'Vim search history',
ignoreFocusOut: false,
});
return item?.searchState;
}
public static async loadHistory(context: ExtensionContext): Promise<void> {
SearchCommandLine.history = new SearchHistory(context);
SearchCommandLine.history
.get()
.forEach((val) =>
SearchCommandLine.previousSearchStates.push(
new SearchState(SearchDirection.Forward, new Position(0, 0), val, undefined),
),
);
}
public static async addSearchStateToHistory(searchState: SearchState) {
const prevSearchString =
SearchCommandLine.previousSearchStates.length === 0
? undefined
: SearchCommandLine.previousSearchStates[SearchCommandLine.previousSearchStates.length - 1]
.searchString;
// Store this search if different than previous
if (searchState.searchString !== prevSearchString) {
SearchCommandLine.previousSearchStates.push(searchState);
if (SearchCommandLine.history !== undefined) {
await SearchCommandLine.history.add(searchState.searchString);
}
}
// Make sure search history does not exceed configuration option
if (SearchCommandLine.previousSearchStates.length > configuration.history) {
SearchCommandLine.previousSearchStates.splice(0, 1);
}
}
/**
* Keeps the state of the current match, i.e. the match to which the cursor moves when the search is executed.
* Incremented / decremented by \<C-g> or \<C-t> in SearchInProgress mode.
* Resets to 0 if the search string becomes empty.
*
* @see {@link getCurrentMatchRelativeIndex}
*/
private currentMatchDisplacement: number = 0;
private searchState: SearchState;
constructor(vimState: VimState, searchString: string, direction: SearchDirection) {
super(searchString, vimState.currentMode);
this.searchState = new SearchState(direction, vimState.cursorStopPosition, searchString);
}
public display(cursorChar: string): string {
return escapeCSSIcons(
`${this.searchState.direction === SearchDirection.Forward ? '/' : '?'}${this.text.substring(
0,
this.cursorIndex,
)}${cursorChar}${this.text.substring(this.cursorIndex)}`,
);
}
public get text(): string {
return this.searchState.searchString;
}
public set text(text: string) {
this.searchState.searchString = text;
if (text === '') {
this.currentMatchDisplacement = 0;
}
}
public getSearchState(): SearchState {
return this.searchState;
}
public getHistory(): HistoryFile {
return SearchCommandLine.history;
}
/**
* @returns the index of the current match, relative to the next match.
*/
private getCurrentMatchRelativeIndex(vimState: VimState): number {
const count = vimState.recordedState.count || 1;
return count - 1 + this.currentMatchDisplacement * count;
}
/**
* @returns The start of the current match range (after applying the search offset) and its rank in the document's matches
*/
public getCurrentMatchPosition(vimState: VimState): IndexedPosition | undefined {
return this.searchState.getNextSearchMatchPosition(
vimState,
vimState.cursorStopPosition,
SearchDirection.Forward,
this.getCurrentMatchRelativeIndex(vimState),
);
}
/**
* @returns The current match range and its rank in the document's matches
*
* NOTE: This method does not take the search offset into account
*/
public getCurrentMatchRange(vimState: VimState): IndexedRange | undefined {
return this.searchState.getNextSearchMatchRange(
vimState,
vimState.cursorStopPosition,
SearchDirection.Forward,
this.getCurrentMatchRelativeIndex(vimState),
);
}
public getDecorations(vimState: VimState): SearchDecorations | undefined {
return getDecorationsForSearchMatchRanges(
this.searchState.getMatchRanges(vimState),
configuration.incsearch && vimState.currentMode === Mode.SearchInProgressMode
? this.getCurrentMatchRange(vimState)?.index
: undefined,
);
}
public async run(vimState: VimState): Promise<void> {
// Repeat the previous search if no new string is entered
if (this.text === '') {
if (SearchCommandLine.previousSearchStates.length > 0) {
this.text =
SearchCommandLine.previousSearchStates[
SearchCommandLine.previousSearchStates.length - 1
].searchString;
}
}
Logger.info(`Searching for ${this.text}`);
this.cursorIndex = 0;
Register.setReadonlyRegister('/', this.text);
void SearchCommandLine.addSearchStateToHistory(this.searchState);
globalState.hl = true;
if (this.searchState.getMatchRanges(vimState).length === 0) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.PatternNotFound, this.text));
return;
}
const currentMatch = this.getCurrentMatchPosition(vimState);
if (currentMatch === undefined) {
StatusBar.displayError(
vimState,
VimError.fromCode(
this.searchState.direction === SearchDirection.Backward
? ErrorCode.SearchHitTop
: ErrorCode.SearchHitBottom,
this.text,
),
);
return;
}
vimState.cursorStopPosition = currentMatch.pos;
reportSearch(currentMatch.index, this.searchState.getMatchRanges(vimState).length, vimState);
}
public async escape(vimState: VimState): Promise<void> {
vimState.cursorStopPosition = this.searchState.cursorStartPosition;
const prevSearchList = SearchCommandLine.previousSearchStates;
globalState.searchState = prevSearchList
? prevSearchList[prevSearchList.length - 1]
: undefined;
if (vimState.modeData.mode === Mode.SearchInProgressMode) {
const offset =
vimState.editor.visibleRanges[0].start.line -
vimState.modeData.firstVisibleLineBeforeSearch;
scrollView(vimState, offset);
}
await vimState.setCurrentMode(this.previousMode);
if (this.text.length > 0) {
void SearchCommandLine.addSearchStateToHistory(this.searchState);
}
}
public async ctrlF(vimState: VimState): Promise<void> {
await SearchCommandLine.onSearch(vimState, this.searchState.direction);
}
/**
* Called when <C-g> or <C-t> is pressed during SearchInProgress mode
*/
public async advanceCurrentMatch(vimState: VimState, direction: SearchDirection): Promise<void> {
// <C-g> always moves forward in the document, and <C-t> always moves back, regardless of search direction.
// To compensate, multiply the desired direction by the searchState's direction, so that
// effectiveDirection == direction * (searchState.direction)^2 == direction.
this.currentMatchDisplacement += this.searchState.direction * direction;
// With nowrapscan, <C-g>/<C-t> shouldn't do anything if it would mean advancing past the last reachable match in the buffer.
// We account for this by checking whether getCurrentMatchRange returns undefined once this.currentMatchDisplacement is advanced.
// If it does, we undo the change to this.currentMatchDisplacement before exiting, making this command a noop.
if (!configuration.wrapscan && !this.getCurrentMatchRange(vimState)) {
this.currentMatchDisplacement -= this.searchState.direction * direction;
}
}
}

View File

@ -0,0 +1,9 @@
import { CommandUnicodeName } from '../../actions/commands/actions';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
export class AsciiCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
await new CommandUnicodeName().exec(vimState.cursorStopPosition, vimState);
}
}

View File

@ -0,0 +1,63 @@
import { VimState } from '../../state/vimState';
import { PositionDiff } from '../../common/motion/position';
import { externalCommand } from '../../util/externalCommand';
import { LineRange } from '../../vimscript/lineRange';
import { ExCommand } from '../../vimscript/exCommand';
import { all, Parser } from 'parsimmon';
export interface IBangCommandArguments {
command: string;
}
export class BangCommand extends ExCommand {
public static readonly argParser: Parser<BangCommand> = all.map(
(command) =>
new BangCommand({
command,
}),
);
protected _arguments: IBangCommandArguments;
constructor(args: IBangCommandArguments) {
super();
this._arguments = args;
}
public override neovimCapable(): boolean {
return true;
}
private getReplaceDiff(text: string): PositionDiff {
const lines = text.split('\n');
const numNewlines = lines.length - 1;
const check = lines[0].match(/^\s*/);
const numWhitespace = check ? check[0].length : 0;
return PositionDiff.exactCharacter({
lineOffset: -numNewlines,
character: numWhitespace,
});
}
async execute(vimState: VimState): Promise<void> {
await externalCommand.run(this._arguments.command);
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const resolvedRange = range.resolveToRange(vimState);
// pipe in stdin from lines in range
const input = vimState.document.getText(resolvedRange);
const output = await externalCommand.run(this._arguments.command, input);
// place cursor at the start of the replaced text and first non-whitespace character
const diff = this.getReplaceDiff(output);
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
text: output,
range: resolvedRange,
diff,
});
}
}

View File

@ -0,0 +1,249 @@
import {
all,
alt,
eof,
optWhitespace,
regexp,
seqObj,
// eslint-disable-next-line id-denylist
string,
succeed,
whitespace,
} from 'parsimmon';
import * as path from 'path';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { fileNameParser, numberParser } from '../../vimscript/parserUtils';
function isSourceBreakpoint(b: vscode.Breakpoint): b is vscode.SourceBreakpoint {
return (b as vscode.SourceBreakpoint).location !== undefined;
}
function isFunctionBreakpoint(b: vscode.Breakpoint): b is vscode.FunctionBreakpoint {
return (b as vscode.FunctionBreakpoint).functionName !== undefined;
}
/**
* Add Breakpoint Command
*/
type AddBreakpointHere = { type: 'here' };
type AddBreakpointFile = { type: 'file'; line: number; file: string };
type AddBreakpointFunction = { type: 'func'; function: string };
type AddBreakpointExpr = { type: 'expr'; expr: string };
type AddBreakpoint =
| AddBreakpointHere
| AddBreakpointFile
| AddBreakpointFunction
| AddBreakpointExpr;
class AddBreakpointCommand extends ExCommand {
public override isRepeatableWithDot: boolean = false;
private readonly addBreakpoint: AddBreakpoint;
constructor(addBreakpoint: AddBreakpoint) {
super();
this.addBreakpoint = addBreakpoint;
}
async execute(vimState: VimState): Promise<void> {
if (this.addBreakpoint.type === 'here') {
const location = new vscode.Location(vimState.document.uri, vimState.cursorStartPosition);
return vscode.debug.addBreakpoints([new vscode.SourceBreakpoint(location)]);
} else if (this.addBreakpoint.type === 'file') {
let file: vscode.Uri;
if (this.addBreakpoint.file === '') {
file = vimState.document.uri;
} else {
const workspaceFolder =
vscode.workspace.getWorkspaceFolder(vimState.document.uri)?.uri ?? vscode.Uri.file('./');
file = vscode.Uri.joinPath(workspaceFolder, this.addBreakpoint.file);
}
const location = new vscode.Location(
file,
new vscode.Position(this.addBreakpoint.line - 1, 0),
);
return vscode.debug.addBreakpoints([new vscode.SourceBreakpoint(location)]);
} else if (this.addBreakpoint.type === 'func') {
return vscode.debug.addBreakpoints([
new vscode.FunctionBreakpoint(this.addBreakpoint.function),
]);
} else if (this.addBreakpoint.type === 'expr') {
const location = new vscode.Location(vimState.document.uri, vimState.cursorStartPosition);
return vscode.debug.addBreakpoints([
new vscode.SourceBreakpoint(location, undefined, this.addBreakpoint.expr),
]);
}
}
}
/**
* Delete Breakpoint Command
*/
type DelBreakpointById = { type: 'byId'; id: number };
type DelAllBreakpoints = { type: 'all' };
type DelBreakpointFunction = { type: 'func'; function: string };
type DelBreakpointFile = { type: 'file'; line: number; file: string };
type DelBreakpointHere = { type: 'here' };
type DelBreakpoint =
| DelBreakpointById
| DelAllBreakpoints
| DelBreakpointFunction
| DelBreakpointFile
| DelBreakpointHere;
class DeleteBreakpointCommand extends ExCommand {
public override isRepeatableWithDot: boolean = false;
private readonly delBreakpoint: DelBreakpoint;
constructor(delBreakpoint: DelBreakpoint) {
super();
this.delBreakpoint = delBreakpoint;
}
async execute(vimState: VimState): Promise<void> {
if (this.delBreakpoint.type === 'byId') {
return vscode.debug.removeBreakpoints(
vscode.debug.breakpoints.slice(this.delBreakpoint.id - 1, 1),
);
} else if (this.delBreakpoint.type === 'all') {
return vscode.debug.removeBreakpoints(vscode.debug.breakpoints);
} else if (this.delBreakpoint.type === 'file') {
let reqUri: vscode.Uri;
if (this.delBreakpoint.file === '') {
reqUri = vimState.document.uri;
} else {
const workspaceFolder =
vscode.workspace.getWorkspaceFolder(vimState.document.uri)?.uri ?? vscode.Uri.file('./');
reqUri = vscode.Uri.joinPath(workspaceFolder, this.delBreakpoint.file);
}
const reqLine = this.delBreakpoint.line - 1;
const breakpoint = vscode.debug.breakpoints
.filter(isSourceBreakpoint)
.find(
(b) =>
b.location.uri.toString() === reqUri.toString() &&
b.location.range.start.line === reqLine,
);
if (breakpoint) return vscode.debug.removeBreakpoints([breakpoint]);
} else if (this.delBreakpoint.type === 'func') {
const functionName = this.delBreakpoint.function;
const breakpoint = vscode.debug.breakpoints
.filter(isFunctionBreakpoint)
.filter((b) => b.functionName === functionName);
if (breakpoint) return vscode.debug.removeBreakpoints(breakpoint);
} else if (this.delBreakpoint.type === 'here') {
const location = new vscode.Location(vimState.document.uri, vimState.cursorStartPosition);
const distFromLocationCharacter = (b: vscode.SourceBreakpoint) =>
Math.abs(b.location.range.start.character - location.range.start.character);
const breakpoint = vscode.debug.breakpoints
.filter(isSourceBreakpoint)
.filter(
(b) =>
b.location.uri.toString() === location.uri.toString() &&
b.location.range.start.line === location.range.start.line,
)
.sort((a, b) => distFromLocationCharacter(a) - distFromLocationCharacter(b))[0];
if (breakpoint) return vscode.debug.removeBreakpoints([breakpoint]);
}
}
}
/**
* List Breakpoints Command
*/
class ListBreakpointsCommand extends ExCommand {
public override isRepeatableWithDot: boolean = false;
async execute(vimState: VimState): Promise<void> {
const breakpoints = vscode.debug.breakpoints;
type BreakpointQuickPick = { breakpointId: string } & vscode.QuickPickItem;
const lines = breakpoints.map((b, i): BreakpointQuickPick => {
const { id, enabled, condition } = b;
let label = '';
label += `#${i + 1}\t`;
label += enabled ? '$(circle-filled)\t' : '$(circle-outline)\t';
label += condition ? '$(debug-breakpoint-conditional)\t' : '\t';
if (isSourceBreakpoint(b))
label += `${path.basename(b.location.uri.fsPath)}:${b.location.range.start.line + 1}`;
if (isFunctionBreakpoint(b)) label += `$(debug-breakpoint-function)${b.functionName}`;
return {
label,
breakpointId: id,
};
});
await vscode.window.showQuickPick(lines).then(async (selected) => {
if (selected) {
const id = selected.breakpointId;
const breakpoint = breakpoints.find((b) => b.id === id);
if (breakpoint && isSourceBreakpoint(breakpoint)) {
await vscode.window.showTextDocument(breakpoint.location.uri).then(() => {
vimState.cursorStopPosition = breakpoint.location.range.start;
});
}
}
});
}
}
export class Breakpoints {
public static readonly argParsers = {
add: whitespace
.then(
alt(
// here
seqObj<AddBreakpointHere>(['type', string('here')], optWhitespace),
// file
seqObj<AddBreakpointFile>(
['type', string('file')],
['line', optWhitespace.then(numberParser).fallback(1)],
['file', optWhitespace.then(fileNameParser).fallback('')],
),
// func
seqObj<AddBreakpointFunction>(
['type', string('func')],
optWhitespace.then(numberParser).fallback(1), // we don't support line numbers in function names, but Vim does, so we'll allow it.
['function', optWhitespace.then(regexp(/\S+/))],
),
// expr
seqObj<AddBreakpointExpr>(['type', string('expr')], ['expr', optWhitespace.then(all)]),
),
)
.or(
// without arg
eof.result<DelBreakpointHere>({ type: 'here' }),
)
.map((a: AddBreakpoint) => new AddBreakpointCommand(a)),
del: whitespace
.then(
alt(
// here
seqObj<DelBreakpointHere>(['type', string('here')], optWhitespace),
// file
seqObj<DelBreakpointFile>(
['type', string('file')],
['line', optWhitespace.then(numberParser).fallback(1)],
['file', optWhitespace.then(fileNameParser).fallback('')],
),
// func
seqObj<DelBreakpointFunction>(
['type', string('func')],
optWhitespace.then(numberParser).fallback(1), // we don't support line numbers in function names, but Vim does, so we'll allow it.
['function', optWhitespace.then(regexp(/\S+/))],
),
// all
string('*').then(optWhitespace).result<DelAllBreakpoints>({ type: 'all' }),
// by number
numberParser.map((n) => ({ type: 'byId', id: n })),
),
)
.or(
// without arg
eof.result<DelBreakpointHere>({ type: 'here' }),
)
.map((a: DelBreakpoint) => new DeleteBreakpointCommand(a)),
list: succeed(new ListBreakpointsCommand()),
};
}

View File

@ -0,0 +1,60 @@
import { alt, optWhitespace, Parser, seq, whitespace } from 'parsimmon';
import * as vscode from 'vscode';
import * as error from '../../error';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser, fileNameParser, numberParser } from '../../vimscript/parserUtils';
interface IBufferDeleteCommandArguments {
bang: boolean;
buffers: Array<string | number>;
}
//
// Implements :bd
// http://vimdoc.sourceforge.net/htmldoc/windows.html#buffers
//
export class BufferDeleteCommand extends ExCommand {
public static readonly argParser: Parser<BufferDeleteCommand> = seq(
bangParser.skip(optWhitespace),
alt<string | number>(numberParser, fileNameParser).sepBy(whitespace),
).map(([bang, buffers]) => new BufferDeleteCommand({ bang, buffers }));
public readonly arguments: IBufferDeleteCommandArguments;
constructor(args: IBufferDeleteCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
if (vimState.document.isDirty && !this.arguments.bang) {
throw error.VimError.fromCode(error.ErrorCode.NoWriteSinceLastChange);
}
if (this.arguments.buffers.length === 0) {
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
} else {
for (const buffer of this.arguments.buffers) {
if (typeof buffer === 'string') {
// TODO
StatusBar.setText(
vimState,
':bd[elete][!] {bufname} is not yet implemented (PRs are welcome!)',
true,
);
continue;
}
try {
await vscode.commands.executeCommand(`workbench.action.openEditorAtIndex${buffer}`);
} catch (e) {
throw error.VimError.fromCode(error.ErrorCode.NoBuffersDeleted);
}
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
}
}
}
}

View File

@ -0,0 +1,43 @@
import { Parser } from 'parsimmon';
import * as vscode from 'vscode';
import * as error from '../../error';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser } from '../../vimscript/parserUtils';
//
// Implements :close
// http://vimdoc.sourceforge.net/htmldoc/windows.html#:close
//
export class CloseCommand extends ExCommand {
public static readonly argParser: Parser<CloseCommand> = bangParser.map(
(bang) => new CloseCommand(bang),
);
public readonly bang: boolean;
constructor(bang: boolean) {
super();
this.bang = bang;
}
async execute(vimState: VimState): Promise<void> {
if (vimState.document.isDirty && !this.bang) {
throw error.VimError.fromCode(error.ErrorCode.NoWriteSinceLastChange);
}
if (vscode.window.visibleTextEditors.length === 1) {
throw error.VimError.fromCode(error.ErrorCode.CannotCloseLastWindow);
}
const oldViewColumn = vimState.editor.viewColumn;
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
if (
vscode.window.activeTextEditor !== undefined &&
vscode.window.activeTextEditor.viewColumn === oldViewColumn
) {
await vscode.commands.executeCommand('workbench.action.previousEditor');
}
}
}

View File

@ -0,0 +1,72 @@
import { optWhitespace, Parser } from 'parsimmon';
import { Position, Range } from 'vscode';
import { PositionDiff } from '../../common/motion/position';
import { ErrorCode, VimError } from '../../error';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
export class CopyCommand extends ExCommand {
public static readonly argParser: Parser<CopyCommand> = optWhitespace
.then(Address.parser.fallback(undefined))
.map((address) => new CopyCommand(address));
private address?: Address;
constructor(address?: Address) {
super();
this.address = address;
}
public override neovimCapable(): boolean {
return true;
}
private copyLines(vimState: VimState, sourceStart: number, sourceEnd: number) {
const dest = this.address?.resolve(vimState, 'left', false);
if (dest === undefined || dest < -1 || dest > vimState.document.lineCount) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.InvalidAddress));
return;
}
if (sourceEnd < sourceStart) {
[sourceStart, sourceEnd] = [sourceEnd, sourceStart];
}
const copiedText = vimState.document.getText(
new Range(new Position(sourceStart, 0), new Position(sourceEnd, 0).getLineEnd()),
);
let text: string;
let position: Position;
if (dest === -1) {
text = copiedText + '\n';
position = new Position(0, 0);
} else {
text = '\n' + copiedText;
position = new Position(dest, 0).getLineEnd();
}
const lines = copiedText.split('\n');
const cursorPosition = new Position(
Math.max(dest + lines.length, 0),
lines[lines.length - 1].match(/\S/)?.index ?? 0,
);
vimState.recordedState.transformer.insert(
position,
text,
PositionDiff.exactPosition(cursorPosition),
);
}
public async execute(vimState: VimState): Promise<void> {
const line = vimState.cursors[0].stop.line;
this.copyLines(vimState, line, line);
}
public override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
this.copyLines(vimState, start, end);
}
}

View File

@ -0,0 +1,107 @@
import * as vscode from 'vscode';
// eslint-disable-next-line id-denylist
import { Parser, alt, any, optWhitespace, seq, whitespace } from 'parsimmon';
import { Position } from 'vscode';
import { Register, RegisterMode } from '../../register/register';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
export interface IDeleteCommandArguments {
register?: string;
count?: number;
}
export class DeleteCommand extends ExCommand {
// TODO: this is copy-pasted from `:y[ank]`
public static readonly argParser: Parser<DeleteCommand> = optWhitespace.then(
alt(
numberParser.map((count) => {
return { register: undefined, count };
}),
// eslint-disable-next-line id-denylist
seq(any.fallback(undefined), whitespace.then(numberParser).fallback(undefined)).map(
([register, count]) => {
return { register, count };
},
),
).map(
({ register, count }) =>
new DeleteCommand({
register,
count,
}),
),
);
private readonly arguments: IDeleteCommandArguments;
constructor(args: IDeleteCommandArguments) {
super();
this.arguments = args;
}
public override neovimCapable(): boolean {
return true;
}
/**
* Deletes text between `startLine` and `endLine`, inclusive.
* Puts the cursor at the start of the line where the deleted range was.
*/
private deleteRange(startLine: number, endLine: number, vimState: VimState): void {
let start = new Position(startLine, 0);
let end = new Position(endLine, 0).getLineEndIncludingEOL();
if (endLine < vimState.document.lineCount - 1) {
end = end.getRightThroughLineBreaks();
} else if (startLine > 0) {
start = start.getLeftThroughLineBreaks();
}
const range = new vscode.Range(start, end);
const text = vimState.document
.getText(range)
// Remove leading or trailing newline
.replace(/^\r?\n/, '')
.replace(/\r?\n$/, '');
vimState.recordedState.transformer.addTransformation({
type: 'deleteRange',
range: new vscode.Range(start, end),
manuallySetCursorPositions: true,
});
vimState.cursorStopPosition = start.getLineBegin();
if (this.arguments.register) {
vimState.recordedState.registerName = this.arguments.register;
}
vimState.currentRegisterMode = RegisterMode.LineWise;
Register.put(vimState, text, 0, true);
}
async execute(vimState: VimState): Promise<void> {
const linesToRemove = this.arguments.count ?? 1;
// :d[elete][cnt] removes [cnt] lines
const startLine = vimState.cursorStartPosition.line;
const endLine = startLine + (linesToRemove - 1);
this.deleteRange(startLine, endLine, vimState);
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
/**
* If a [cnt] and [range] is specified (e.g. :.+2d3), :delete [cnt] is called from
* the end of the [range].
* Ex. if two lines are VisualLine highlighted, :<,>d3 will :d3
* from the end of the selected lines.
*/
const { start, end } = range.resolve(vimState);
if (this.arguments.count) {
vimState.cursorStartPosition = new Position(end, 0);
await this.execute(vimState);
return;
}
this.deleteRange(start, end, vimState);
}
}

View File

@ -0,0 +1,65 @@
import * as vscode from 'vscode';
// eslint-disable-next-line id-denylist
import { any, Parser, seq, whitespace } from 'parsimmon';
import { DefaultDigraphs } from '../../actions/commands/digraphs';
import { Digraph } from '../../configuration/iconfiguration';
import { VimState } from '../../state/vimState';
import { TextEditor } from '../../textEditor';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser, numberParser } from '../../vimscript/parserUtils';
import { configuration } from './../../configuration/configuration';
export interface IDigraphsCommandArguments {
bang: boolean;
newDigraph: [string, string, number[]] | undefined;
}
interface DigraphQuickPickItem extends vscode.QuickPickItem {
charCodes: number[];
}
export class DigraphsCommand extends ExCommand {
public static readonly argParser: Parser<DigraphsCommand> = seq(
bangParser,
whitespace.then(seq(any, any, whitespace.then(numberParser).atLeast(1))).fallback(undefined),
).map(([bang, newDigraph]) => new DigraphsCommand({ bang, newDigraph }));
private readonly arguments: IDigraphsCommandArguments;
constructor(args: IDigraphsCommandArguments) {
super();
this.arguments = args;
}
private makeQuickPicks(digraphs: Array<[string, Digraph]>): DigraphQuickPickItem[] {
return digraphs.map(([shortcut, [charDesc, charCodes]]) => {
if (!Array.isArray(charCodes)) {
charCodes = [charCodes];
}
return {
label: shortcut,
description: `${charDesc} (user)`,
charCodes,
};
});
}
async execute(vimState: VimState): Promise<void> {
if (this.arguments.newDigraph) {
const digraph = this.arguments.newDigraph[0] + this.arguments.newDigraph[1];
const charCodes = this.arguments.newDigraph[2];
DefaultDigraphs.set(digraph, [String.fromCharCode(...charCodes), charCodes]);
} else {
const digraphKeyAndContent = this.makeQuickPicks(
Object.entries(configuration.digraphs),
).concat(this.makeQuickPicks([...DefaultDigraphs.entries()]));
void vscode.window.showQuickPick(digraphKeyAndContent).then(async (val) => {
if (val) {
const char = String.fromCharCode(...val.charCodes);
await TextEditor.insert(vimState.editor, char);
}
});
}
}
}

View File

@ -0,0 +1,250 @@
import * as vscode from 'vscode';
import { Logger } from '../../util/logger';
import { getPathDetails, resolveUri } from '../../util/path';
import { doesFileExist } from 'platform/fs';
import untildify = require('untildify');
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import {
bangParser,
FileCmd,
fileCmdParser,
fileNameParser,
FileOpt,
fileOptParser,
} from '../../vimscript/parserUtils';
import { optWhitespace, regexp, seq } from 'parsimmon';
export enum FilePosition {
NewWindowVerticalSplit,
NewWindowHorizontalSplit,
}
export type IFileCommandArguments =
| {
name: 'edit';
bang: boolean;
opt: FileOpt;
cmd?: FileCmd;
file?: string;
createFileIfNotExists?: boolean;
}
| {
name: 'enew';
bang: boolean;
createFileIfNotExists?: boolean;
}
| {
name: 'new' | 'vnew' | 'split' | 'vsplit';
opt: FileOpt;
cmd?: FileCmd;
file?: string;
createFileIfNotExists?: boolean;
};
// TODO: This is a hack to get this to work in the short term with new arg parsing logic.
type LegacyArgs = {
file?: string;
bang?: boolean;
position?: FilePosition;
cmd?: FileCmd;
createFileIfNotExists?: boolean;
};
function getLegacyArgs(args: IFileCommandArguments): LegacyArgs {
if (args.name === 'edit') {
return { file: args.file, bang: args.bang, cmd: args.cmd, createFileIfNotExists: true };
} else if (args.name === 'enew') {
return { bang: args.bang, createFileIfNotExists: true };
} else if (args.name === 'new') {
return {
file: args.file,
position: FilePosition.NewWindowHorizontalSplit,
createFileIfNotExists: true,
};
} else if (args.name === 'vnew') {
return {
file: args.file,
position: FilePosition.NewWindowVerticalSplit,
createFileIfNotExists: true,
};
} else if (args.name === 'split') {
return {
file: args.file,
position: FilePosition.NewWindowHorizontalSplit,
// only to create if file arg is specified
createFileIfNotExists: args.file !== undefined,
};
} else if (args.name === 'vsplit') {
return {
file: args.file,
position: FilePosition.NewWindowVerticalSplit,
// only to create if file arg is specified
createFileIfNotExists: args.file !== undefined,
};
} else {
throw new Error(`Unexpected FileCommand.arguments.name: ${args.name}`);
}
}
export class FileCommand extends ExCommand {
// TODO: There's a lot of duplication here
// TODO: These `optWhitespace` calls should be `whitespace`
public static readonly argParsers = {
edit: seq(
bangParser,
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(fileNameParser).fallback(undefined),
).map(([bang, opt, cmd, file]) => new FileCommand({ name: 'edit', bang, opt, cmd, file })),
enew: bangParser.map((bang) => new FileCommand({ name: 'enew', bang })),
new: seq(
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(fileNameParser).fallback(undefined),
).map(([opt, cmd, file]) => new FileCommand({ name: 'new', opt, cmd, file })),
split: seq(
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(fileNameParser).fallback(undefined),
).map(([opt, cmd, file]) => new FileCommand({ name: 'split', opt, cmd, file })),
vnew: seq(
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(fileNameParser).fallback(undefined),
).map(([opt, cmd, file]) => new FileCommand({ name: 'vnew', opt, cmd, file })),
vsplit: seq(
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(fileNameParser).fallback(undefined),
).map(([opt, cmd, file]) => new FileCommand({ name: 'vsplit', opt, cmd, file })),
};
private readonly arguments: IFileCommandArguments;
constructor(args: IFileCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
const args = getLegacyArgs(this.arguments);
if (args.bang) {
await vscode.commands.executeCommand('workbench.action.files.revert');
return;
}
// Need to do this before the split since it loses the activeTextEditor
const editorFileUri = vscode.window.activeTextEditor!.document.uri;
const editorFilePath = editorFileUri.fsPath;
// Do the split if requested
let split = false;
if (args.position === FilePosition.NewWindowVerticalSplit) {
await vscode.commands.executeCommand('workbench.action.splitEditorRight');
split = true;
}
if (args.position === FilePosition.NewWindowHorizontalSplit) {
await vscode.commands.executeCommand('workbench.action.splitEditorDown');
split = true;
}
const hidePreviousEditor = async () => {
if (split === true) {
await vscode.commands.executeCommand('workbench.action.previousEditor');
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
}
};
// No file was specified
if (args.file === undefined) {
if (args.createFileIfNotExists === true) {
await vscode.commands.executeCommand('workbench.action.files.newUntitledFile');
await hidePreviousEditor();
}
return;
}
// Only untidify when the currently open page and file completion is local
if (args.file && editorFileUri.scheme === 'file') {
args.file = untildify(args.file);
}
let fileUri = editorFileUri;
// Using the empty string will request to open a file
if (args.file === '') {
// No file on split is fine and just return
if (split === true) {
return;
}
const fileList = await vscode.window.showOpenDialog({});
if (fileList && fileList.length > 0) {
fileUri = fileList[0];
}
} else {
// remove file://
args.file = args.file.replace(/^file:\/\//, '');
// Using a filename, open or create the file
const isRemote = !!vscode.env.remoteName;
const { fullPath, path: p } = getPathDetails(args.file, editorFileUri, isRemote);
// Only if the expanded path of the full path is different than
// the currently opened window path
if (fullPath !== editorFilePath) {
const uriPath = resolveUri(fullPath, p.sep, editorFileUri, isRemote);
if (uriPath === null) {
// return if the path is invalid
return;
}
let fileExists = await doesFileExist(uriPath);
if (fileExists) {
// If the file without the added ext exists
fileUri = uriPath;
} else {
// if file does not exist
// try to find it with the same extension as the current file
const pathWithExt = fullPath + p.extname(editorFilePath);
const uriPathWithExt = resolveUri(pathWithExt, p.sep, editorFileUri, isRemote);
if (uriPathWithExt !== null) {
fileExists = await doesFileExist(uriPathWithExt);
if (fileExists) {
// if the file with the added ext exists
fileUri = uriPathWithExt;
}
}
}
// If both with and without ext path do not exist
if (!fileExists) {
if (args.createFileIfNotExists) {
// Change the scheme to untitled to open an
// untitled tab
fileUri = uriPath.with({ scheme: 'untitled' });
} else {
Logger.error(`${args.file} does not exist.`);
return;
}
}
}
}
const doc = await vscode.workspace.openTextDocument(fileUri);
const editor = await vscode.window.showTextDocument(doc);
const lineNumber =
args.cmd?.type === 'line_number'
? args.cmd.line
: args.cmd?.type === 'last_line'
? vscode.window.activeTextEditor!.document.lineCount - 1
: undefined;
if (lineNumber !== undefined && lineNumber >= 0) {
const pos = new vscode.Position(lineNumber, 0);
editor.selection = new vscode.Selection(pos, pos);
const range = new vscode.Range(pos, pos);
editor.revealRange(range);
}
await hidePreviousEditor();
}
}

View File

@ -0,0 +1,26 @@
import { all, optWhitespace, Parser, seq } from 'parsimmon';
import { VimState } from '../../state/vimState';
import { reportFileInfo } from '../../util/statusBarTextUtils';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser } from '../../vimscript/parserUtils';
export class FileInfoCommand extends ExCommand {
public static readonly argParser: Parser<FileInfoCommand> = seq(
bangParser,
optWhitespace.then(all),
).map(([bang, fileName]) => new FileInfoCommand({ bang, fileName }));
private args: {
bang: boolean;
fileName?: string;
};
private constructor(args: { bang: boolean; fileName?: string }) {
super();
this.args = args;
}
async execute(vimState: VimState): Promise<void> {
// TODO: Use `this.args`
reportFileInfo(vimState.cursors[0].start, vimState);
}
}

View File

@ -0,0 +1,32 @@
import { optWhitespace, Parser } from 'parsimmon';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
export class GotoCommand extends ExCommand {
public static readonly argParser: Parser<GotoCommand> = optWhitespace
.then(numberParser.fallback(undefined))
.map((count) => new GotoCommand(count));
private offset?: number;
constructor(offset?: number) {
super();
this.offset = offset;
}
private gotoOffset(vimState: VimState, offset: number) {
vimState.cursorStopPosition = vimState.document.positionAt(offset);
}
public async execute(vimState: VimState): Promise<void> {
this.gotoOffset(vimState, this.offset ?? 0);
}
public override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
if (this.offset === undefined) {
this.offset = range.resolve(vimState)?.end ?? 0;
}
this.gotoOffset(vimState, this.offset);
}
}

View File

@ -0,0 +1,15 @@
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
export class GotoLineCommand extends ExCommand {
public async execute(vimState: VimState): Promise<void> {
return;
}
public override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
vimState.cursorStartPosition = vimState.cursorStopPosition = vimState.cursorStopPosition
.with({ line: range.resolve(vimState).end })
.obeyStartOfLine(vimState.document);
}
}

View File

@ -0,0 +1,69 @@
// eslint-disable-next-line id-denylist
import { Parser, alt, optWhitespace, string } from 'parsimmon';
import {
CommandShowCommandHistory,
CommandShowSearchHistory,
} from '../../actions/commands/actions';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { nameAbbrevParser } from '../../vimscript/parserUtils';
import { SearchDirection } from '../../vimscript/pattern';
export enum HistoryCommandType {
Cmd,
Search,
Expr,
Input,
Debug,
All,
}
const historyTypeParser: Parser<HistoryCommandType> = alt(
alt(nameAbbrevParser('c', 'md'), string(':')).result(HistoryCommandType.Cmd),
alt(nameAbbrevParser('s', 'earch'), string('/')).result(HistoryCommandType.Search),
alt(nameAbbrevParser('e', 'xpr'), string('=')).result(HistoryCommandType.Expr),
alt(nameAbbrevParser('i', 'nput'), string('@')).result(HistoryCommandType.Input),
alt(nameAbbrevParser('d', 'ebug'), string('>')).result(HistoryCommandType.Debug),
nameAbbrevParser('a', 'll').result(HistoryCommandType.All),
);
export interface IHistoryCommandArguments {
type: HistoryCommandType;
// TODO: :history can also accept a range
}
// http://vimdoc.sourceforge.net/htmldoc/cmdline.html#:history
export class HistoryCommand extends ExCommand {
public static readonly argParser: Parser<HistoryCommand> = optWhitespace
.then(historyTypeParser.fallback(HistoryCommandType.Cmd))
.map((type) => new HistoryCommand({ type }));
private readonly arguments: IHistoryCommandArguments;
constructor(args: IHistoryCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
switch (this.arguments.type) {
case HistoryCommandType.Cmd:
await new CommandShowCommandHistory().exec(vimState.cursorStopPosition, vimState);
break;
case HistoryCommandType.Search:
await new CommandShowSearchHistory(SearchDirection.Forward).exec(
vimState.cursorStopPosition,
vimState,
);
break;
// TODO: Implement these
case HistoryCommandType.Expr:
throw new Error('Not implemented');
case HistoryCommandType.Input:
throw new Error('Not implemented');
case HistoryCommandType.Debug:
throw new Error('Not implemented');
case HistoryCommandType.All:
throw new Error('Not implemented');
}
}
}

View File

@ -0,0 +1,53 @@
import { QuickPickItem, window } from 'vscode';
import { Cursor } from '../../common/motion/cursor';
import { Jump } from '../../jumps/jump';
import { globalState } from '../../state/globalState';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
class JumpPickItem implements QuickPickItem {
jump: Jump;
label: string;
description?: string;
detail?: string;
picked?: boolean;
alwaysShow?: boolean;
constructor(jump: Jump, idx: number) {
this.jump = jump;
this.label = jump.fileName;
this.detail = `jump ${idx} line ${jump.position.line + 1} col ${jump.position.character}`;
try {
this.description = jump.document.lineAt(jump.position).text;
} catch (e) {
this.description = undefined;
}
}
}
export class JumpsCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
const jumpTracker = globalState.jumpTracker;
if (jumpTracker.hasJumps) {
const quickPickItems = jumpTracker.jumps.map((jump, idx) => new JumpPickItem(jump, idx));
const item = await window.showQuickPick(quickPickItems, {
canPickMany: false,
});
if (item && item.jump.document !== undefined) {
void window.showTextDocument(item.jump.document);
vimState.cursors = [new Cursor(item.jump.position, item.jump.position)];
}
} else {
void window.showInformationMessage('No jumps available');
}
}
}
export class ClearJumpsCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
const jumpTracker = globalState.jumpTracker;
jumpTracker.clearJumps();
}
}

View File

@ -0,0 +1,134 @@
import { optWhitespace, Parser } from 'parsimmon';
import { Range, TextLine } from 'vscode';
import { configuration } from '../../configuration/configuration';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
type LeftArgs = {
indent: number;
};
export class LeftCommand extends ExCommand {
public static readonly argParser: Parser<LeftCommand> = optWhitespace
.then(numberParser.fallback(0))
.map((indent) => new LeftCommand({ indent }));
private args: LeftArgs;
constructor(args: LeftArgs) {
super();
this.args = args;
}
async execute(vimState: VimState): Promise<void> {
void this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
const lines: TextLine[] = [];
for (let line = start; line <= end; line++) {
lines.push(vimState.document.lineAt(line));
}
vimState.recordedState.transformer.replace(
new Range(lines[0].range.start, lines[lines.length - 1].range.end),
lines
.map(
(line) =>
' '.repeat(this.args.indent) + line.text.slice(line.firstNonWhitespaceCharacterIndex),
)
.join('\n'),
);
}
}
type RightArgs = {
width: number;
};
export class RightCommand extends ExCommand {
public static readonly argParser: Parser<RightCommand> = optWhitespace
.then(numberParser.fallback(undefined))
.map((width) => new RightCommand({ width: width ?? configuration.textwidth }));
private args: RightArgs;
constructor(args: RightArgs) {
super();
this.args = args;
}
async execute(vimState: VimState): Promise<void> {
void this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
const lines: TextLine[] = [];
for (let line = start; line <= end; line++) {
lines.push(vimState.document.lineAt(line));
}
vimState.recordedState.transformer.replace(
new Range(lines[0].range.start, lines[lines.length - 1].range.end),
lines
.map((line) => {
const indent = ' '.repeat(
Math.max(
0,
this.args.width - (line.text.length - line.firstNonWhitespaceCharacterIndex),
),
);
return indent + line.text.slice(line.firstNonWhitespaceCharacterIndex);
})
.join('\n'),
);
}
}
type CenterArgs = {
width: number;
};
export class CenterCommand extends ExCommand {
public static readonly argParser: Parser<CenterCommand> = optWhitespace
.then(numberParser.fallback(undefined))
.map((width) => new CenterCommand({ width: width ?? configuration.textwidth }));
private args: CenterArgs;
constructor(args: CenterArgs) {
super();
this.args = args;
}
async execute(vimState: VimState): Promise<void> {
void this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
const lines: TextLine[] = [];
for (let line = start; line <= end; line++) {
lines.push(vimState.document.lineAt(line));
}
vimState.recordedState.transformer.replace(
new Range(lines[0].range.start, lines[lines.length - 1].range.end),
lines
.map((line) => {
const indent = ' '.repeat(
Math.max(
0,
this.args.width - (line.text.length - line.firstNonWhitespaceCharacterIndex),
) / 2,
);
return indent + line.text.slice(line.firstNonWhitespaceCharacterIndex);
})
.join('\n'),
);
}
}

View File

@ -0,0 +1,135 @@
import { QuickPickItem, window } from 'vscode';
// eslint-disable-next-line id-denylist
import { Parser, alt, noneOf, optWhitespace, regexp, seq, string, whitespace } from 'parsimmon';
import { Cursor } from '../../common/motion/cursor';
import { ErrorCode, VimError } from '../../error';
import { IMark } from '../../history/historyTracker';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
class MarkQuickPickItem implements QuickPickItem {
mark: IMark;
label: string;
description: string;
detail: string;
picked = false;
alwaysShow = false;
constructor(vimState: VimState, mark: IMark) {
this.mark = mark;
this.label = mark.name;
if (mark.document && mark.document !== vimState.document) {
this.description = mark.document.fileName;
} else {
this.description = vimState.document.lineAt(mark.position).text.trim();
}
this.detail = `line ${mark.position.line} col ${mark.position.character}`;
}
}
export class MarksCommand extends ExCommand {
public static readonly argParser: Parser<MarksCommand> = optWhitespace
.then(noneOf('|'))
.many()
.map((marks) => new MarksCommand(marks));
private marksFilter: string[];
constructor(marksFilter: string[]) {
super();
this.marksFilter = marksFilter;
}
async execute(vimState: VimState): Promise<void> {
const quickPickItems: MarkQuickPickItem[] = vimState.historyTracker
.getMarks()
.filter((mark) => {
return this.marksFilter.length === 0 || this.marksFilter.includes(mark.name);
})
.map((mark) => new MarkQuickPickItem(vimState, mark));
if (quickPickItems.length > 0) {
const item = await window.showQuickPick(quickPickItems, {
canPickMany: false,
});
if (item) {
vimState.cursors = [new Cursor(item.mark.position, item.mark.position)];
}
} else {
void window.showInformationMessage('No marks set');
}
}
}
type DeleteMarksArgs = Array<{ start: string; end: string } | string> | '!';
export class DeleteMarksCommand extends ExCommand {
public static readonly argParser: Parser<DeleteMarksCommand> = alt<DeleteMarksArgs>(
string('!'),
whitespace.then(
optWhitespace
.then(
alt<{ start: string; end: string } | string>(
seq(regexp(/[a-z]/).skip(string('-')), regexp(/[a-z]/)).map(([start, end]) => {
return { start, end };
}),
seq(regexp(/[A-Z]/).skip(string('-')), regexp(/[A-Z]/)).map(([start, end]) => {
return { start, end };
}),
seq(regexp(/[0-9]/).skip(string('-')), regexp(/[0-9]/)).map(([start, end]) => {
return { start, end };
}),
noneOf('-'),
),
)
.many(),
),
).map((marks) => new DeleteMarksCommand(marks));
private args: DeleteMarksArgs;
constructor(args: DeleteMarksArgs) {
super();
this.args = args;
}
private static resolveMarkList(vimState: VimState, args: DeleteMarksArgs) {
const asciiRange = (start: string, end: string) => {
if (start > end) {
throw VimError.fromCode(ErrorCode.InvalidArgument);
}
const [asciiStart, asciiEnd] = [start.charCodeAt(0), end.charCodeAt(0)];
const chars: string[] = [];
for (let ascii = asciiStart; ascii <= asciiEnd; ascii++) {
chars.push(String.fromCharCode(ascii));
}
return chars;
};
if (args === '!') {
// TODO: clear change list
return asciiRange('a', 'z');
}
const marks: string[] = [];
for (const x of args) {
if (typeof x === 'string') {
marks.push(x);
} else {
const range = asciiRange(x.start, x.end);
if (range === undefined) {
throw VimError.fromCode(ErrorCode.InvalidArgument);
}
marks.push(...range.concat());
}
}
return marks;
}
async execute(vimState: VimState): Promise<void> {
const marks = DeleteMarksCommand.resolveMarkList(vimState, this.args);
vimState.historyTracker.removeMarks(marks);
}
}

View File

@ -0,0 +1,105 @@
import { optWhitespace, Parser } from 'parsimmon';
import { Position, Range } from 'vscode';
import { PositionDiff } from '../../common/motion/position';
import { ErrorCode, VimError } from '../../error';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
export class MoveCommand extends ExCommand {
public static readonly argParser: Parser<MoveCommand> = optWhitespace
.then(Address.parser.fallback(undefined))
.map((address) => new MoveCommand(address));
private address?: Address;
constructor(address?: Address) {
super();
this.address = address;
}
public override neovimCapable(): boolean {
return true;
}
private moveLines(vimState: VimState, sourceStart: number, sourceEnd: number) {
const dest = this.address?.resolve(vimState, 'left', false);
if (dest === undefined || dest < -1 || dest > vimState.document.lineCount) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.InvalidAddress));
return;
}
if (sourceEnd < sourceStart) {
[sourceStart, sourceEnd] = [sourceEnd, sourceStart];
}
/* make sure
1. not move a range to the place inside itself.
2. not move a range to the place right below or above itself, which leads to no change.
*/
if (dest >= sourceStart && dest <= sourceEnd) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.InvalidAddress));
return;
}
// copy
const copiedText = vimState.document.getText(
new Range(new Position(sourceStart, 0), new Position(sourceEnd, 0).getLineEnd()),
);
let text: string;
let position: Position;
if (dest === -1) {
text = copiedText + '\n';
position = new Position(0, 0);
} else {
text = '\n' + copiedText;
position = new Position(dest, 0).getLineEnd();
}
const lines = copiedText.split('\n');
let cursorPosition: Position;
if (dest > sourceEnd) {
// make the cursor position at the beginning of the endline.
cursorPosition = new Position(
Math.max(dest, 0),
lines[lines.length - 1].match(/\S/)?.index ?? 0,
);
} else {
cursorPosition = new Position(
Math.max(dest + lines.length, 0),
lines[lines.length - 1].match(/\S/)?.index ?? 0,
);
}
// delete
let start = new Position(sourceStart, 0);
let end = new Position(sourceEnd, 0).getLineEndIncludingEOL();
if (sourceEnd < vimState.document.lineCount - 1) {
end = end.getRightThroughLineBreaks();
} else if (sourceStart > 0) {
start = start.getLeftThroughLineBreaks();
}
vimState.recordedState.transformer.addTransformation({
type: 'deleteRange',
range: new Range(start, end),
manuallySetCursorPositions: true,
});
// insert
vimState.recordedState.transformer.insert(
position,
text,
PositionDiff.exactPosition(cursorPosition),
);
}
public async execute(vimState: VimState): Promise<void> {
const line = vimState.cursors[0].stop.line;
this.moveLines(vimState, line, line);
}
public override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
this.moveLines(vimState, start, end);
}
}

View File

@ -0,0 +1,13 @@
import { VimState } from '../../state/vimState';
import { globalState } from '../../state/globalState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
export class NohlCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
globalState.hl = false;
// Clear the `match x of y` message from status bar
StatusBar.clear(vimState);
}
}

View File

@ -0,0 +1,13 @@
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
export class OnlyCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
await Promise.allSettled([
vscode.commands.executeCommand('workbench.action.closeEditorsInOtherGroups'),
vscode.commands.executeCommand('workbench.action.maximizeEditor'),
vscode.commands.executeCommand('workbench.action.closePanel'),
]);
}
}

View File

@ -0,0 +1,54 @@
import { Parser, succeed } from 'parsimmon';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
type PrintArgs = {
printNumbers: boolean;
printText: boolean;
};
// TODO: `:l[ist]` is more than an alias
// TODO: `:z`
export class PrintCommand extends ExCommand {
// TODO: Print {count} and [flags]
public static readonly argParser = (args: {
printNumbers: boolean;
printText: boolean;
}): Parser<PrintCommand> => succeed(new PrintCommand(args));
private args: PrintArgs;
constructor(args: PrintArgs) {
super();
this.args = args;
}
async execute(vimState: VimState): Promise<void> {
// TODO: Wrong default for `:=`
void this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { end } = range.resolve(vimState);
// For now, we just print the last line.
// TODO: Create a dynamic document if there's more than one line?
const line = vimState.document.lineAt(end);
let output: string;
if (this.args.printNumbers) {
if (this.args.printText) {
output = `${line.lineNumber + 1} ${line.text}`;
} else {
output = `${line.lineNumber + 1}`;
}
} else {
if (this.args.printText) {
output = `${line.text}`;
} else {
output = '';
}
}
StatusBar.setText(vimState, output);
}
}

View File

@ -0,0 +1,83 @@
import { configuration } from '../../configuration/configuration';
import { VimState } from '../../state/vimState';
// eslint-disable-next-line id-denylist
import { Parser, alt, any, optWhitespace, seq } from 'parsimmon';
import { Position } from 'vscode';
import { PutBeforeFromCmdLine, PutFromCmdLine } from '../../actions/commands/put';
import { ErrorCode, VimError } from '../../error';
import { Register } from '../../register/register';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { bangParser } from '../../vimscript/parserUtils';
import { expressionParser } from '../expression';
export interface IPutCommandArguments {
bang: boolean;
register?: string;
fromExpression?: string;
}
//
// Implements :put
// http://vimdoc.sourceforge.net/htmldoc/change.html#:put
//
export class PutExCommand extends ExCommand {
public static readonly argParser: Parser<PutExCommand> = seq(
bangParser,
alt(
expressionParser,
optWhitespace
.then(any)
.map((x) => ({ register: x }))
.fallback({ register: undefined }),
),
).map(([bang, register]) => new PutExCommand({ bang, ...register }));
public readonly arguments: IPutCommandArguments;
constructor(args: IPutCommandArguments) {
super();
this.arguments = args;
}
public override neovimCapable(): boolean {
return true;
}
async doPut(vimState: VimState, position: Position): Promise<void> {
if (this.arguments.fromExpression && this.arguments.register) {
// set the register to the value of the expression
Register.overwriteRegister(
vimState,
this.arguments.register,
this.arguments.fromExpression,
0,
);
}
const registerName = this.arguments.register || (configuration.useSystemClipboard ? '*' : '"');
if (!Register.isValidRegister(registerName)) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.TrailingCharacters));
return;
}
vimState.recordedState.registerName = registerName;
const putCmd = this.arguments.bang ? new PutBeforeFromCmdLine() : new PutFromCmdLine();
putCmd.setInsertionLine(position.line);
await putCmd.exec(position, vimState);
}
async execute(vimState: VimState): Promise<void> {
await this.doPut(vimState, vimState.cursorStopPosition);
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { end } = range.resolve(vimState);
await this.doPut(vimState, new Position(end, 0).getLineEnd());
}
}

View File

@ -0,0 +1,61 @@
import { Parser } from 'parsimmon';
import * as vscode from 'vscode';
import * as error from '../../error';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser } from '../../vimscript/parserUtils';
export interface IQuitCommandArguments {
bang?: boolean;
quitAll?: boolean;
}
//
// Implements :quit
// http://vimdoc.sourceforge.net/htmldoc/editing.html#:quit
//
export class QuitCommand extends ExCommand {
public static readonly argParser: (quitAll: boolean) => Parser<QuitCommand> = (
quitAll: boolean,
) =>
bangParser.map(
(bang) =>
new QuitCommand({
bang,
quitAll,
}),
);
public override isRepeatableWithDot = false;
public arguments: IQuitCommandArguments;
constructor(args: IQuitCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
// NOTE: We can't currently get all open text editors, so this isn't perfect. See #3809
const duplicatedInSplit =
vscode.window.visibleTextEditors.filter((editor) => editor.document === vimState.document)
.length > 1;
if (
vimState.document.isDirty &&
!this.arguments.bang &&
(!duplicatedInSplit || this.arguments.quitAll)
) {
throw error.VimError.fromCode(error.ErrorCode.NoWriteSinceLastChange);
}
if (this.arguments.quitAll) {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
} else {
if (!this.arguments.bang) {
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
} else {
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
}
}
}
}

View File

@ -0,0 +1,87 @@
// eslint-disable-next-line id-denylist
import { all, alt, optWhitespace, Parser, seq, string, whitespace } from 'parsimmon';
import { SUPPORT_READ_COMMAND } from 'platform/constants';
import { readFileAsync } from 'platform/fs';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { fileNameParser, FileOpt, fileOptParser } from '../../vimscript/parserUtils';
export type IReadCommandArguments = {
opt: FileOpt;
} & ({ cmd: string } | { file: string } | object);
//
// Implements :read and :read!
// http://vimdoc.sourceforge.net/htmldoc/insert.html#:read
// http://vimdoc.sourceforge.net/htmldoc/insert.html#:read!
//
export class ReadCommand extends ExCommand {
public static readonly argParser: Parser<ReadCommand> = seq(
whitespace.then(fileOptParser).fallback([]),
optWhitespace
.then(
alt<{ cmd: string } | { file: string }>(
string('!')
.then(all)
.map((cmd) => {
return { cmd };
}),
fileNameParser.map((file) => {
return { file };
}),
),
)
.fallback(undefined),
).map(([opt, other]) => new ReadCommand({ opt, ...other }));
private readonly arguments: IReadCommandArguments;
constructor(args: IReadCommandArguments) {
super();
this.arguments = args;
}
public override neovimCapable(): boolean {
return true;
}
async execute(vimState: VimState): Promise<void> {
const textToInsert = await this.getTextToInsert(vimState);
if (textToInsert) {
vimState.recordedState.transformer.insert(
vimState.cursorStopPosition.getLineEnd(),
'\n' + textToInsert,
);
}
}
// TODO: executeWithRange()
async getTextToInsert(vimState: VimState): Promise<string> {
if ('file' in this.arguments) {
return readFileAsync(this.arguments.file, 'utf8');
} else if ('cmd' in this.arguments) {
if (this.arguments.cmd.length > 0) {
if (SUPPORT_READ_COMMAND) {
const cmd = this.arguments.cmd;
return new Promise<string>(async (resolve, reject) => {
const { exec } = await import('child_process');
exec(cmd, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve(stdout);
}
});
});
} else {
return '';
}
} else {
// TODO: error message?
return '';
}
} else {
return vimState.document.getText();
}
}
}

View File

@ -0,0 +1,28 @@
import { VimState } from '../../state/vimState';
import { CommandRedo } from '../../actions/commands/actions';
import { Position } from 'vscode';
import { ExCommand } from '../../vimscript/exCommand';
import { optWhitespace, Parser } from 'parsimmon';
import { numberParser } from '../../vimscript/parserUtils';
//
// Implements :red[o]
// http://vimdoc.sourceforge.net/htmldoc/undo.html#redo
//
export class RedoCommand extends ExCommand {
public static readonly argParser: Parser<RedoCommand> = optWhitespace
.then(numberParser)
.fallback(undefined)
.map((count) => new RedoCommand(count));
private count?: number;
private constructor(count?: number) {
super();
this.count = count;
}
async execute(vimState: VimState): Promise<void> {
// TODO: Use `this.count`
await new CommandRedo().exec(new Position(0, 0), vimState);
}
}

View File

@ -0,0 +1,91 @@
import * as vscode from 'vscode';
// eslint-disable-next-line id-denylist
import { Parser, any, optWhitespace } from 'parsimmon';
import { ErrorCode, VimError } from '../../error';
import { Register } from '../../register/register';
import { RecordedState } from '../../state/recordedState';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
export class RegisterCommand extends ExCommand {
public override isRepeatableWithDot: boolean = false;
public static readonly argParser: Parser<RegisterCommand> = optWhitespace.then(
// eslint-disable-next-line id-denylist
any.sepBy(optWhitespace).map((registers) => new RegisterCommand(registers)),
);
private readonly registers: string[];
constructor(registers: string[]) {
super();
this.registers = registers;
}
private async getRegisterDisplayValue(register: string): Promise<string | undefined> {
let result = (await Register.get(register))?.text;
if (result instanceof Array) {
result = result.join('\n').substr(0, 100);
} else if (result instanceof RecordedState) {
result = result.actionsRun.map((x) => x.keysPressed.join('')).join('');
}
return result;
}
async displayRegisterValue(vimState: VimState, register: string): Promise<void> {
let result = await this.getRegisterDisplayValue(register);
if (result === undefined) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.NothingInRegister, register));
} else {
result = result.replace(/\n/g, '\\n');
void vscode.window.showInformationMessage(`${register} ${result}`);
}
}
private regSortOrder(register: string): number {
const specials = ['-', '*', '+', '.', ':', '%', '#', '/', '='];
if (register === '"') {
return 0;
} else if (register >= '0' && register <= '9') {
return 10 + parseInt(register, 10);
} else if (register >= 'a' && register <= 'z') {
return 100 + (register.charCodeAt(0) - 'a'.charCodeAt(0));
} else if (specials.includes(register)) {
return 1000 + specials.indexOf(register);
} else {
throw new Error(`Unexpected register ${register}`);
}
}
async execute(vimState: VimState): Promise<void> {
if (this.registers.length === 1) {
await this.displayRegisterValue(vimState, this.registers[0]);
} else {
const currentRegisterKeys = Register.getKeys()
.filter(
(reg) => reg !== '_' && (this.registers.length === 0 || this.registers.includes(reg)),
)
.sort((reg1: string, reg2: string) => this.regSortOrder(reg1) - this.regSortOrder(reg2));
const registerKeyAndContent = new Array<vscode.QuickPickItem>();
for (const registerKey of currentRegisterKeys) {
const displayValue = await this.getRegisterDisplayValue(registerKey);
if (typeof displayValue === 'string') {
registerKeyAndContent.push({
label: registerKey,
description: displayValue,
});
}
}
void vscode.window.showQuickPick(registerKeyAndContent).then(async (val) => {
if (val) {
const result = val.description;
void vscode.window.showInformationMessage(`${val.label} ${result}`);
}
});
}
}
}

View File

@ -0,0 +1,174 @@
import { optWhitespace, Parser, seq } from 'parsimmon';
import { Range } from 'vscode';
import { configuration } from '../../configuration/configuration';
import { isVisualMode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { bangParser, numberParser } from '../../vimscript/parserUtils';
import { SetCommand } from './set';
export interface IRetabCommandArguments {
replaceSpaces: boolean;
newTabstop?: number;
}
interface UpdatedLineSegment {
value: string;
length: number;
}
// :[range]ret[ab][!] [new_tabstop]
export class RetabCommand extends ExCommand {
public static readonly argParser: Parser<RetabCommand> = seq(
bangParser,
optWhitespace.then(numberParser).fallback(undefined),
).map(
([replaceSpaces, newTabstop]) =>
new RetabCommand({
replaceSpaces,
newTabstop,
}),
);
private readonly arguments: IRetabCommandArguments;
constructor(args: IRetabCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
if (isVisualMode(vimState.currentMode)) {
const { start, end } = vimState.editor.selection;
this.retab(vimState, start.line, end.line);
} else {
this.retab(vimState, 0, vimState.document.lineCount - 1);
}
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
this.retab(vimState, start, end);
}
private concat(count: number, char: string): string {
let result = '';
for (let i = 0; i < count; i++) {
result += char;
}
return result;
}
private hasTabs(str: string): boolean {
return str.indexOf('\t') >= 0;
}
expandtab(str: string, start = 0, tabstop = configuration.tabstop): string {
let expanded = '';
let i = start;
for (const char of str) {
if (char === '\t') {
const spaces = tabstop - (i % tabstop) || tabstop;
expanded += this.concat(spaces, ' ');
i += spaces;
} else {
expanded += char;
i++;
}
}
return expanded;
}
retabLineSegment(
segment: string,
start: number,
tabstop = configuration.tabstop,
): UpdatedLineSegment {
const retab = this.arguments.replaceSpaces || this.hasTabs(segment);
if (!retab) {
return {
value: segment,
length: segment.length,
};
}
const retabTabstop = this.arguments.newTabstop || tabstop;
const detabbed = this.expandtab(segment, start, tabstop);
const spaces = Math.min((start + detabbed.length) % retabTabstop, detabbed.length);
const tabs = Math.ceil((detabbed.length - spaces) / retabTabstop);
let result = '';
result += this.concat(tabs, '\t');
result += this.concat(spaces, ' ');
return {
value: result,
length: detabbed.length,
};
}
retabLine(line: string, tabstop = configuration.tabstop): string {
const segments = line.split(/(\s+)/);
let i = 0;
let retabbed = '';
for (const str of segments) {
if (!str) {
continue;
}
if (![' ', '\t'].includes(str[0])) {
retabbed += str;
i += str.length;
} else {
const result = this.retabLineSegment(str, i, tabstop);
retabbed += result.value;
i += result.length;
}
}
return retabbed;
}
public retab(vimState: VimState, startLine: number, endLine: number) {
const originalLines: string[] = [];
const lastLine = Math.min(endLine, vimState.document.lineCount - 1);
for (let i = startLine; i <= lastLine; i++) {
originalLines.push(vimState.document.lineAt(i).text);
}
const replacedLines = originalLines.map((line: string) => {
return configuration.expandtab ? this.expandtab(line) : this.retabLine(line);
});
const replacedContent = replacedLines.join('\n');
const lastLineLength = originalLines[originalLines.length - 1].length;
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
range: new Range(startLine, 0, endLine, lastLineLength),
text: replacedContent,
});
if (this.arguments.newTabstop) {
const setTabstop = new SetCommand({
type: 'equal',
option: 'tabstop',
value: this.arguments.newTabstop.toString(),
});
void setTabstop.execute(vimState);
}
}
}

View File

@ -0,0 +1,300 @@
// eslint-disable-next-line id-denylist
import { alt, oneOf, Parser, regexp, seq, string, whitespace } from 'parsimmon';
import { configuration, optionAliases } from '../../configuration/configuration';
import { ErrorCode, VimError } from '../../error';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { ExCommand } from '../../vimscript/exCommand';
type SetOperation =
| {
// :se[t]
// :se[t] {option}
type: 'show_or_set';
option: string | undefined;
}
| {
// :se[t] {option}?
type: 'show';
option: string;
}
| {
// :se[t] no{option}
type: 'unset';
option: string;
}
| {
// :se[t] {option}!
// :se[t] inv{option}
type: 'invert';
option: string;
}
| {
// :se[t] {option}&
// :se[t] {option}&vi
// :se[t] {option}&vim
type: 'default';
option: string;
source: 'vi' | 'vim' | '';
}
| {
// :se[t] {option}={value}
// :se[t] {option}:{value}
type: 'equal';
option: string;
value: string;
}
| {
// :se[t] {option}+={value}
type: 'add';
option: string;
value: string;
}
| {
// :se[t] {option}^={value}
type: 'multiply';
option: string;
value: string;
}
| {
// :se[t] {option}-={value}
type: 'subtract';
option: string;
value: string;
};
const optionParser = regexp(/[a-z]+/);
const valueParser = regexp(/\S*/);
const setOperationParser: Parser<SetOperation> = whitespace
.then(
alt<SetOperation>(
string('no')
.then(optionParser)
.map((option) => {
return {
type: 'unset',
option,
};
}),
string('inv')
.then(optionParser)
.map((option) => {
return {
type: 'invert',
option,
};
}),
optionParser.skip(string('!')).map((option) => {
return {
type: 'invert',
option,
};
}),
optionParser.skip(string('?')).map((option) => {
return {
type: 'show',
option,
};
}),
seq(optionParser.skip(string('&')), alt(string('vim'), string('vi'), string(''))).map(
([option, source]) => {
return {
type: 'default',
option,
source,
};
},
),
seq(optionParser.skip(oneOf('=:')), valueParser).map(([option, value]) => {
return {
type: 'equal',
option,
value,
};
}),
seq(optionParser.skip(string('+=')), valueParser).map(([option, value]) => {
return {
type: 'add',
option,
value,
};
}),
seq(optionParser.skip(string('^=')), valueParser).map(([option, value]) => {
return {
type: 'multiply',
option,
value,
};
}),
seq(optionParser.skip(string('-=')), valueParser).map(([option, value]) => {
return {
type: 'subtract',
option,
value,
};
}),
optionParser.map((option) => {
return {
type: 'show_or_set',
option,
};
}),
),
)
.fallback({ type: 'show_or_set', option: undefined });
export class SetCommand extends ExCommand {
public static readonly argParser: Parser<SetCommand> = setOperationParser.map(
(operation) => new SetCommand(operation),
);
private readonly operation: SetOperation;
constructor(operation: SetOperation) {
super();
this.operation = operation;
}
async execute(vimState: VimState): Promise<void> {
if (this.operation.option === undefined) {
// TODO: Show all options that differ from their default value
return;
}
const option = optionAliases.get(this.operation.option) ?? this.operation.option;
const currentValue = configuration[option] as string | number | boolean | undefined;
if (currentValue === undefined) {
throw VimError.fromCode(ErrorCode.UnknownOption, option);
}
const type =
typeof currentValue === 'boolean'
? 'boolean'
: typeof currentValue === 'string'
? 'string'
: 'number';
switch (this.operation.type) {
case 'show_or_set': {
if (this.operation.option === 'all') {
// TODO: Show all options
} else {
if (type === 'boolean') {
configuration[option] = true;
} else {
this.showOption(vimState, option, currentValue);
}
}
break;
}
case 'show': {
this.showOption(vimState, option, currentValue);
break;
}
case 'unset': {
if (type === 'boolean') {
configuration[option] = false;
} else {
throw VimError.fromCode(ErrorCode.InvalidArgument, `no${option}`);
}
break;
}
case 'invert': {
if (type === 'boolean') {
configuration[option] = !currentValue;
} else {
// TODO: Could also be {option}!
throw VimError.fromCode(ErrorCode.InvalidArgument, `inv${option}`);
}
break;
}
case 'default': {
if (this.operation.option === 'all') {
// TODO: Set all options to default
} else {
// TODO: Set the option to default
}
break;
}
case 'equal': {
if (type === 'boolean') {
// TODO: Could also be {option}:{value}
throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}=${this.operation.value}`);
} else if (type === 'string') {
configuration[option] = this.operation.value;
} else {
const value = Number.parseInt(this.operation.value, 10);
if (isNaN(value)) {
// TODO: Could also be {option}:{value}
throw VimError.fromCode(
ErrorCode.NumberRequiredAfterEqual,
`${option}=${this.operation.value}`,
);
}
configuration[option] = value;
}
break;
}
case 'add': {
if (type === 'boolean') {
throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}+=${this.operation.value}`);
} else if (type === 'string') {
configuration[option] = currentValue + this.operation.value;
} else {
const value = Number.parseInt(this.operation.value, 10);
if (isNaN(value)) {
throw VimError.fromCode(
ErrorCode.NumberRequiredAfterEqual,
`${option}+=${this.operation.value}`,
);
}
configuration[option] = (currentValue as number) + value;
}
break;
}
case 'multiply': {
if (type === 'boolean') {
throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}^=${this.operation.value}`);
} else if (type === 'string') {
configuration[option] = this.operation.value + currentValue;
} else {
const value = Number.parseInt(this.operation.value, 10);
if (isNaN(value)) {
throw VimError.fromCode(
ErrorCode.NumberRequiredAfterEqual,
`${option}^=${this.operation.value}`,
);
}
configuration[option] = (currentValue as number) * value;
}
break;
}
case 'subtract': {
if (type === 'boolean') {
throw VimError.fromCode(ErrorCode.InvalidArgument, `${option}-=${this.operation.value}`);
} else if (type === 'string') {
configuration[option] = (currentValue as string).split(this.operation.value).join('');
} else {
const value = Number.parseInt(this.operation.value, 10);
if (isNaN(value)) {
throw VimError.fromCode(
ErrorCode.NumberRequiredAfterEqual,
`${option}-=${this.operation.value}`,
);
}
configuration[option] = (currentValue as number) - value;
}
break;
}
default:
const guard: never = this.operation;
throw new Error('Got unexpected SetOperation.type');
}
}
private showOption(vimState: VimState, option: string, value: boolean | string | number) {
if (typeof value === 'boolean') {
StatusBar.setText(vimState, value ? option : `no${option}`);
} else {
StatusBar.setText(vimState, `${option}=${value}`);
}
}
}

View File

@ -0,0 +1,9 @@
import { window } from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
export class ShCommand extends ExCommand {
async execute(vimState: VimState): Promise<void> {
window.createTerminal().show();
}
}

View File

@ -0,0 +1,62 @@
import { Position, Selection } from 'vscode';
// eslint-disable-next-line id-denylist
import { optWhitespace, Parser, seq, string } from 'parsimmon';
import { PositionDiff } from '../../common/motion/position';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
export type ShiftDirection = '>' | '<';
export type ShiftArgs = {
dir: '>' | '<';
depth: number;
numLines: number | undefined;
};
export class ShiftCommand extends ExCommand {
public static readonly argParser = (dir: '>' | '<'): Parser<ShiftCommand> =>
optWhitespace
.then(
seq(
// `:>>>` indents 3 times `shiftwidth`
string(dir)
.many()
.map((shifts) => shifts.length + 1)
.skip(optWhitespace),
// `:> 2` indents 2 lines
numberParser.fallback(undefined),
),
)
.map(([depth, numLines]) => new ShiftCommand({ dir, depth, numLines }));
private args: ShiftArgs;
constructor(args: ShiftArgs) {
super();
this.args = args;
}
public async execute(vimState: VimState): Promise<void> {
void this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
public override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
let { start, end } = range.resolve(vimState);
if (this.args.numLines !== undefined) {
start = end;
end = start + this.args.numLines;
}
vimState.editor.selection = new Selection(new Position(start, 0), new Position(end, 0));
for (let i = 0; i < this.args.depth; i++) {
if (this.args.dir === '>') {
vimState.recordedState.transformer.vscodeCommand('editor.action.indentLines');
} else if (this.args.dir === '<') {
vimState.recordedState.transformer.vscodeCommand('editor.action.outdentLines');
}
}
vimState.recordedState.transformer.moveCursor(PositionDiff.startOfLine());
}
}

View File

@ -0,0 +1,43 @@
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { TextEditor } from '../../textEditor';
import { ExCommand } from '../../vimscript/exCommand';
export class SmileCommand extends ExCommand {
static readonly smileText: string = `
oooo$$$$$$$$$$$$oooo
oo$$$$$$$$$$$$$$$$$$$$$$$$o
oo$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$o o$ $$ o$
o $ oo o$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$o $$ $$ $$o$
oo $ $ "$ o$$$$$$$$$ $$$$$$$$$$$$$ $$$$$$$$$o $$$o$$o$
"$$$$$$o$ o$$$$$$$$$ $$$$$$$$$$$ $$$$$$$$$$o $$$$$$$$
$$$$$$$ $$$$$$$$$$$ $$$$$$$$$$$ $$$$$$$$$$$$$$$$$$$$$$$
$$$$$$$$$$$$$$$$$$$$$$$ $$$$$$$$$$$$$ $$$$$$$$$$$$$$ """$$$
"$$$""""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ "$$$
$$$ o$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ "$$$o
o$$" $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$o
$$$ $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$" "$$$$$$ooooo$$$$o
o$$$oooo$$$$$ $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ o$$$$$$$$$$$$$$$$$
$$$$$$$$"$$$$ $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ $$$$""""""""
"""" $$$$ "$$$$$$$$$$$$$$$$$$$$$$$$$$$$" o$$$
"$$$o """$$$$$$$$$$$$$$$$$$"$$" $$$
$$$o "$$""$$$$$$"""" o$$$
$$$$o o$$$"
"$$$$o o$$$$$$o"$$$$o o$$$$
"$$$$$oo ""$$$$o$$$$$o o$$$$""
""$$$$$oooo "$$$o$$$$$$$$$"""
""$$$$$$$oo $$$$$$$$$$
""""$$$$$$$$$$$
$$$$$$$$$$$$
$$$$$$$$$$"
"$$$""""`;
constructor() {
super();
}
async execute(vimState: VimState): Promise<void> {
await vscode.commands.executeCommand('workbench.action.files.newUntitledFile');
await TextEditor.insert(vscode.window.activeTextEditor!, SmileCommand.smileText);
}
}

View File

@ -0,0 +1,114 @@
import { oneOf, optWhitespace, Parser, seq } from 'parsimmon';
import { NumericString, NumericStringRadix } from '../../common/number/numericString';
import * as vscode from 'vscode';
import { PositionDiff } from '../../common/motion/position';
import { isVisualMode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { bangParser } from '../../vimscript/parserUtils';
export interface ISortCommandArguments {
reverse: boolean;
ignoreCase: boolean;
unique: boolean;
numeric: boolean;
// TODO: support other flags
// TODO(#6676): support pattern
}
export class SortCommand extends ExCommand {
public static readonly argParser: Parser<SortCommand> = seq(
bangParser,
optWhitespace.then(oneOf('bfilnorux').many()),
).map(
([bang, flags]) =>
new SortCommand({
reverse: bang,
ignoreCase: flags.includes('i'),
unique: flags.includes('u'),
numeric: flags.includes('n'),
}),
);
private readonly arguments: ISortCommandArguments;
constructor(args: ISortCommandArguments) {
super();
this.arguments = args;
}
public override neovimCapable(): boolean {
return true;
}
async execute(vimState: VimState): Promise<void> {
if (isVisualMode(vimState.currentMode)) {
const { start, end } = vimState.editor.selection;
await this.sortLines(vimState, start.line, end.line);
} else {
await this.sortLines(vimState, 0, vimState.document.lineCount - 1);
}
}
async sortLines(vimState: VimState, startLine: number, endLine: number) {
let originalLines: string[] = [];
for (
let currentLine = startLine;
currentLine <= endLine && currentLine < vimState.document.lineCount;
currentLine++
) {
originalLines.push(vimState.document.lineAt(currentLine).text);
}
const lastLineLength = originalLines[originalLines.length - 1].length;
if (this.arguments.unique) {
const seen = new Set<string>();
const uniqueLines: string[] = [];
for (const line of originalLines) {
const adjustedLine = this.arguments.ignoreCase ? line.toLowerCase() : line;
if (!seen.has(adjustedLine)) {
seen.add(adjustedLine);
uniqueLines.push(line);
}
}
originalLines = uniqueLines;
}
let sortedLines;
if (this.arguments.numeric) {
sortedLines = originalLines.sort(
(a: string, b: string) =>
(NumericString.parse(a, NumericStringRadix.Dec)?.num.value ?? Number.MAX_VALUE) -
(NumericString.parse(b, NumericStringRadix.Dec)?.num.value ?? Number.MAX_VALUE),
);
} else if (this.arguments.ignoreCase) {
sortedLines = originalLines.sort((a: string, b: string) => a.localeCompare(b));
} else {
sortedLines = originalLines.sort();
}
if (this.arguments.reverse) {
sortedLines.reverse();
}
const sortedContent = sortedLines.join('\n');
vimState.recordedState.transformer.addTransformation({
type: 'replaceText',
range: new vscode.Range(startLine, 0, endLine, lastLineLength),
text: sortedContent,
diff: PositionDiff.exactPosition(
new vscode.Position(startLine, sortedLines[0].match(/\S/)?.index ?? 0),
),
});
}
override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const { start, end } = range.resolve(vimState);
await this.sortLines(vimState, start, end);
}
}

View File

@ -0,0 +1,638 @@
// eslint-disable-next-line id-denylist
import { Parser, alt, any, noneOf, oneOf, optWhitespace, regexp, seq, string } from 'parsimmon';
import { CancellationTokenSource, DecorationOptions, Position, Range, window } from 'vscode';
import { PositionDiff } from '../../common/motion/position';
import { configuration } from '../../configuration/configuration';
import { decoration } from '../../configuration/decoration';
import { ErrorCode, VimError } from '../../error';
import { Jump } from '../../jumps/jump';
import { globalState } from '../../state/globalState';
import { SearchState } from '../../state/searchState';
import { SubstituteState } from '../../state/substituteState';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { SearchDecorations, ensureVisible, formatDecorationText } from '../../util/decorationUtils';
import { escapeCSSIcons } from '../../util/statusBarTextUtils';
import { ExCommand } from '../../vimscript/exCommand';
import { Address, LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
import { Pattern, PatternMatch, SearchDirection } from '../../vimscript/pattern';
type ReplaceStringComponent =
| { type: 'string'; value: string }
| { type: 'capture_group'; group: number | '&' }
| { type: 'prev_replace_string' }
| { type: 'change_case'; case: 'upper' | 'lower'; duration: 'char' | 'until_end' }
| { type: 'change_case_end' };
export class ReplaceString {
private components: ReplaceStringComponent[];
constructor(components: ReplaceStringComponent[]) {
this.components = components;
}
public toString(): string {
return this.components
.map((component) => {
if (component.type === 'string') {
return component.value;
} else if (component.type === 'capture_group') {
return component.group === '&' ? '&' : `\\${component.group}`;
} else if (component.type === 'prev_replace_string') {
return '~';
} else if (component.type === 'change_case') {
if (component.case === 'upper') {
return component.duration === 'char' ? '\\u' : '\\U';
} else {
return component.duration === 'char' ? '\\l' : '\\L';
}
} else if (component.type === 'change_case_end') {
return '\\E';
} else {
const guard: never = component;
return '';
}
})
.join('');
}
public resolve(matches: string[]): string {
let result = '';
let changeCase: 'upper' | 'lower' | undefined;
let changeCaseChar: 'upper' | 'lower' | undefined;
for (const component of this.components) {
let newChangeCaseChar: 'upper' | 'lower' | undefined;
let _result: string = '';
if (component.type === 'string') {
_result = component.value;
} else if (component.type === 'capture_group') {
const group: number = component.group === '&' ? 0 : component.group;
_result = matches[group] ?? '';
} else if (component.type === 'prev_replace_string') {
_result = globalState.substituteState?.replaceString.toString() ?? '';
} else if (component.type === 'change_case') {
if (component.duration === 'until_end') {
changeCase = component.case;
} else {
newChangeCaseChar = component.case;
}
} else if (component.type === 'change_case_end') {
changeCase = undefined;
} else {
const guard: never = component;
}
if (_result) {
if (changeCase) {
_result =
changeCase === 'upper' ? _result.toLocaleUpperCase() : _result.toLocaleLowerCase();
}
if (changeCaseChar) {
_result =
(changeCaseChar === 'upper'
? _result[0].toLocaleUpperCase()
: _result[0].toLocaleLowerCase()) + _result.slice(1);
}
result += _result;
}
changeCaseChar = newChangeCaseChar;
}
return result;
}
}
/**
* NOTE: for "pattern", undefined is different from an empty string.
* when it's undefined, it means to repeat the previous REPLACEMENT and ignore "replace".
* when it's an empty string, it means to use the previous SEARCH (not replacement) state,
* and replace with whatever's set by "replace" (even an empty string).
*/
export interface ISubstituteCommandArguments {
pattern: Pattern | undefined;
replace: ReplaceString;
flags: SubstituteFlags;
count?: number;
}
/**
* The flags that you can use for the substitute commands:
* [&] Must be the first one: Keep the flags from the previous substitute command.
* [c] Confirm each substitution.
* [e] When the search pattern fails, do not issue an error message and, in
* particular, continue in maps as if no error occurred.
* [g] Replace all occurrences in the line. Without this argument, replacement
* occurs only for the first occurrence in each line.
* [i] Ignore case for the pattern.
* [I] Don't ignore case for the pattern.
* [n] Report the number of matches, do not actually substitute.
* [p] Print the line containing the last substitute.
* [#] Like [p] and prepend the line number.
* [l] Like [p] but print the text like |:list|.
* [r] When the search pattern is empty, use the previously used search pattern
* instead of the search pattern from the last substitute or ":global".
*/
export interface SubstituteFlags {
keepPreviousFlags?: true; // TODO: use this flag
confirmEach?: true;
suppressError?: true; // TODO: use this flag
replaceAll?: true;
ignoreCase?: true; // TODO: use this flag
noIgnoreCase?: true; // TODO: use this flag
printCount?: true;
// TODO: use the following flags:
printLastMatchedLine?: true;
printLastMatchedLineWithNumber?: true;
printLastMatchedLineWithList?: true;
usePreviousPattern?: true;
}
// TODO: `:help sub-replace-special`
// TODO: `:help sub-replace-expression`
const replaceStringParser = (delimiter: string): Parser<ReplaceString> =>
alt<ReplaceStringComponent>(
string('\\').then(
// eslint-disable-next-line id-denylist
any.fallback(undefined).map<ReplaceStringComponent>((escaped) => {
if (escaped === undefined || escaped === '\\') {
return { type: 'string', value: '\\' };
} else if (escaped === '/') {
return { type: 'string', value: '/' };
} else if (escaped === 'b') {
return { type: 'string', value: '\b' };
} else if (escaped === 'r') {
return { type: 'string', value: '\r' };
} else if (escaped === 'n') {
return { type: 'string', value: '\n' };
} else if (escaped === 't') {
return { type: 'string', value: '\t' };
} else if (escaped === '&') {
return { type: 'string', value: '&' };
} else if (escaped === '~') {
return { type: 'string', value: '~' };
} else if (/[0-9]/.test(escaped)) {
return { type: 'capture_group', group: Number.parseInt(escaped, 10) };
} else if (escaped === 'u') {
return { type: 'change_case', case: 'upper', duration: 'char' };
} else if (escaped === 'l') {
return { type: 'change_case', case: 'lower', duration: 'char' };
} else if (escaped === 'U') {
return { type: 'change_case', case: 'upper', duration: 'until_end' };
} else if (escaped === 'L') {
return { type: 'change_case', case: 'lower', duration: 'until_end' };
} else if (escaped === 'e' || escaped === 'E') {
return { type: 'change_case_end' };
} else {
return { type: 'string', value: `\\${escaped}` };
}
}),
),
string('&').result({ type: 'capture_group', group: '&' }),
string('~').result({ type: 'prev_replace_string' }),
noneOf(delimiter).map((value) => ({ type: 'string', value })),
)
.many()
.map((components) => new ReplaceString(components));
const substituteFlagsParser: Parser<SubstituteFlags> = seq(
string('&').fallback(undefined),
oneOf('cegiInp#lr').many(),
).map(([amp, flagChars]) => {
const flags: SubstituteFlags = {};
if (amp === '&') {
flags.keepPreviousFlags = true;
}
for (const flag of flagChars) {
switch (flag) {
case 'c':
flags.confirmEach = true;
break;
case 'e':
flags.suppressError = true;
break;
case 'g':
flags.replaceAll = true;
break;
case 'i':
flags.ignoreCase = true;
break;
case 'I':
flags.noIgnoreCase = true;
break;
case 'n':
flags.printCount = true;
break;
case 'p':
flags.printLastMatchedLine = true;
break;
case '#':
flags.printLastMatchedLineWithNumber = true;
break;
case 'l':
flags.printLastMatchedLineWithList = true;
break;
case 'r':
flags.usePreviousPattern = true;
break;
}
}
return flags;
});
const countParser: Parser<number | undefined> = optWhitespace
.then(numberParser)
.fallback(undefined);
/**
* vim has a distinctly different state for previous search and for previous substitute. However, in SOME
* cases a substitution will also update the search state along with the substitute state.
*
* Also, the substitute command itself will sometimes use the search state, and other times it will use the
* substitute state.
*
* These are the following cases and how vim handles them:
* 1. :s/this/that
* - standard search/replace
* - update substitution state
* - update search state too!
* 2. :s or :s [flags]
* - use previous SUBSTITUTION state, and repeat previous substitution pattern and replace.
* - do not touch search state!
* - changing substitution state is dont-care cause we're repeating it ;)
* 3. :s/ or :s// or :s///
* - use previous SEARCH state (not substitution), and DELETE the string matching the pattern (replace with nothing)
* - update substitution state
* - updating search state is dont-care cause we're repeating it ;)
* 4. :s/this or :s/this/ or :s/this//
* - input is pattern - replacement is empty (delete)
* - update replacement state
* - update search state too!
*/
export class SubstituteCommand extends ExCommand {
public static readonly argParser: Parser<SubstituteCommand> = optWhitespace.then(
alt(
// :s[ubstitute]/{pattern}/{string}/[flags] [count]
regexp(/[^\w\s\\|"]{1}/).chain((delimiter) =>
seq(
Pattern.parser({ direction: SearchDirection.Forward, delimiter }),
replaceStringParser(delimiter),
string(delimiter).then(substituteFlagsParser).fallback({}),
countParser,
).map(
([pattern, replace, flags, count]) =>
new SubstituteCommand({ pattern, replace, flags, count }),
),
),
// :s[ubstitute] [flags] [count]
seq(substituteFlagsParser, countParser).map(
([flags, count]) =>
new SubstituteCommand({
pattern: undefined,
replace: new ReplaceString([]),
flags,
count,
}),
),
),
);
public readonly arguments: ISubstituteCommandArguments;
protected abort: boolean;
private cSearchHighlights?: DecorationOptions[];
private confirmedSubstitutions?: DecorationOptions[];
constructor(args: ISubstituteCommandArguments) {
super();
this.arguments = args;
this.abort = false;
}
public override neovimCapable(): boolean {
// We need to use VSCode's quickpick capabilities to do confirmation
return !this.arguments.flags.confirmEach;
}
public getSubstitutionDecorations(
vimState: VimState,
lineRange = new LineRange(new Address({ type: 'current_line' })),
): SearchDecorations {
const substitutionAppend: DecorationOptions[] = [];
const substitutionReplace: DecorationOptions[] = [];
const searchHighlight: DecorationOptions[] = [];
const subsArr: DecorationOptions[] =
configuration.inccommand === 'replace' ? substitutionReplace : substitutionAppend;
const { pattern, replace } = this.resolvePatterns(false);
const showReplacements =
this.arguments.pattern?.closed &&
configuration.inccommand &&
!this.arguments.flags.printCount;
let matches: PatternMatch[] = [];
if (pattern?.patternString) {
matches = pattern.allMatches(vimState, { lineRange });
}
const global =
(configuration.gdefault || configuration.substituteGlobalFlag) !==
(this.arguments.flags.replaceAll ?? false);
const lines = new Set<number>();
for (const match of matches) {
if (!global && lines.has(match.range.start.line)) {
// If not global, only replace one match per line
continue;
}
lines.add(match.range.start.line);
if (showReplacements) {
const contentText = formatDecorationText(
replace.resolve(match.groups),
vimState.editor.options.tabSize as number,
);
subsArr.push({
range: match.range,
renderOptions: {
[configuration.inccommand === 'append' ? 'after' : 'before']: { contentText },
},
});
} else {
searchHighlight.push(ensureVisible(match.range));
}
}
return { substitutionAppend, substitutionReplace, searchHighlight };
}
/**
* @returns If match, (# newlines added) - (# newlines removed)
* Else, undefined
*/
private async replaceMatchRange(
vimState: VimState,
match: PatternMatch,
): Promise<number | undefined> {
if (this.arguments.flags.printCount) {
return 0;
}
const replaceText = this.arguments.replace.resolve(match.groups);
if (this.arguments.flags.confirmEach) {
if (await this.confirmReplacement(vimState, match, replaceText)) {
vimState.recordedState.transformer.replace(match.range, replaceText);
} else {
return undefined;
}
} else {
vimState.recordedState.transformer.replace(match.range, replaceText);
}
const addedNewlines = replaceText.split('\n').length - 1;
const removedNewlines = match.groups[0].split('\n').length - 1;
return addedNewlines - removedNewlines;
}
private async confirmReplacement(
vimState: VimState,
match: PatternMatch,
replaceText: string,
): Promise<boolean> {
const cancellationToken = new CancellationTokenSource();
const validSelections: readonly string[] = ['y', 'n', 'a', 'q', 'l'];
let selection: string = '';
const prompt = escapeCSSIcons(
`Replace with ${formatDecorationText(
replaceText,
vimState.editor.options.tabSize as number,
'\\n',
)} (${validSelections.join('/')})?`,
);
const newConfirmationSearchHighlights =
this.cSearchHighlights?.filter((d) => !d.range.isEqual(match.range)) ?? [];
vimState.editor.revealRange(new Range(match.range.start.line, 0, match.range.start.line, 0));
vimState.editor.setDecorations(decoration.searchHighlight, newConfirmationSearchHighlights);
vimState.editor.setDecorations(decoration.searchMatch, [ensureVisible(match.range)]);
vimState.editor.setDecorations(
decoration.confirmedSubstitution,
this.confirmedSubstitutions ?? [],
);
await window.showInputBox(
{
ignoreFocusOut: true,
prompt,
placeHolder: validSelections.join('/'),
validateInput: (input: string): string => {
if (validSelections.includes(input)) {
selection = input;
cancellationToken.cancel();
}
return prompt;
},
},
cancellationToken.token,
);
if (selection === 'q' || selection === 'l' || !selection) {
this.abort = true;
} else if (selection === 'a') {
this.arguments.flags.confirmEach = undefined;
}
if (selection === 'y' || selection === 'a' || selection === 'l') {
if (this.cSearchHighlights) {
this.cSearchHighlights = newConfirmationSearchHighlights;
}
this.confirmedSubstitutions?.push({
range: match.range,
renderOptions: {
before: {
contentText: formatDecorationText(
replaceText,
vimState.editor.options.tabSize as number,
),
},
},
});
return true;
}
return false;
}
/**
* @returns the concrete Pattern and ReplaceString to be used for this substitution.
* If throwErrors is true, errors will be thrown :)
*/
private resolvePatterns(throwErrors: boolean): {
pattern: Pattern | undefined;
replace: ReplaceString;
} {
let { pattern, replace } = this.arguments;
if (pattern === undefined) {
// If no pattern is entered, use previous SUBSTITUTION state and don't update search state
// i.e. :s
const prevSubstituteState = globalState.substituteState;
if (
prevSubstituteState?.searchPattern === undefined ||
prevSubstituteState.searchPattern.patternString === ''
) {
if (throwErrors) {
throw VimError.fromCode(ErrorCode.NoPreviousSubstituteRegularExpression);
}
} else {
pattern = prevSubstituteState.searchPattern;
replace = prevSubstituteState.replaceString;
}
} else {
if (pattern.patternString === '') {
// If an explicitly empty pattern is entered, use previous search state (including search with * and #) and update both states
// e.g :s/ or :s///
const prevSearchState = globalState.searchState;
if (prevSearchState === undefined || prevSearchState.searchString === '') {
if (throwErrors) {
throw VimError.fromCode(ErrorCode.NoPreviousRegularExpression);
}
} else {
pattern = prevSearchState.pattern;
}
}
}
return { pattern, replace };
}
async execute(vimState: VimState): Promise<void> {
await this.executeWithRange(vimState, new LineRange(new Address({ type: 'current_line' })));
}
override async executeWithRange(vimState: VimState, lineRange: LineRange): Promise<void> {
let { start, end } = lineRange.resolve(vimState);
if (this.arguments.count && this.arguments.count >= 0) {
start = end;
end = end + this.arguments.count - 1;
}
// TODO: this is all a bit gross
const { pattern, replace } = this.resolvePatterns(true);
this.arguments.replace = replace;
// `/g` flag inverts the default behavior (from `gdefault`)
const global =
(configuration.gdefault || configuration.substituteGlobalFlag) !==
(this.arguments.flags.replaceAll ?? false);
// TODO: `allMatches` lies for patterns with empty branches, which makes this wrong (not that anyone cares)
const allMatches =
pattern?.allMatches(vimState, {
// TODO: This method should probably take start/end lines as numbers
lineRange: new LineRange(
new Address({ type: 'number', num: start + 1 }),
',',
new Address({ type: 'number', num: end + 1 }),
),
}) ?? [];
let replaceableMatches;
if (global) {
// every match is replaceable
replaceableMatches = allMatches;
} else {
// only the first match on a line is replaceable
const replaceableLines = new Set<number>();
replaceableMatches = allMatches.filter((match) => {
if (replaceableLines.has(match.range.start.line)) {
return false;
}
replaceableLines.add(match.range.start.line);
return true;
});
}
if (this.arguments.flags.confirmEach) {
vimState.editor.setDecorations(decoration.substitutionAppend, []);
vimState.editor.setDecorations(decoration.substitutionReplace, []);
if (configuration.inccommand) {
this.confirmedSubstitutions = [];
}
if (configuration.incsearch) {
this.cSearchHighlights = replaceableMatches.map((match) => ensureVisible(match.range));
}
}
const substitutionLines = new Set<number>();
let substitutions = 0;
let netNewLines = 0;
for (const match of replaceableMatches) {
if (this.abort) {
break;
}
const newLines = await this.replaceMatchRange(vimState, match);
if (newLines !== undefined) {
substitutions++;
substitutionLines.add(match.range.start.line);
netNewLines += newLines;
}
}
if (substitutions > 0 && !this.arguments.flags.printCount) {
// if any substitutions were made, jump to latest one
const lastLine = Math.max(...substitutionLines.values()) + netNewLines;
const cursor = new Position(Math.max(0, lastLine), 0);
globalState.jumpTracker.recordJump(
new Jump({
document: vimState.document,
position: cursor,
}),
Jump.fromStateNow(vimState),
);
vimState.recordedState.transformer.moveCursor(PositionDiff.exactPosition(cursor), 0);
}
this.confirmedSubstitutions = undefined;
this.cSearchHighlights = undefined;
vimState.editor.setDecorations(decoration.confirmedSubstitution, []);
this.setStatusBarText(vimState, substitutions, substitutionLines.size);
if (this.arguments.pattern !== undefined) {
globalState.substituteState = new SubstituteState(pattern, replace);
globalState.searchState = new SearchState(
SearchDirection.Forward,
vimState.cursorStopPosition,
pattern?.patternString,
{},
);
}
}
private setStatusBarText(vimState: VimState, substitutions: number, lines: number) {
if (substitutions === 0) {
StatusBar.displayError(
vimState,
VimError.fromCode(ErrorCode.PatternNotFound, this.arguments.pattern?.patternString),
);
} else if (this.arguments.flags.printCount) {
StatusBar.setText(
vimState,
`${substitutions} match${substitutions > 1 ? 'es' : ''} on ${lines} line${
lines > 1 ? 's' : ''
}`,
);
} else if (substitutions > configuration.report) {
StatusBar.setText(
vimState,
`${substitutions} substitution${substitutions > 1 ? 's' : ''} on ${lines} line${
lines > 1 ? 's' : ''
}`,
);
}
}
}

View File

@ -0,0 +1,251 @@
// eslint-disable-next-line id-denylist
import { alt, optWhitespace, regexp, seq, string, whitespace } from 'parsimmon';
import * as path from 'path';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import {
FileCmd,
FileOpt,
bangParser,
fileCmdParser,
fileOptParser,
numberParser,
} from '../../vimscript/parserUtils';
export enum TabCommandType {
Next,
Previous,
First,
Last,
Absolute,
New,
Close,
Only,
Move,
}
// TODO: many of these arguments aren't used
export type ITabCommandArguments =
| {
type: TabCommandType.Absolute;
count: number;
}
| {
type: TabCommandType.First | TabCommandType.Last;
cmd?: FileCmd;
}
| {
type: TabCommandType.Next | TabCommandType.Previous;
bang: boolean;
cmd?: FileCmd;
count?: number;
}
| {
type: TabCommandType.Close | TabCommandType.Only;
bang: boolean;
count?: number;
}
| {
type: TabCommandType.New;
opt: FileOpt;
cmd?: FileCmd;
file?: string;
}
| {
type: TabCommandType.Move;
direction?: 'left' | 'right';
count?: number;
};
//
// Implements most buffer and tab ex commands
// http://vimdoc.sourceforge.net/htmldoc/tabpage.html
//
export class TabCommand extends ExCommand {
// TODO: `count` is parsed as a number, which is incomplete
public static readonly argParsers = {
bfirst: whitespace
.then(fileCmdParser)
.fallback(undefined)
.map((cmd) => {
return new TabCommand({ type: TabCommandType.First, cmd });
}),
blast: whitespace
.then(fileCmdParser)
.fallback(undefined)
.map((cmd) => {
return new TabCommand({ type: TabCommandType.Last, cmd });
}),
bnext: seq(
bangParser,
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(numberParser).fallback(undefined),
).map(([bang, cmd, count]) => {
return new TabCommand({ type: TabCommandType.Next, bang, cmd, count });
}),
bprev: seq(
bangParser,
optWhitespace.then(fileCmdParser).fallback(undefined),
optWhitespace.then(numberParser).fallback(undefined),
).map(([bang, cmd, count]) => {
return new TabCommand({ type: TabCommandType.Previous, bang, cmd, count });
}),
tabclose: seq(bangParser, optWhitespace.then(numberParser).fallback(undefined)).map(
([bang, count]) => {
return new TabCommand({ type: TabCommandType.Close, bang, count });
},
),
tabonly: seq(bangParser, optWhitespace.then(numberParser).fallback(undefined)).map(
([bang, count]) => {
return new TabCommand({ type: TabCommandType.Only, bang, count });
},
),
tabnew: seq(
optWhitespace.then(fileOptParser).fallback([]),
optWhitespace.then(fileCmdParser).fallback(undefined),
regexp(/\S+/).fallback(undefined),
).map(([opt, cmd, file]) => {
return new TabCommand({
type: TabCommandType.New,
opt,
cmd,
file,
});
}),
tabmove: optWhitespace
.then(
seq(
alt<'right' | 'left'>(string('+').result('right'), string('-').result('left')).fallback(
undefined,
),
numberParser.fallback(undefined),
),
)
.map(([direction, count]) => new TabCommand({ type: TabCommandType.Move, direction, count })),
tabAbsolute: optWhitespace
.then(numberParser.fallback(undefined))
.map((count) => new TabCommand({ type: TabCommandType.Absolute, count: count ?? 0 })),
};
public readonly arguments: ITabCommandArguments;
constructor(args: ITabCommandArguments) {
super();
this.arguments = args;
}
private async executeCommandWithCount(count: number, command: string): Promise<void> {
for (let i = 0; i < count; i++) {
await vscode.commands.executeCommand(command);
}
}
async execute(vimState: VimState): Promise<void> {
switch (this.arguments.type) {
case TabCommandType.Absolute:
if (this.arguments.count !== undefined && this.arguments.count >= 0) {
await vscode.commands.executeCommand(
'workbench.action.openEditorAtIndex',
this.arguments.count - 1,
);
}
break;
case TabCommandType.Next:
if (this.arguments.count !== undefined && this.arguments.count <= 0) {
break;
}
if (this.arguments.count) {
const tabGroup = vscode.window.tabGroups.activeTabGroup;
if (0 < this.arguments.count && this.arguments.count <= tabGroup.tabs.length) {
const tab = tabGroup.tabs[this.arguments.count - 1];
if ((tab.input as vscode.TextDocument).uri !== undefined) {
const uri = (tab.input as vscode.TextDocument).uri;
await vscode.commands.executeCommand('vscode.open', uri);
}
}
} else {
await vscode.commands.executeCommand('workbench.action.nextEditorInGroup');
}
break;
case TabCommandType.Previous:
if (this.arguments.count !== undefined && this.arguments.count <= 0) {
break;
}
await this.executeCommandWithCount(
this.arguments.count || 1,
'workbench.action.previousEditorInGroup',
);
break;
case TabCommandType.First:
await vscode.commands.executeCommand('workbench.action.openEditorAtIndex1');
break;
case TabCommandType.Last:
await vscode.commands.executeCommand('workbench.action.lastEditorInGroup');
break;
case TabCommandType.New: {
const hasFile = !(this.arguments.file === undefined || this.arguments.file === '');
if (hasFile) {
const isAbsolute = path.isAbsolute(this.arguments.file!);
const isInWorkspace =
vscode.workspace.workspaceFolders !== undefined &&
vscode.workspace.workspaceFolders.length > 0;
const currentFilePath = vscode.window.activeTextEditor!.document.uri.fsPath;
let toOpenPath: string;
if (isAbsolute) {
toOpenPath = this.arguments.file!;
} else if (isInWorkspace) {
const workspacePath = vscode.workspace.workspaceFolders![0].uri.path;
toOpenPath = path.join(workspacePath, this.arguments.file!);
} else {
toOpenPath = path.join(path.dirname(currentFilePath), this.arguments.file!);
}
if (toOpenPath !== currentFilePath) {
await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(toOpenPath));
}
} else {
await vscode.commands.executeCommand('workbench.action.files.newUntitledFile');
}
break;
}
case TabCommandType.Close:
// Navigate the correct position
if (this.arguments.count === undefined) {
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
break;
}
if (this.arguments.count === 0) {
// Wrong paramter
break;
}
// TODO: Close Page {count}. Page count is one-based.
break;
case TabCommandType.Only:
await vscode.commands.executeCommand('workbench.action.closeOtherEditors');
break;
case TabCommandType.Move: {
const { count, direction } = this.arguments;
let args;
if (direction !== undefined) {
args = { to: direction, by: 'tab', value: count ?? 1 };
} else if (count === 0) {
args = { to: 'first' };
} else if (count === undefined) {
args = { to: 'last' };
} else {
args = { to: 'position', by: 'tab', value: count + 1 };
}
await vscode.commands.executeCommand('moveActiveEditor', args);
break;
}
default:
break;
}
}
}

View File

@ -0,0 +1,12 @@
import { Parser, succeed } from 'parsimmon';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
export class TerminalCommand extends ExCommand {
public static readonly argParser: Parser<TerminalCommand> = succeed(new TerminalCommand());
async execute(vimState: VimState): Promise<void> {
await vscode.commands.executeCommand('workbench.action.createTerminalEditor');
}
}

View File

@ -0,0 +1,28 @@
import { VimState } from '../../state/vimState';
import { CommandUndo } from '../../actions/commands/actions';
import { Position } from 'vscode';
import { ExCommand } from '../../vimscript/exCommand';
import { optWhitespace, Parser } from 'parsimmon';
import { numberParser } from '../../vimscript/parserUtils';
//
// Implements :u[ndo]
// http://vimdoc.sourceforge.net/htmldoc/undo.html
//
export class UndoCommand extends ExCommand {
public static readonly argParser: Parser<UndoCommand> = optWhitespace
.then(numberParser)
.fallback(undefined)
.map((count) => new UndoCommand(count));
private count?: number;
private constructor(count?: number) {
super();
this.count = count;
}
async execute(vimState: VimState): Promise<void> {
// TODO: Use `this.count`
await new CommandUndo().exec(new Position(0, 0), vimState);
}
}

View File

@ -0,0 +1,27 @@
import { ErrorCode, VimError } from '../../error';
import { StatusBar } from '../../statusBar';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { all, Parser, whitespace } from 'parsimmon';
export class VsCodeCommand extends ExCommand {
public static readonly argParser: Parser<VsCodeCommand> = whitespace
.then(all)
.map((command) => new VsCodeCommand(command));
private command?: string;
public constructor(command?: string) {
super();
this.command = command;
}
async execute(vimState: VimState): Promise<void> {
if (!this.command) {
StatusBar.displayError(vimState, VimError.fromCode(ErrorCode.ArgumentRequired));
return;
}
await vscode.commands.executeCommand(this.command);
}
}

View File

@ -0,0 +1,26 @@
import { Parser } from 'parsimmon';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser } from '../../vimscript/parserUtils';
//
// Implements :wall (write all)
// http://vimdoc.sourceforge.net/htmldoc/editing.html#:wall
//
export class WallCommand extends ExCommand {
public static readonly argParser: Parser<WallCommand> = bangParser.map(
(bang) => new WallCommand(bang),
);
private readonly bang: boolean;
constructor(bang?: boolean) {
super();
this.bang = bang ?? false;
}
async execute(vimState: VimState): Promise<void> {
// TODO : overwrite readonly files when bang? == true
await vscode.workspace.saveAll(false);
}
}

View File

@ -0,0 +1,160 @@
// eslint-disable-next-line id-denylist
import { all, alt, optWhitespace, Parser, seq, string } from 'parsimmon';
import * as path from 'path';
import * as fs from 'platform/fs';
import * as vscode from 'vscode';
import { VimState } from '../../state/vimState';
import { StatusBar } from '../../statusBar';
import { Logger } from '../../util/logger';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser, fileNameParser, FileOpt, fileOptParser } from '../../vimscript/parserUtils';
export type IWriteCommandArguments = {
bang: boolean;
opt: FileOpt;
bgWrite: boolean;
file?: string;
} & ({ cmd: string } | object);
//
// Implements :write
// http://vimdoc.sourceforge.net/htmldoc/editing.html#:write
//
export class WriteCommand extends ExCommand {
public static readonly argParser: Parser<WriteCommand> = seq(
bangParser.skip(optWhitespace),
fileOptParser.skip(optWhitespace),
alt<{ cmd: string } | { file: string }>(
string('!')
.then(all)
.map((cmd) => {
return { cmd };
}),
fileNameParser.map((file) => {
return { file };
}),
// TODO: Support `:help :w_a` ('>>')
).fallback({}),
).map(([bang, opt, other]) => new WriteCommand({ bang, opt, bgWrite: true, ...other }));
public override isRepeatableWithDot = false;
public readonly arguments: IWriteCommandArguments;
constructor(args: IWriteCommandArguments) {
super();
this.arguments = args;
}
async execute(vimState: VimState): Promise<void> {
// TODO: Use arguments: opt, file, cmd
// If the file isn't on disk because it's brand new or on a remote file system, let VS Code handle it
if (vimState.document.isUntitled || vimState.document.uri.scheme !== 'file') {
await this.background(vscode.commands.executeCommand('workbench.action.files.save'));
return;
}
try {
if (this.arguments.file) {
await this.saveAs(vimState, this.arguments.file);
} else {
await fs.accessAsync(vimState.document.fileName, fs.constants.W_OK);
await this.save(vimState);
}
} catch (accessErr) {
if (this.arguments.bang) {
try {
const mode = await fs.getMode(vimState.document.fileName);
await fs.chmodAsync(vimState.document.fileName, 0o666);
// We must do a foreground write so we can await the save
// and chmod the file back to its original state
this.arguments.bgWrite = false;
await this.save(vimState);
await fs.chmodAsync(vimState.document.fileName, mode);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
StatusBar.setText(vimState, e.message);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
StatusBar.setText(vimState, accessErr.message);
}
}
}
// TODO: Aparentemente foi tudo, claro que falta alguns ERRORS e bla bla bla tipo o do :w 8/ E212 mas fds o E357 sobrepoe
// TODO: fazer PR (#1876)
private async saveAs(vimState: VimState, fileName: string): Promise<void> {
try {
const filePath = path.resolve(path.dirname(vimState.document.fileName), fileName);
const fileExists = await fs.existsAsync(filePath);
const uri = vscode.Uri.file(path.resolve(path.dirname(vimState.document.fileName), filePath));
// An extension to the file must be specified.
if (path.extname(filePath) === '') {
StatusBar.setText(vimState, `E357: The file extension must be specified`, true);
return;
}
// Checks if the file exists.
if (fileExists) {
const stats = await vscode.workspace.fs.stat(uri);
const isDirectory = stats.type === vscode.FileType.Directory;
// If it's a directory, throw an error.
if (isDirectory) {
StatusBar.setText(vimState, `E17: "${filePath}" is a directory`, true);
return;
}
// Create a pop-up asking if user wants to overwrite the file.
const confirmOverwrite = await vscode.window.showWarningMessage(
`File "${fileName}" already exists. Do you want to overwrite it?`,
{ modal: true },
'Yes',
'No',
);
if (confirmOverwrite === 'No') {
return;
}
}
// Create a new file in 'filePath', appending the current's file content to it.
await vscode.window.showTextDocument(vimState.document, { preview: false });
await vscode.commands.executeCommand('workbench.action.files.save', uri);
await vscode.workspace.fs.copy(vimState.document.uri, uri, { overwrite: true });
StatusBar.setText(
vimState,
`"${fileName}" ${fileExists ? '' : '[New]'} ${vimState.document.lineCount}L ${
vimState.document.getText().length
}C written`,
);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
StatusBar.setText(vimState, e.message);
}
}
private async save(vimState: VimState): Promise<void> {
await this.background(
vimState.document.save().then((success) => {
if (success) {
StatusBar.setText(
vimState,
`"${path.basename(vimState.document.fileName)}" ${vimState.document.lineCount}L ${
vimState.document.getText().length
}C written`,
);
} else {
Logger.warn(':w failed');
// TODO: What's the right thing to do here?
}
}),
);
}
private async background<T>(fn: Thenable<T>): Promise<void> {
if (!this.arguments.bgWrite) {
await fn;
}
}
}

View File

@ -0,0 +1,42 @@
import { optWhitespace, Parser, regexp, seq } from 'parsimmon';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser, fileNameParser, FileOpt, fileOptParser } from '../../vimscript/parserUtils';
import { QuitCommand } from './quit';
import { WriteCommand } from './write';
//
// Implements :writequit
// http://vimdoc.sourceforge.net/htmldoc/editing.html#write-quit
//
export interface IWriteQuitCommandArguments {
bang: boolean;
opt: FileOpt;
file?: string;
}
export class WriteQuitCommand extends ExCommand {
public static readonly argParser: Parser<WriteQuitCommand> = seq(
bangParser.skip(optWhitespace),
fileOptParser.skip(optWhitespace),
fileNameParser.fallback(undefined),
).map(([bang, opt, file]) => new WriteQuitCommand(file ? { bang, opt, file } : { bang, opt }));
public override isRepeatableWithDot = false;
private readonly args: IWriteQuitCommandArguments;
constructor(args: IWriteQuitCommandArguments) {
super();
this.args = args;
}
// Writing command. Taken as a basis from the "write.ts" file.
async execute(vimState: VimState): Promise<void> {
await new WriteCommand({ bgWrite: false, ...this.args }).execute(vimState);
await new QuitCommand({
// wq! fails when no file name is provided
bang: false,
}).execute(vimState);
}
}

View File

@ -0,0 +1,47 @@
import { Parser, seq, whitespace } from 'parsimmon';
import { VimState } from '../../state/vimState';
import { ExCommand } from '../../vimscript/exCommand';
import { bangParser, FileOpt, fileOptParser } from '../../vimscript/parserUtils';
import * as wall from '../commands/wall';
import * as quit from './quit';
//
// Implements :writequitall
// http://vimdoc.sourceforge.net/htmldoc/editing.html#:wqall
//
export interface IWriteQuitAllCommandArguments {
bang: boolean;
fileOpt: FileOpt;
}
export class WriteQuitAllCommand extends ExCommand {
public static readonly argParser: Parser<WriteQuitAllCommand> = seq(
bangParser,
whitespace.then(fileOptParser).fallback([]),
).map(([bang, fileOpt]) => new WriteQuitAllCommand({ bang, fileOpt }));
public override isRepeatableWithDot = false;
private readonly arguments: IWriteQuitAllCommandArguments;
constructor(args: IWriteQuitAllCommandArguments) {
super();
this.arguments = args;
}
// Writing command. Taken as a basis from the "write.ts" file.
async execute(vimState: VimState): Promise<void> {
const quitArgs: quit.IQuitCommandArguments = {
// wq! fails when no file name is provided
bang: false,
};
const wallCmd = new wall.WallCommand(this.arguments.bang);
await wallCmd.execute(vimState);
// TODO: fileOpt is not used
quitArgs.quitAll = true;
const quitCmd = new quit.QuitCommand(quitArgs);
await quitCmd.execute(vimState);
}
}

Some files were not shown because too many files have changed in this diff Show More