Skip to content

Commit 91b5899

Browse files
authored
Add Python-specific motions (#6393)
- Map ([|])(m|M) to motions to the prev|next start|end of Python functions - Map ([|])([|]) to motions to the prev|next end|start of Python classes Fixes #6213
1 parent fbea6a0 commit 91b5899

File tree

3 files changed

+1127
-7
lines changed

3 files changed

+1127
-7
lines changed
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { RegisterAction } from '../../base';
2+
import { VimState } from '../../../state/vimState';
3+
import { BaseMovement, failedMovement, IMovement } from '../../baseMotion';
4+
import { Position, TextDocument } from 'vscode';
5+
6+
type Type = 'function' | 'class';
7+
type Edge = 'start' | 'end';
8+
type Direction = 'next' | 'prev';
9+
10+
interface LineInfo {
11+
line: number;
12+
indentation: number;
13+
text: string;
14+
}
15+
16+
interface StructureElement {
17+
type: Type;
18+
start: Position;
19+
end: Position;
20+
}
21+
22+
/*
23+
* Utility class used to parse the lines in the document and
24+
* determine class and function boundaries
25+
*
26+
* The class keeps track of two positions: the ORIGINAL and the CURRENT
27+
* using their relative locations to make decisions.
28+
*/
29+
export class PythonDocument {
30+
_document: TextDocument;
31+
structure: StructureElement[];
32+
33+
static readonly reOnlyWhitespace = /\S/;
34+
static readonly reLastNonWhiteSpaceCharacter = /(?<=\S)\s*$/;
35+
static readonly reDefOrClass = /^\s*(def|class) /;
36+
37+
constructor(document: TextDocument) {
38+
this._document = document;
39+
const parsed = PythonDocument._parseLines(document);
40+
this.structure = PythonDocument._parseStructure(parsed);
41+
}
42+
43+
/*
44+
* Generator of the lines of text in the document
45+
*/
46+
static *lines(document: TextDocument): Generator<string> {
47+
for (let index = 0; index < document.lineCount; index++) {
48+
yield document.lineAt(index).text;
49+
}
50+
}
51+
52+
/*
53+
* Calculate the indentation of a line of text.
54+
* Lines consisting entirely of whitespace of "starting" with a comment are defined
55+
* to have an indentation of "undefined".
56+
*/
57+
static _indentation(line: string): number | undefined {
58+
const index: number = line.search(PythonDocument.reOnlyWhitespace);
59+
60+
// Return undefined if line is empty, just whitespace, or starts with a comment
61+
if (index === -1 || line[index] === '#') {
62+
return undefined;
63+
}
64+
65+
return index;
66+
}
67+
68+
/*
69+
* Parse a line of text to extract LineInfo
70+
* Return undefined if the line is empty or starts with a comment
71+
*/
72+
static _parseLine(index: number, text: string): LineInfo | undefined {
73+
const indentation = this._indentation(text);
74+
75+
// Since indentation === 0 is a valid result we need to check for undefined explicitly
76+
return indentation !== undefined ? { line: index, indentation, text } : undefined;
77+
}
78+
79+
static _parseLines(document: TextDocument): LineInfo[] {
80+
const lines = [...this.lines(document)]; // convert generator to Array
81+
const infos = lines.map((text, index) => this._parseLine(index, text));
82+
83+
return infos.filter((x) => x) as LineInfo[]; // filter out empty/comment lines (undefined info)
84+
}
85+
86+
static _parseStructure(lines: LineInfo[]): StructureElement[] {
87+
const last = lines.length;
88+
const structure: StructureElement[] = [];
89+
90+
for (let index = 0; index < last; index++) {
91+
const info = lines[index];
92+
const text = info.text;
93+
const match = text.match(PythonDocument.reDefOrClass);
94+
95+
if (match) {
96+
const type = match[1] === 'def' ? 'function' : 'class';
97+
98+
// Find the end of the current function/class
99+
let idx = index + 1;
100+
101+
for (; idx < last; idx++) {
102+
if (lines[idx].indentation <= info.indentation) {
103+
break;
104+
}
105+
}
106+
107+
// Since we stop when we find the first line with a less indentation
108+
// we pull back one line to get to the end of the function/class
109+
idx--;
110+
111+
const endLine = lines[idx];
112+
113+
structure.push({
114+
type,
115+
start: new Position(info.line, info.indentation),
116+
// Calculate position of last non-white character)
117+
end: new Position(
118+
endLine.line,
119+
endLine.text.search(PythonDocument.reLastNonWhiteSpaceCharacter) - 1
120+
),
121+
});
122+
}
123+
}
124+
125+
return structure;
126+
}
127+
128+
/*
129+
* Find the position of the specified:
130+
* type: function or class
131+
* direction: next or prev
132+
* edge: start or end
133+
*
134+
* With this information one can determine all of the required motions
135+
*/
136+
find(type: Type, direction: Direction, edge: Edge, position: Position): Position | undefined {
137+
// Choose the ordering method name based on direction
138+
const isDirection = direction === 'next' ? 'isAfter' : 'isBefore';
139+
140+
// Filter function for all elements whose "edge" is in the correct "direction"
141+
// relative to the cursor's position
142+
const dir = (element: StructureElement) => element[edge][isDirection](position);
143+
144+
// Filter out elements from structure based on type and direction
145+
const elements = this.structure.filter((elem) => elem.type === type).filter(dir);
146+
147+
if (edge === 'end') {
148+
// When moving to an 'end' the elements should be started by the end position
149+
elements.sort((a, b) => a.end.line - b.end.line);
150+
}
151+
152+
// Return the first match if any exist
153+
if (elements.length) {
154+
// If direction === 'next' return the first element
155+
// otherwise return the last element
156+
const index = direction === 'next' ? 0 : elements.length - 1;
157+
const element = elements[index];
158+
const pos = element[edge];
159+
160+
// execAction MUST return a fully realized Position object created using new
161+
return pos;
162+
}
163+
164+
return undefined;
165+
}
166+
167+
// Use PythonDocument instance to move to specified class boundary
168+
static moveClassBoundary(
169+
document: TextDocument,
170+
position: Position,
171+
vimState: VimState,
172+
forward: boolean,
173+
start: boolean
174+
): Position | IMovement {
175+
const direction = forward ? 'next' : 'prev';
176+
const edge = start ? 'start' : 'end';
177+
178+
return (
179+
new PythonDocument(document).find('class', direction, edge, position) ??
180+
failedMovement(vimState)
181+
);
182+
}
183+
}
184+
185+
// Uses the specified findFunction to execute the motion coupled to the shortcut (keys)
186+
abstract class BasePythonMovement extends BaseMovement {
187+
abstract type: Type;
188+
abstract direction: Direction;
189+
abstract edge: Edge;
190+
191+
public doesActionApply(vimState: VimState, keysPressed: string[]): boolean {
192+
return (
193+
super.doesActionApply(vimState, keysPressed) && vimState.document.languageId === 'python'
194+
);
195+
}
196+
197+
public async execAction(position: Position, vimState: VimState): Promise<Position | IMovement> {
198+
const document = vimState.document;
199+
return (
200+
new PythonDocument(document).find(this.type, this.direction, this.edge, position) ??
201+
failedMovement(vimState)
202+
);
203+
}
204+
}
205+
206+
@RegisterAction
207+
class MovePythonNextFunctionStart extends BasePythonMovement {
208+
keys = [']', 'm'];
209+
type: Type = 'function';
210+
direction: Direction = 'next';
211+
edge: Edge = 'start';
212+
}
213+
214+
@RegisterAction
215+
class MovePythonPrevFunctionStart extends BasePythonMovement {
216+
keys = ['[', 'm'];
217+
type: Type = 'function';
218+
direction: Direction = 'prev';
219+
edge: Edge = 'start';
220+
}
221+
222+
@RegisterAction
223+
class MovePythonNextFunctionEnd extends BasePythonMovement {
224+
keys = [']', 'M'];
225+
type: Type = 'function';
226+
direction: Direction = 'next';
227+
edge: Edge = 'end';
228+
}
229+
230+
@RegisterAction
231+
class MovePythonPrevFunctionEnd extends BasePythonMovement {
232+
keys = ['[', 'M'];
233+
type: Type = 'function';
234+
direction: Direction = 'prev';
235+
edge: Edge = 'end';
236+
}

src/actions/motion.ts

100644100755
Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { SearchDirection } from '../state/searchState';
2222
import { StatusBar } from '../statusBar';
2323
import { clamp } from '../util/util';
2424
import { getCurrentParagraphBeginning, getCurrentParagraphEnd } from '../textobject/paragraph';
25+
import { PythonDocument } from './languages/python/motion';
2526
import { Position } from 'vscode';
2627
import { sorted } from '../common/motion/position';
2728
import { WordType } from '../textobject/word';
@@ -1559,11 +1560,25 @@ class MoveParagraphBegin extends BaseMovement {
15591560
}
15601561

15611562
abstract class MoveSectionBoundary extends BaseMovement {
1562-
abstract boundary: string;
1563+
abstract begin: boolean;
15631564
abstract forward: boolean;
15641565
isJump = true;
15651566

1566-
public async execAction(position: Position, vimState: VimState): Promise<Position> {
1567+
public async execAction(position: Position, vimState: VimState): Promise<Position | IMovement> {
1568+
const document = vimState.document;
1569+
1570+
switch (document.languageId) {
1571+
case 'python':
1572+
return PythonDocument.moveClassBoundary(
1573+
document,
1574+
position,
1575+
vimState,
1576+
this.forward,
1577+
this.begin
1578+
);
1579+
}
1580+
1581+
const boundary = this.begin ? '{' : '}';
15671582
let line = position.line;
15681583

15691584
if (
@@ -1575,7 +1590,7 @@ abstract class MoveSectionBoundary extends BaseMovement {
15751590

15761591
line = this.forward ? line + 1 : line - 1;
15771592

1578-
while (!vimState.document.lineAt(line).text.startsWith(this.boundary)) {
1593+
while (!vimState.document.lineAt(line).text.startsWith(boundary)) {
15791594
if (this.forward) {
15801595
if (line === vimState.document.lineCount - 1) {
15811596
break;
@@ -1598,28 +1613,28 @@ abstract class MoveSectionBoundary extends BaseMovement {
15981613
@RegisterAction
15991614
class MoveNextSectionBegin extends MoveSectionBoundary {
16001615
keys = [']', ']'];
1601-
boundary = '{';
1616+
begin = true;
16021617
forward = true;
16031618
}
16041619

16051620
@RegisterAction
16061621
class MoveNextSectionEnd extends MoveSectionBoundary {
16071622
keys = [']', '['];
1608-
boundary = '}';
1623+
begin = false;
16091624
forward = true;
16101625
}
16111626

16121627
@RegisterAction
16131628
class MovePreviousSectionBegin extends MoveSectionBoundary {
16141629
keys = ['[', '['];
1615-
boundary = '{';
1630+
begin = true;
16161631
forward = false;
16171632
}
16181633

16191634
@RegisterAction
16201635
class MovePreviousSectionEnd extends MoveSectionBoundary {
16211636
keys = ['[', ']'];
1622-
boundary = '}';
1637+
begin = false;
16231638
forward = false;
16241639
}
16251640

0 commit comments

Comments
 (0)