@thazhemadam/vim-state 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +165 -0
- package/LICENSE.GPL +674 -0
- package/README.md +18 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +1 -0
- package/dist/editor/constants.d.ts +13 -0
- package/dist/editor/constants.js +31 -0
- package/dist/editor/index.d.ts +13 -0
- package/dist/editor/index.js +621 -0
- package/dist/editor/operators.d.ts +17 -0
- package/dist/editor/operators.js +85 -0
- package/dist/editor/types.d.ts +118 -0
- package/dist/editor/types.js +1 -0
- package/dist/editor/utils.d.ts +85 -0
- package/dist/editor/utils.js +206 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.js +1 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +1 -0
- package/dist/history.d.ts +17 -0
- package/dist/history.js +44 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/machine.d.ts +174 -0
- package/dist/machine.js +1235 -0
- package/dist/selectors.d.ts +9 -0
- package/dist/selectors.js +59 -0
- package/dist/state.d.ts +8 -0
- package/dist/state.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { endOfBigWordPosition, endOfWordPosition, nextBigWordPosition, nextWordPosition, previousBigWordPosition, previousWordPosition, } from "./utils.js";
|
|
2
|
+
/** Build the register metadata for a range without mutating editor state. */
|
|
3
|
+
export function registerForRange(lines, range) {
|
|
4
|
+
return {
|
|
5
|
+
text: textForRange(lines, range),
|
|
6
|
+
type: range.type === "linewise" ? "linewise" : "charwise",
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Resolve repeated word-ish motions without mutating the host editor. */
|
|
10
|
+
export function countedWordPosition(lines, start, noun, count) {
|
|
11
|
+
let position = start;
|
|
12
|
+
for (let i = 0; i < count; ++i) {
|
|
13
|
+
const next = wordPosition(lines, position, noun);
|
|
14
|
+
if (samePosition(next, position)) {
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
position = next;
|
|
18
|
+
}
|
|
19
|
+
return position;
|
|
20
|
+
}
|
|
21
|
+
export function samePosition(left, right) {
|
|
22
|
+
return left.line === right.line && left.col === right.col;
|
|
23
|
+
}
|
|
24
|
+
/** Return a forward charwise range even when the motion destination is before the cursor. */
|
|
25
|
+
export function normalizedCharRange(start, end) {
|
|
26
|
+
if (start.line < end.line ||
|
|
27
|
+
(start.line === end.line && start.col <= end.col)) {
|
|
28
|
+
return { type: "charwise", start, end };
|
|
29
|
+
}
|
|
30
|
+
return { type: "charwise", start: end, end: start };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Return how many forward deletes move text from `start` up to `end`.
|
|
34
|
+
*
|
|
35
|
+
* Crossing a line counts the newline separator as one deleted character, matching
|
|
36
|
+
* the host editor's repeated forward-delete behavior.
|
|
37
|
+
*/
|
|
38
|
+
export function deleteDistance(lines, start, end) {
|
|
39
|
+
if (start.line === end.line) {
|
|
40
|
+
return Math.max(end.col - start.col, 0);
|
|
41
|
+
}
|
|
42
|
+
let distance = (lines[start.line]?.length ?? 0) - start.col + 1;
|
|
43
|
+
for (let line = start.line + 1; line < end.line; ++line) {
|
|
44
|
+
distance += (lines[line]?.length ?? 0) + 1;
|
|
45
|
+
}
|
|
46
|
+
return distance + end.col;
|
|
47
|
+
}
|
|
48
|
+
/** Resolve one supported word-ish motion. */
|
|
49
|
+
function wordPosition(lines, position, noun) {
|
|
50
|
+
switch (noun) {
|
|
51
|
+
case "nextWord":
|
|
52
|
+
return nextWordPosition(lines, position);
|
|
53
|
+
case "previousWord":
|
|
54
|
+
return previousWordPosition(lines, position);
|
|
55
|
+
case "endOfWord":
|
|
56
|
+
return endOfWordPosition(lines, position);
|
|
57
|
+
case "nextBigWord":
|
|
58
|
+
return nextBigWordPosition(lines, position);
|
|
59
|
+
case "previousBigWord":
|
|
60
|
+
return previousBigWordPosition(lines, position);
|
|
61
|
+
case "endOfBigWord":
|
|
62
|
+
return endOfBigWordPosition(lines, position);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Return the exact buffer text covered by a resolved operator range. */
|
|
66
|
+
function textForRange(lines, range) {
|
|
67
|
+
switch (range.type) {
|
|
68
|
+
case "charwise":
|
|
69
|
+
return charwiseText(lines, range.start, range.end);
|
|
70
|
+
case "linewise":
|
|
71
|
+
return `${lines.slice(range.startLine, range.endLine + 1).join("\n")}\n`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Return charwise text between two positions, preserving embedded newlines. */
|
|
75
|
+
function charwiseText(lines, start, end) {
|
|
76
|
+
if (start.line === end.line) {
|
|
77
|
+
return (lines[start.line] ?? "").slice(start.col, end.col);
|
|
78
|
+
}
|
|
79
|
+
const chunks = [(lines[start.line] ?? "").slice(start.col)];
|
|
80
|
+
for (let line = start.line + 1; line < end.line; ++line) {
|
|
81
|
+
chunks.push(lines[line] ?? "");
|
|
82
|
+
}
|
|
83
|
+
chunks.push((lines[end.line] ?? "").slice(0, end.col));
|
|
84
|
+
return chunks.join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Target accepted by line-jump commands. Numbers are 1-based like Vim counts. */
|
|
2
|
+
export type VimLineTarget = number | "first" | "last";
|
|
3
|
+
/** Single-character search operation that waits for a target character. */
|
|
4
|
+
export type VimFindOperation = "find" | "till";
|
|
5
|
+
/** Direction for single-character search operations. */
|
|
6
|
+
export type VimFindDirection = "forward" | "backward";
|
|
7
|
+
/** Target-character motion resolved after f/F/t/T receives its character. */
|
|
8
|
+
export type VimFindTarget = {
|
|
9
|
+
type: "find";
|
|
10
|
+
operation: VimFindOperation;
|
|
11
|
+
direction: VimFindDirection;
|
|
12
|
+
char: string;
|
|
13
|
+
};
|
|
14
|
+
/** Operator-only text object target. */
|
|
15
|
+
export type VimTextObject = {
|
|
16
|
+
type: "textObject";
|
|
17
|
+
kind: "inner" | "around";
|
|
18
|
+
object: "word";
|
|
19
|
+
};
|
|
20
|
+
/** Supported cursor motions understood by the current Vim editor core. */
|
|
21
|
+
export type VimMotion = "left" | "down" | "up" | "right" | "lineStart" | "lineEnd" | "firstNonBlank" | "nextWord" | "previousWord" | "endOfWord" | "nextBigWord" | "previousBigWord" | "endOfBigWord";
|
|
22
|
+
/** Pending operator plus the count captured before the operator key. */
|
|
23
|
+
export type VimOperator = {
|
|
24
|
+
name: "delete" | "change" | "yank";
|
|
25
|
+
count?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Unnamed-register payload captured by delete/change operations. */
|
|
28
|
+
export type VimRegister = {
|
|
29
|
+
text: string;
|
|
30
|
+
type: "charwise" | "linewise";
|
|
31
|
+
};
|
|
32
|
+
export type VimEditorOptions = {
|
|
33
|
+
/**
|
|
34
|
+
* Called whenever an operation writes Vim's unnamed register.
|
|
35
|
+
* Hosts can use this to mirror the register to a system clipboard, remote
|
|
36
|
+
* clipboard, or any other external paste target. Leave unset to keep register
|
|
37
|
+
* writes inside Vim only.
|
|
38
|
+
*/
|
|
39
|
+
onUnnamedRegisterWrite?: (register: VimRegister) => void;
|
|
40
|
+
};
|
|
41
|
+
/** Zero-based editor position. `col` is a UTF-16/string column for now. */
|
|
42
|
+
export type VimPosition = {
|
|
43
|
+
line: number;
|
|
44
|
+
col: number;
|
|
45
|
+
};
|
|
46
|
+
export type VimVisualMode = "charwise" | "linewise";
|
|
47
|
+
/** Visual selection anchor; the active end is the editor cursor. */
|
|
48
|
+
export type VimVisualSelection = {
|
|
49
|
+
mode: VimVisualMode;
|
|
50
|
+
anchor: VimPosition;
|
|
51
|
+
};
|
|
52
|
+
/** Motion or operator-range noun an operator can act on (`line` backs doubled operators like `dd`). */
|
|
53
|
+
export type VimNoun = VimMotion | VimFindTarget | VimTextObject | "line";
|
|
54
|
+
export type VimOperatorTarget = VimNoun | VimVisualSelection;
|
|
55
|
+
export type VimCaseTransform = "toggle" | "lower" | "upper";
|
|
56
|
+
/**
|
|
57
|
+
* Minimal internal operator range model.
|
|
58
|
+
*
|
|
59
|
+
* Charwise ranges are cursor-position spans. Linewise ranges are whole row spans
|
|
60
|
+
* because Vim linewise operations ignore cursor column and carry different
|
|
61
|
+
* register/cursor semantics.
|
|
62
|
+
*/
|
|
63
|
+
export type VimRange = {
|
|
64
|
+
type: "charwise";
|
|
65
|
+
start: VimPosition;
|
|
66
|
+
end: VimPosition;
|
|
67
|
+
} | {
|
|
68
|
+
type: "linewise";
|
|
69
|
+
startLine: number;
|
|
70
|
+
endLine: number;
|
|
71
|
+
};
|
|
72
|
+
/** Resolved real motion data: where motion lands and what range that motion covers for operators. */
|
|
73
|
+
export type VimMotionResult = {
|
|
74
|
+
range: VimRange;
|
|
75
|
+
destination: VimPosition;
|
|
76
|
+
};
|
|
77
|
+
/** Semantic editor operations the Vim state machine can request. */
|
|
78
|
+
export interface VimEditorApi extends Pick<VimEditorHost, "getCursor"> {
|
|
79
|
+
move(target: VimMotion | VimPosition): void;
|
|
80
|
+
insertLineBelow(): void;
|
|
81
|
+
insertLineAbove(): void;
|
|
82
|
+
joinLines(count?: number): void;
|
|
83
|
+
join(target: VimOperatorTarget): void;
|
|
84
|
+
goToLine(line: VimLineTarget): void;
|
|
85
|
+
moveToChar(operation: VimFindOperation, direction: VimFindDirection, char: string, count?: number): void;
|
|
86
|
+
placeCaretAtLineStart(): void;
|
|
87
|
+
placeCaretAfterCursor(): void;
|
|
88
|
+
placeCaretAtLineEnd(): void;
|
|
89
|
+
delete(target: VimOperatorTarget, count?: number): VimRegister | undefined;
|
|
90
|
+
change(target: VimOperatorTarget, count?: number): VimRegister | undefined;
|
|
91
|
+
yank(target: VimOperatorTarget, count?: number): VimRegister | undefined;
|
|
92
|
+
replace(target: VimOperatorTarget, replacement: VimRegister, emitRegisterWrite?: boolean): VimRegister | undefined;
|
|
93
|
+
transformCase(target: VimOperatorTarget, transform: VimCaseTransform): void;
|
|
94
|
+
put(register: VimRegister, placement: "before" | "after"): void;
|
|
95
|
+
replaceCharUnderCursor(char: string): void;
|
|
96
|
+
toggleCase(count?: number): void;
|
|
97
|
+
undo(): void;
|
|
98
|
+
redo(): void;
|
|
99
|
+
clampCursorColumn(): void;
|
|
100
|
+
}
|
|
101
|
+
/** Configuration operations exposed by a Vim editor. */
|
|
102
|
+
export interface VimEditorConfiguration {
|
|
103
|
+
/** Replace the options used by subsequent editor operations. */
|
|
104
|
+
setOptions(options: VimEditorOptions): void;
|
|
105
|
+
}
|
|
106
|
+
/** Constructor accepted by the TypeScript mixin class-expression pattern. */
|
|
107
|
+
export type Constructor<T = {}> = new (...args: any[]) => T;
|
|
108
|
+
/** Primitive host-editor surface required by the reusable Vim composition mixin. */
|
|
109
|
+
export interface VimEditorHost {
|
|
110
|
+
getCursor(): VimPosition;
|
|
111
|
+
getLines(): string[];
|
|
112
|
+
/** Restore the most recent host undo point, when supported. */
|
|
113
|
+
undoEditor?(): void;
|
|
114
|
+
/** Restore the most recent host redo point, when supported. */
|
|
115
|
+
redoEditor?(): void;
|
|
116
|
+
/** Forward raw input/control bytes to the underlying host editor. */
|
|
117
|
+
sendInputToEditor(data: string): void;
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { VimNoun, VimOperator } from "./types.js";
|
|
2
|
+
/** Last valid Normal-mode cursor column for a line; empty lines stay at column 0. */
|
|
3
|
+
export declare function normalMaxColumn(line: string): number;
|
|
4
|
+
/** Column of the first non-blank character, or 0 for blank/empty lines. */
|
|
5
|
+
export declare function firstNonBlankColumn(line: string): number;
|
|
6
|
+
/**
|
|
7
|
+
* Return the next Normal-mode `w` target.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately small Vim subset:
|
|
10
|
+
* - a run is a contiguous sequence of characters with the same `charType()`
|
|
11
|
+
* - word chars are ASCII letters, digits, and `_`
|
|
12
|
+
* - punctuation is any other non-whitespace run
|
|
13
|
+
* - whitespace is skipped after leaving the current run
|
|
14
|
+
* - scanning continues onto following lines
|
|
15
|
+
* - if no next run exists, clamp to the final Normal-mode cursor position
|
|
16
|
+
*/
|
|
17
|
+
export declare function nextWordPosition(lines: string[], cursor: {
|
|
18
|
+
line: number;
|
|
19
|
+
col: number;
|
|
20
|
+
}): {
|
|
21
|
+
line: number;
|
|
22
|
+
col: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Return the previous Normal-mode `b` target.
|
|
26
|
+
*
|
|
27
|
+
* Uses the same small run model as `nextWordPosition()`. The only special case
|
|
28
|
+
* is starting at the first character of a run: `b` skips that run and lands on
|
|
29
|
+
* the previous one instead of staying in place.
|
|
30
|
+
*/
|
|
31
|
+
export declare function previousWordPosition(lines: string[], cursor: {
|
|
32
|
+
line: number;
|
|
33
|
+
col: number;
|
|
34
|
+
}): {
|
|
35
|
+
line: number;
|
|
36
|
+
col: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Return the next Normal-mode `e` target.
|
|
40
|
+
*
|
|
41
|
+
* Uses the same small run model as `nextWordPosition()`. From whitespace it
|
|
42
|
+
* skips forward to the next run; from a run it lands on that run's end unless
|
|
43
|
+
* already there, in which case it advances to the next run's end.
|
|
44
|
+
*/
|
|
45
|
+
export declare function endOfWordPosition(lines: string[], cursor: {
|
|
46
|
+
line: number;
|
|
47
|
+
col: number;
|
|
48
|
+
}): {
|
|
49
|
+
line: number;
|
|
50
|
+
col: number;
|
|
51
|
+
};
|
|
52
|
+
/** Return the next whitespace-delimited WORD start for Normal-mode `W`. */
|
|
53
|
+
export declare function nextBigWordPosition(lines: string[], cursor: {
|
|
54
|
+
line: number;
|
|
55
|
+
col: number;
|
|
56
|
+
}): {
|
|
57
|
+
line: number;
|
|
58
|
+
col: number;
|
|
59
|
+
};
|
|
60
|
+
/** Return the previous whitespace-delimited WORD start for Normal-mode `B`. */
|
|
61
|
+
export declare function previousBigWordPosition(lines: string[], cursor: {
|
|
62
|
+
line: number;
|
|
63
|
+
col: number;
|
|
64
|
+
}): {
|
|
65
|
+
line: number;
|
|
66
|
+
col: number;
|
|
67
|
+
};
|
|
68
|
+
/** Return the next whitespace-delimited WORD end for Normal-mode `E`. */
|
|
69
|
+
export declare function endOfBigWordPosition(lines: string[], cursor: {
|
|
70
|
+
line: number;
|
|
71
|
+
col: number;
|
|
72
|
+
}): {
|
|
73
|
+
line: number;
|
|
74
|
+
col: number;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a Vim key into the noun/motion it names.
|
|
78
|
+
*
|
|
79
|
+
* With a pending operator, repeating the operator key means the current line
|
|
80
|
+
* (`dd`, later `cc`, `yy`). Otherwise keys are resolved through the shared
|
|
81
|
+
* normal-motion noun table.
|
|
82
|
+
*/
|
|
83
|
+
export declare function nounForKey(key: string, operator?: VimOperator): VimNoun | undefined;
|
|
84
|
+
/** Return the same character with letter case flipped; non-letters are unchanged. */
|
|
85
|
+
export declare function toggleCase(char: string): string;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { NOUN_BY_KEY, OPERATOR_KEY } from "./constants.js";
|
|
2
|
+
/** Last valid Normal-mode cursor column for a line; empty lines stay at column 0. */
|
|
3
|
+
export function normalMaxColumn(line) {
|
|
4
|
+
return Math.max(line.length - 1, 0);
|
|
5
|
+
}
|
|
6
|
+
/** Column of the first non-blank character, or 0 for blank/empty lines. */
|
|
7
|
+
export function firstNonBlankColumn(line) {
|
|
8
|
+
const match = /\S/.exec(line);
|
|
9
|
+
return match?.index ?? 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Return the next Normal-mode `w` target.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately small Vim subset:
|
|
15
|
+
* - a run is a contiguous sequence of characters with the same `charType()`
|
|
16
|
+
* - word chars are ASCII letters, digits, and `_`
|
|
17
|
+
* - punctuation is any other non-whitespace run
|
|
18
|
+
* - whitespace is skipped after leaving the current run
|
|
19
|
+
* - scanning continues onto following lines
|
|
20
|
+
* - if no next run exists, clamp to the final Normal-mode cursor position
|
|
21
|
+
*/
|
|
22
|
+
export function nextWordPosition(lines, cursor) {
|
|
23
|
+
for (let lineIndex = cursor.line; lineIndex < lines.length; lineIndex += 1) {
|
|
24
|
+
const line = lines[lineIndex] ?? "";
|
|
25
|
+
let col = lineIndex === cursor.line ? cursor.col : 0;
|
|
26
|
+
if (lineIndex === cursor.line &&
|
|
27
|
+
col < line.length &&
|
|
28
|
+
!isWhitespace(line[col])) {
|
|
29
|
+
const type = charType(line[col]);
|
|
30
|
+
while (col < line.length && charType(line[col]) === type) {
|
|
31
|
+
col += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
while (col < line.length && isWhitespace(line[col])) {
|
|
35
|
+
col += 1;
|
|
36
|
+
}
|
|
37
|
+
if (col < line.length) {
|
|
38
|
+
return { line: lineIndex, col };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const lastLine = Math.max(lines.length - 1, 0);
|
|
42
|
+
return { line: lastLine, col: normalMaxColumn(lines[lastLine] ?? "") };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Return the previous Normal-mode `b` target.
|
|
46
|
+
*
|
|
47
|
+
* Uses the same small run model as `nextWordPosition()`. The only special case
|
|
48
|
+
* is starting at the first character of a run: `b` skips that run and lands on
|
|
49
|
+
* the previous one instead of staying in place.
|
|
50
|
+
*/
|
|
51
|
+
export function previousWordPosition(lines, cursor) {
|
|
52
|
+
for (let lineIndex = cursor.line; lineIndex >= 0; lineIndex -= 1) {
|
|
53
|
+
const line = lines[lineIndex] ?? "";
|
|
54
|
+
let col = lineIndex === cursor.line ? cursor.col : line.length - 1;
|
|
55
|
+
while (col >= 0) {
|
|
56
|
+
while (col >= 0 && isWhitespace(line[col])) {
|
|
57
|
+
col -= 1;
|
|
58
|
+
}
|
|
59
|
+
if (col < 0) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const type = charType(line[col]);
|
|
63
|
+
let start = col;
|
|
64
|
+
while (start > 0 && charType(line[start - 1]) === type) {
|
|
65
|
+
start -= 1;
|
|
66
|
+
}
|
|
67
|
+
if (lineIndex !== cursor.line || start !== cursor.col) {
|
|
68
|
+
return { line: lineIndex, col: start };
|
|
69
|
+
}
|
|
70
|
+
col = start - 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { line: 0, col: 0 };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Return the next Normal-mode `e` target.
|
|
77
|
+
*
|
|
78
|
+
* Uses the same small run model as `nextWordPosition()`. From whitespace it
|
|
79
|
+
* skips forward to the next run; from a run it lands on that run's end unless
|
|
80
|
+
* already there, in which case it advances to the next run's end.
|
|
81
|
+
*/
|
|
82
|
+
export function endOfWordPosition(lines, cursor) {
|
|
83
|
+
for (let lineIndex = cursor.line; lineIndex < lines.length; lineIndex += 1) {
|
|
84
|
+
const line = lines[lineIndex] ?? "";
|
|
85
|
+
let col = lineIndex === cursor.line ? cursor.col : 0;
|
|
86
|
+
while (col < line.length) {
|
|
87
|
+
while (col < line.length && isWhitespace(line[col])) {
|
|
88
|
+
col += 1;
|
|
89
|
+
}
|
|
90
|
+
if (col >= line.length) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const type = charType(line[col]);
|
|
94
|
+
let end = col;
|
|
95
|
+
while (end + 1 < line.length && charType(line[end + 1]) === type) {
|
|
96
|
+
end += 1;
|
|
97
|
+
}
|
|
98
|
+
if (lineIndex !== cursor.line || end !== cursor.col) {
|
|
99
|
+
return { line: lineIndex, col: end };
|
|
100
|
+
}
|
|
101
|
+
col = end + 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const lastLine = Math.max(lines.length - 1, 0);
|
|
105
|
+
return { line: lastLine, col: normalMaxColumn(lines[lastLine] ?? "") };
|
|
106
|
+
}
|
|
107
|
+
/** Return the next whitespace-delimited WORD start for Normal-mode `W`. */
|
|
108
|
+
export function nextBigWordPosition(lines, cursor) {
|
|
109
|
+
for (let lineIndex = cursor.line; lineIndex < lines.length; lineIndex += 1) {
|
|
110
|
+
const line = lines[lineIndex] ?? "";
|
|
111
|
+
let col = lineIndex === cursor.line ? cursor.col : 0;
|
|
112
|
+
if (lineIndex === cursor.line &&
|
|
113
|
+
col < line.length &&
|
|
114
|
+
!isWhitespace(line[col])) {
|
|
115
|
+
while (col < line.length && !isWhitespace(line[col])) {
|
|
116
|
+
col += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
while (col < line.length && isWhitespace(line[col])) {
|
|
120
|
+
col += 1;
|
|
121
|
+
}
|
|
122
|
+
if (col < line.length) {
|
|
123
|
+
return { line: lineIndex, col };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const lastLine = Math.max(lines.length - 1, 0);
|
|
127
|
+
return { line: lastLine, col: normalMaxColumn(lines[lastLine] ?? "") };
|
|
128
|
+
}
|
|
129
|
+
/** Return the previous whitespace-delimited WORD start for Normal-mode `B`. */
|
|
130
|
+
export function previousBigWordPosition(lines, cursor) {
|
|
131
|
+
for (let lineIndex = cursor.line; lineIndex >= 0; lineIndex -= 1) {
|
|
132
|
+
const line = lines[lineIndex] ?? "";
|
|
133
|
+
let col = lineIndex === cursor.line ? cursor.col : line.length - 1;
|
|
134
|
+
while (col >= 0) {
|
|
135
|
+
while (col >= 0 && isWhitespace(line[col])) {
|
|
136
|
+
col -= 1;
|
|
137
|
+
}
|
|
138
|
+
if (col < 0) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
let start = col;
|
|
142
|
+
while (start > 0 && !isWhitespace(line[start - 1])) {
|
|
143
|
+
start -= 1;
|
|
144
|
+
}
|
|
145
|
+
if (lineIndex !== cursor.line || start !== cursor.col) {
|
|
146
|
+
return { line: lineIndex, col: start };
|
|
147
|
+
}
|
|
148
|
+
col = start - 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { line: 0, col: 0 };
|
|
152
|
+
}
|
|
153
|
+
/** Return the next whitespace-delimited WORD end for Normal-mode `E`. */
|
|
154
|
+
export function endOfBigWordPosition(lines, cursor) {
|
|
155
|
+
for (let lineIndex = cursor.line; lineIndex < lines.length; lineIndex += 1) {
|
|
156
|
+
const line = lines[lineIndex] ?? "";
|
|
157
|
+
let col = lineIndex === cursor.line ? cursor.col : 0;
|
|
158
|
+
while (col < line.length) {
|
|
159
|
+
while (col < line.length && isWhitespace(line[col])) {
|
|
160
|
+
col += 1;
|
|
161
|
+
}
|
|
162
|
+
if (col >= line.length) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
let end = col;
|
|
166
|
+
while (end + 1 < line.length && !isWhitespace(line[end + 1])) {
|
|
167
|
+
end += 1;
|
|
168
|
+
}
|
|
169
|
+
if (lineIndex !== cursor.line || end !== cursor.col) {
|
|
170
|
+
return { line: lineIndex, col: end };
|
|
171
|
+
}
|
|
172
|
+
col = end + 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const lastLine = Math.max(lines.length - 1, 0);
|
|
176
|
+
return { line: lastLine, col: normalMaxColumn(lines[lastLine] ?? "") };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a Vim key into the noun/motion it names.
|
|
180
|
+
*
|
|
181
|
+
* With a pending operator, repeating the operator key means the current line
|
|
182
|
+
* (`dd`, later `cc`, `yy`). Otherwise keys are resolved through the shared
|
|
183
|
+
* normal-motion noun table.
|
|
184
|
+
*/
|
|
185
|
+
export function nounForKey(key, operator) {
|
|
186
|
+
if (operator && key === OPERATOR_KEY[operator.name]) {
|
|
187
|
+
return "line";
|
|
188
|
+
}
|
|
189
|
+
return NOUN_BY_KEY[key];
|
|
190
|
+
}
|
|
191
|
+
/** Return the same character with letter case flipped; non-letters are unchanged. */
|
|
192
|
+
export function toggleCase(char) {
|
|
193
|
+
const lower = char.toLocaleLowerCase();
|
|
194
|
+
const upper = char.toLocaleUpperCase();
|
|
195
|
+
return char === lower && lower !== upper ? upper : lower;
|
|
196
|
+
}
|
|
197
|
+
/** Classify characters for the initial word-motion subset. */
|
|
198
|
+
function charType(char) {
|
|
199
|
+
if (isWhitespace(char)) {
|
|
200
|
+
return "space";
|
|
201
|
+
}
|
|
202
|
+
return /[A-Za-z0-9_]/.test(char) ? "word" : "punct";
|
|
203
|
+
}
|
|
204
|
+
function isWhitespace(char) {
|
|
205
|
+
return /\s/.test(char);
|
|
206
|
+
}
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./editor/index.js";
|
package/dist/editor.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./editor/index.js";
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Vim event vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Everything starts as a key. Printable characters are still keys: the current
|
|
5
|
+
* Vim mode decides whether `a` inserts text or runs the Normal-mode append
|
|
6
|
+
* command. Host-specific paste/IME handling belongs in adapters until we have
|
|
7
|
+
* a Vim-semantics reason to model it here.
|
|
8
|
+
*/
|
|
9
|
+
export interface VimKeyEvent {
|
|
10
|
+
type: "KEY";
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
13
|
+
export type VimEvent = VimKeyEvent;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Linear undo/redo history with bounded snapshot stacks. */
|
|
2
|
+
export declare class LinearHistory<Snapshot> {
|
|
3
|
+
private readonly maxSnapshots;
|
|
4
|
+
private readonly undoStack;
|
|
5
|
+
private readonly redoStack;
|
|
6
|
+
constructor(maxSnapshots?: number);
|
|
7
|
+
/** Remove all undo and redo snapshots. */
|
|
8
|
+
reset(): void;
|
|
9
|
+
/** Record a new edit boundary and discard any abandoned redo path. */
|
|
10
|
+
commit(snapshot: Snapshot): void;
|
|
11
|
+
/** Return the previous snapshot and move the current state onto redo. */
|
|
12
|
+
undo(current: Snapshot): Snapshot | undefined;
|
|
13
|
+
/** Return the next snapshot and move the current state back onto undo. */
|
|
14
|
+
redo(current: Snapshot): Snapshot | undefined;
|
|
15
|
+
/** Push a snapshot and discard the oldest entry if the stack is full. */
|
|
16
|
+
private pushBounded;
|
|
17
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Linear undo/redo history with bounded snapshot stacks. */
|
|
2
|
+
export class LinearHistory {
|
|
3
|
+
maxSnapshots;
|
|
4
|
+
undoStack = [];
|
|
5
|
+
redoStack = [];
|
|
6
|
+
constructor(maxSnapshots = 100) {
|
|
7
|
+
this.maxSnapshots = maxSnapshots;
|
|
8
|
+
}
|
|
9
|
+
/** Remove all undo and redo snapshots. */
|
|
10
|
+
reset() {
|
|
11
|
+
this.undoStack.length = 0;
|
|
12
|
+
this.redoStack.length = 0;
|
|
13
|
+
}
|
|
14
|
+
/** Record a new edit boundary and discard any abandoned redo path. */
|
|
15
|
+
commit(snapshot) {
|
|
16
|
+
this.pushBounded(this.undoStack, snapshot);
|
|
17
|
+
this.redoStack.length = 0;
|
|
18
|
+
}
|
|
19
|
+
/** Return the previous snapshot and move the current state onto redo. */
|
|
20
|
+
undo(current) {
|
|
21
|
+
const snapshot = this.undoStack.pop();
|
|
22
|
+
if (!snapshot) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
this.pushBounded(this.redoStack, current);
|
|
26
|
+
return snapshot;
|
|
27
|
+
}
|
|
28
|
+
/** Return the next snapshot and move the current state back onto undo. */
|
|
29
|
+
redo(current) {
|
|
30
|
+
const snapshot = this.redoStack.pop();
|
|
31
|
+
if (!snapshot) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
this.pushBounded(this.undoStack, current);
|
|
35
|
+
return snapshot;
|
|
36
|
+
}
|
|
37
|
+
/** Push a snapshot and discard the oldest entry if the stack is full. */
|
|
38
|
+
pushBounded(stack, snapshot) {
|
|
39
|
+
stack.push(structuredClone(snapshot));
|
|
40
|
+
if (stack.length > this.maxSnapshots) {
|
|
41
|
+
stack.shift();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED