Skip to content

:cur[sor] #7506

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/cmd_line/commands/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { escapeRegExp } from 'lodash';
import { optWhitespace, Parser, seq, string, whitespace, eof } from 'parsimmon';
import { Position } from 'vscode';
import { Cursor } from '../../common/motion/cursor';
import { isVisualMode, Mode } from '../../mode/mode';
import { VimState } from '../../state/vimState';
import { TextEditor } from '../../textEditor';
import { ExCommand } from '../../vimscript/exCommand';
import { LineRange } from '../../vimscript/lineRange';
import { numberParser } from '../../vimscript/parserUtils';
import {
Pattern,
PatternMatch,
SearchDirection,
searchStringParser,
} from '../../vimscript/pattern';

export class CursorCommand extends ExCommand {
public static readonly CURSOR_HERE = '\\#';
private static readonly CURSOR_LOCATION_REGEX = '(?:VSCodeVimCursor){0}';
public override isRepeatableWithDot: boolean = false;
private readonly count: number | undefined; // undefined mean all matches.
private readonly pattern: Pattern | undefined; // undefined mean use current word/selection as pattern
public static readonly argParser: Parser<CursorCommand> = optWhitespace
.then(
seq(
numberParser.skip(whitespace.or(eof)).fallback(-1),
Pattern.parser({
direction: SearchDirection.Forward,
additionalParsers: [
string(CursorCommand.CURSOR_HERE).map(() => CursorCommand.CURSOR_LOCATION_REGEX),
],
})
.map((p) => (p.patternString.length === 0 ? undefined : p))
.fallback(undefined) // fallback to undefined if pattern is empty, so we can use current word/selection as pattern
)
)
.map(([c, sp]) => new CursorCommand(c, sp));

constructor(count: number, pattern: Pattern | undefined) {
super();
this.count = count === -1 ? undefined : count;
this.pattern = pattern;
}

cursorFromMatches(matches: PatternMatch[], pattern: Pattern): Cursor[] {
const matchToPosition = pattern.patternString.includes(CursorCommand.CURSOR_LOCATION_REGEX)
? (match: PatternMatch): Position[] => {
const groupBetweenCursorRegex = new RegExp(
pattern.patternString
.split(CursorCommand.CURSOR_LOCATION_REGEX)
.slice(undefined, -1)
.map((s) => `(${s})`)
.join('')
);
const groupedMatches = groupBetweenCursorRegex.exec(match.groups[0]);
const cursorPositions =
groupedMatches?.slice(1).reduce((acc: Position[], v): Position[] => {
const pos = acc[acc.length - 1] ?? match.range.start;
return [...acc, pos.advancePositionByText(v)];
}, []) ?? [];
return cursorPositions;
}
: (match: PatternMatch): Position[] => {
return [match.range.start];
};

return matches.flatMap(matchToPosition).map((p) => new Cursor(p, p));
}

async execute(vimState: VimState): Promise<void> {
const pattern = this.pattern ?? patternFromCurrentSelection(vimState);
const allMatches = pattern.allMatches(vimState, {
fromPosition: vimState.editor.selection.active,
maxResults: this.count,
});
vimState.cursors = this.cursorFromMatches(allMatches, pattern);
}

override async executeWithRange(vimState: VimState, range: LineRange): Promise<void> {
const pattern = this.pattern ?? patternFromCurrentSelection(vimState);
const allMatchesArgs = this.pattern
? { lineRange: range, maxResults: this.count } // range is used for search RANGE
: { fromPosition: vimState.editor.selection.start, maxResults: this.count }; // range is used for search PATTERN
const allMatches = pattern.allMatches(vimState, allMatchesArgs);
vimState.cursors = this.cursorFromMatches(allMatches, pattern);
}
}

function patternFromCurrentSelection(vimState: VimState): Pattern {
// adapted from actions/commands/search.ts, that's why it's messy
let needle: string;
let isExact: boolean;
if (
vimState.currentMode === Mode.CommandlineInProgress && // should always be true
'commandLine' in vimState.modeData && // should always be true, given the previous line
isVisualMode(vimState.modeData.commandLine.previousMode) // the only interesting part of the condition
) {
needle = vimState.document.getText(vimState.editor.selection);
isExact = false;
} else {
isExact = true;
let currentWord = TextEditor.getWord(vimState.document, vimState.editor.selection.active);
if (currentWord === undefined) {
throw new Error('No word under cursor');
}
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);
}
needle = currentWord;
}

const escapedNeedle = escapeRegExp(needle).replace('/', '\\/');
const searchString = isExact ? `\\<${escapedNeedle}\\>` : escapedNeedle;
const result = searchStringParser({
direction: SearchDirection.Forward,
ignoreSmartcase: true,
}).parse(searchString);
const { pattern, offset } = result.status
? result.value
: { pattern: undefined, offset: undefined };

if (pattern === undefined) {
// TODO: improve error handling
throw new Error('No pattern');
}

return pattern;
}
2 changes: 2 additions & 0 deletions src/vimscript/exCommandParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Breakpoints } from '../cmd_line/commands/breakpoints';
import { BufferDeleteCommand } from '../cmd_line/commands/bufferDelete';
import { CloseCommand } from '../cmd_line/commands/close';
import { CopyCommand } from '../cmd_line/commands/copy';
import { CursorCommand } from '../cmd_line/commands/cursor';
import { DeleteCommand } from '../cmd_line/commands/delete';
import { DigraphsCommand } from '../cmd_line/commands/digraph';
import { FileCommand } from '../cmd_line/commands/file';
Expand Down Expand Up @@ -185,6 +186,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und
[['cu', 'nmap'], undefined],
[['cuna', 'bbrev'], undefined],
[['cunme', 'nu'], undefined],
[['cur', 'sor'], CursorCommand.argParser],
[['cw', 'indow'], succeed(new VsCodeCommand('workbench.panel.markers.view.focus'))],
[['d', 'elete'], DeleteCommand.argParser],
[['deb', 'ug'], undefined],
Expand Down
11 changes: 11 additions & 0 deletions src/vimscript/pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ export class Pattern {
args:
| {
fromPosition: Position;
maxResults?: number;
}
| {
lineRange: LineRange;
maxResults?: number;
},
): PatternMatch[] {
if (this.emptyBranch) {
Expand Down Expand Up @@ -158,6 +160,13 @@ export class Pattern {
groups: match,
});

if (
args.maxResults !== undefined &&
matchRanges.beforeWrapping.length + matchRanges.afterWrapping.length >= args.maxResults
) {
break;
}

if (Date.now() - start > Pattern.MAX_SEARCH_TIME) {
break;
}
Expand Down Expand Up @@ -192,6 +201,7 @@ export class Pattern {
direction: SearchDirection;
ignoreSmartcase?: boolean;
delimiter?: string;
additionalParsers?: Array<Parser<string>>;
}): Parser<Pattern> {
const delimiter = args.delimiter
? args.delimiter
Expand All @@ -202,6 +212,7 @@ export class Pattern {
return seqMap(
string('|').result(true).fallback(false), // Leading | matches everything
alt(
...(args.additionalParsers ?? []),
string('\\%V').map((_) => ({ inSelection: true })),
string('$').map(() => '(?:$(?<!\\r))'), // prevents matching \r\n as two lines
string('^').map(() => '(?:^(?<!\\r))'), // prevents matching \r\n as two lines
Expand Down
98 changes: 98 additions & 0 deletions test/cmd_line/cursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { getAndUpdateModeHandler } from '../../extension';
import { setupWorkspace, cleanUpWorkspace } from '../testUtils';
import { newTest } from '../testSimplifier';
import { CursorCommand } from '../../src/cmd_line/commands/cursor';

function cursor(pattern: string, then: string, count?: number): string {
const countStr = count ? `${count}` : '';
return `:cur ${countStr} ${pattern}\n${then}`;
}

suite('cursor', () => {
const CH = CursorCommand.CURSOR_HERE;

setup(async () => {
await setupWorkspace();
await getAndUpdateModeHandler();
});

suiteTeardown(cleanUpWorkspace);

newTest({
title: 'All matches',
start: ['|abaa', 'aac'],
keysPressed: cursor('a', 'x'),
end: ['|b', 'c'],
});

newTest({
title: 'Limit to one match',
start: ['|aba'],
keysPressed: cursor('a', 'x', 1),
end: ['|ba'],
});

newTest({
title: 'Start at end of line',
start: ['abaa|', 'aac'],
keysPressed: cursor('a', 'x', 2),
end: ['abaa', '|c'],
});

newTest({
title: 'With cursor position indicator',
start: ['|abc'],
keysPressed: cursor(`a${CH}bc`, 'x'),
end: ['a|c'],
});

newTest({
title: 'With multiple cursor position indicators',
start: ['|abcde'],
keysPressed: cursor(`a${CH}bc${CH}d`, 'x'),
end: ['a|ce'],
});

newTest({
title: 'With multiple cursor position indicators with limit',
start: ['abc|de', 'abcde', 'abcde', 'abcde'],
keysPressed: cursor(`a${CH}bc${CH}d`, 'x', 2),
end: ['abcde', 'a|ce', 'ace', 'abcde'],
});

newTest({
title: 'With multiple cursor position indicators with regex',
start: ['|abc!e', 'a123!e'],
keysPressed: cursor(`${CH}a\\w+${CH}!`, 'x'),
end: ['|bce', '123e'],
});

// tests for empty pattern. should use the current word/selection as pattern
newTest({
title: 'Empty pattern',
start: ['|abc', 'abc', 'bbc', 'abc'],
keysPressed: cursor('', 'dw'),
end: ['|', '', 'bbc', ''],
});

newTest({
title: 'Empty pattern with limit',
start: ['|abc', 'abc', 'bbc', 'abc'],
keysPressed: cursor('', 'dw', 2),
end: ['|', '', 'bbc', 'abc'],
});

newTest({
title: 'Empty pattern, selection',
start: ['|abcd', 'abcd', 'bbcd', 'abcd'],
keysPressed: 'lvll' + cursor('', 'dw'),
end: ['|a', 'a', 'b', 'a'],
});

newTest({
title: 'Empty pattern, selection with limit',
start: ['|abcd', 'abcd', 'bbcd', 'abcd'],
keysPressed: 'lvll' + cursor('', 'dw', 3),
end: ['|a', 'a', 'b', 'abcd'],
});
});
Loading