@vemjs/core 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/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @vemjs/core - Pure Vim State Machine Engine
3
+ */
4
+
5
+ export type EditorMode = 'NORMAL' | 'INSERT' | 'VISUAL' | 'COMMAND';
6
+
7
+ export interface Position {
8
+ line: number;
9
+ character: number;
10
+ }
11
+
12
+ export { VimBuffer, UndoManager } from './buffer';
13
+ export {
14
+ getCharClass,
15
+ nextPosition,
16
+ prevPosition,
17
+ getWordForward,
18
+ getWordBackward,
19
+ getWordEndForward,
20
+ getTextObjectRange,
21
+ } from './motions';
22
+ export { parseKeys } from './parser';
23
+ export type { ParsedCommand } from './parser';
24
+ export { VemEditorState } from './editor';
25
+ export type {
26
+ RegisterContent,
27
+ VisualType,
28
+ VisualSelection,
29
+ Diagnostic,
30
+ DiagnosticSeverity,
31
+ } from './editor';
32
+ export { ConfigLoader, type VemConfig } from './ConfigLoader';
package/src/motions.ts ADDED
@@ -0,0 +1,216 @@
1
+ import type { Position } from './index';
2
+ import { VimBuffer } from './buffer';
3
+
4
+ export function getCharClass(char: string): number {
5
+ if (!char) return 0; // EOF/EOL
6
+ if (/\s/.test(char)) return 1; // Whitespace
7
+ if (/^[a-zA-Z0-9_]$/.test(char)) return 2; // Word char
8
+ return 3; // Punctuation/Special
9
+ }
10
+
11
+ export function nextPosition(buffer: VimBuffer, pos: Position): Position | null {
12
+ const line = buffer.getLine(pos.line);
13
+ if (pos.character < line.length - 1) {
14
+ return { line: pos.line, character: pos.character + 1 };
15
+ }
16
+ if (pos.line < buffer.getLineCount() - 1) {
17
+ return { line: pos.line + 1, character: 0 };
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export function prevPosition(buffer: VimBuffer, pos: Position): Position | null {
23
+ if (pos.character > 0) {
24
+ return { line: pos.line, character: pos.character - 1 };
25
+ }
26
+ if (pos.line > 0) {
27
+ const prevLine = buffer.getLine(pos.line - 1);
28
+ return { line: pos.line - 1, character: Math.max(0, prevLine.length - 1) };
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function getWordForward(buffer: VimBuffer, start: Position): Position {
34
+ let curr: Position | null = start;
35
+ const startChar = buffer.getLine(start.line)[start.character];
36
+ if (!startChar) {
37
+ const next = nextPosition(buffer, start);
38
+ if (!next) return start;
39
+ return next;
40
+ }
41
+
42
+ let startClass = getCharClass(startChar);
43
+
44
+ // 1. Move past characters of the same class (unless starting on whitespace)
45
+ if (startClass !== 1) {
46
+ while (curr) {
47
+ const next = nextPosition(buffer, curr);
48
+ if (!next) return curr;
49
+ const nextChar = buffer.getLine(next.line)[next.character];
50
+ const nextClass = getCharClass(nextChar);
51
+ if (nextClass !== startClass) {
52
+ curr = next;
53
+ break;
54
+ }
55
+ curr = next;
56
+ }
57
+ }
58
+
59
+ // 2. We are now at a different class. If it's whitespace, skip it
60
+ if (curr) {
61
+ let currChar = buffer.getLine(curr.line)[curr.character];
62
+ let currClass = getCharClass(currChar);
63
+ if (currClass === 1) {
64
+ while (curr) {
65
+ const next = nextPosition(buffer, curr);
66
+ if (!next) return curr;
67
+ const nextChar = buffer.getLine(next.line)[next.character];
68
+ const nextClass = getCharClass(nextChar);
69
+ if (nextClass !== 1) {
70
+ curr = next;
71
+ break;
72
+ }
73
+ curr = next;
74
+ }
75
+ }
76
+ }
77
+
78
+ return curr || start;
79
+ }
80
+
81
+ export function getWordBackward(buffer: VimBuffer, start: Position): Position {
82
+ let curr: Position | null = prevPosition(buffer, start);
83
+ if (!curr) return start;
84
+
85
+ let currChar = buffer.getLine(curr.line)[curr.character];
86
+ let currClass = getCharClass(currChar);
87
+
88
+ // If starting on whitespace, skip all preceding whitespace
89
+ if (currClass === 1) {
90
+ while (curr) {
91
+ const prev = prevPosition(buffer, curr);
92
+ if (!prev) return curr;
93
+ const prevChar = buffer.getLine(prev.line)[prev.character];
94
+ const prevClass = getCharClass(prevChar);
95
+ if (prevClass !== 1) {
96
+ currClass = prevClass;
97
+ curr = prev;
98
+ break;
99
+ }
100
+ curr = prev;
101
+ }
102
+ }
103
+
104
+ // Now scan backward through the same class
105
+ while (curr) {
106
+ const prev = prevPosition(buffer, curr);
107
+ if (!prev) return curr;
108
+ const prevChar = buffer.getLine(prev.line)[prev.character];
109
+ const prevClass = getCharClass(prevChar);
110
+ if (prevClass !== currClass) {
111
+ return curr;
112
+ }
113
+ curr = prev;
114
+ }
115
+
116
+ return start;
117
+ }
118
+
119
+ export function getWordEndForward(buffer: VimBuffer, start: Position): Position {
120
+ let curr: Position | null = nextPosition(buffer, start);
121
+ if (!curr) return start;
122
+
123
+ let currChar = buffer.getLine(curr.line)[curr.character];
124
+ let currClass = getCharClass(currChar);
125
+
126
+ // Skip whitespace
127
+ if (currClass === 1) {
128
+ while (curr) {
129
+ const next = nextPosition(buffer, curr);
130
+ if (!next) return curr;
131
+ const nextChar = buffer.getLine(next.line)[next.character];
132
+ const nextClass = getCharClass(nextChar);
133
+ if (nextClass !== 1) {
134
+ currClass = nextClass;
135
+ curr = next;
136
+ break;
137
+ }
138
+ curr = next;
139
+ }
140
+ }
141
+
142
+ // Scan until the character class changes, but stop on the last character of that class
143
+ while (curr) {
144
+ const next = nextPosition(buffer, curr);
145
+ if (!next) return curr;
146
+ const nextChar = buffer.getLine(next.line)[next.character];
147
+ const nextClass = getCharClass(nextChar);
148
+ if (nextClass !== currClass) {
149
+ return curr;
150
+ }
151
+ curr = next;
152
+ }
153
+
154
+ return start;
155
+ }
156
+
157
+ export function getTextObjectRange(
158
+ buffer: VimBuffer,
159
+ pos: Position,
160
+ textObj: string,
161
+ ): { start: Position; end: Position } | null {
162
+ const line = buffer.getLine(pos.line);
163
+ if (line.length === 0) {
164
+ return { start: { ...pos }, end: { ...pos } };
165
+ }
166
+
167
+ const char = line[pos.character] || '';
168
+ const initialClass = getCharClass(char);
169
+
170
+ // Find start of current block
171
+ let startCharIdx = pos.character;
172
+ while (startCharIdx > 0) {
173
+ if (getCharClass(line[startCharIdx - 1]) !== initialClass) {
174
+ break;
175
+ }
176
+ startCharIdx--;
177
+ }
178
+
179
+ // Find end of current block
180
+ let endCharIdx = pos.character;
181
+ while (endCharIdx < line.length - 1) {
182
+ if (getCharClass(line[endCharIdx + 1]) !== initialClass) {
183
+ break;
184
+ }
185
+ endCharIdx++;
186
+ }
187
+
188
+ const start: Position = { line: pos.line, character: startCharIdx };
189
+ const end: Position = { line: pos.line, character: endCharIdx };
190
+
191
+ if (textObj === 'aw') {
192
+ // Extend to include trailing whitespace (on the same line)
193
+ let nextIdx = end.character + 1;
194
+ let extendedTrailing = false;
195
+ while (nextIdx < line.length && /\s/.test(line[nextIdx])) {
196
+ extendedTrailing = true;
197
+ nextIdx++;
198
+ }
199
+ if (extendedTrailing) {
200
+ end.character = nextIdx - 1;
201
+ } else {
202
+ // If no trailing whitespace, extend to include leading whitespace
203
+ let prevIdx = start.character - 1;
204
+ let extendedLeading = false;
205
+ while (prevIdx >= 0 && /\s/.test(line[prevIdx])) {
206
+ extendedLeading = true;
207
+ prevIdx--;
208
+ }
209
+ if (extendedLeading) {
210
+ start.character = prevIdx + 1;
211
+ }
212
+ }
213
+ }
214
+
215
+ return { start, end };
216
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { parseKeys } from './parser';
3
+
4
+ describe('Vim Keybinding Parser', () => {
5
+ it('should parse single motions', () => {
6
+ expect(parseKeys(['w'])).toEqual({
7
+ count: 1,
8
+ motion: 'w',
9
+ isComplete: true,
10
+ isValid: true,
11
+ });
12
+
13
+ expect(parseKeys(['g', 'g'])).toEqual({
14
+ count: 1,
15
+ motion: 'gg',
16
+ isComplete: true,
17
+ isValid: true,
18
+ });
19
+ });
20
+
21
+ it('should handle counts with motions', () => {
22
+ expect(parseKeys(['3', 'w'])).toEqual({
23
+ count: 3,
24
+ motion: 'w',
25
+ isComplete: true,
26
+ isValid: true,
27
+ });
28
+
29
+ expect(parseKeys(['1', '0', 'j'])).toEqual({
30
+ count: 10,
31
+ motion: 'j',
32
+ isComplete: true,
33
+ isValid: true,
34
+ });
35
+ });
36
+
37
+ it('should handle counts with operators and motions', () => {
38
+ expect(parseKeys(['d', '3', 'w'])).toEqual({
39
+ count: 3,
40
+ operator: 'd',
41
+ motion: 'w',
42
+ isComplete: true,
43
+ isValid: true,
44
+ });
45
+
46
+ expect(parseKeys(['2', 'c', '3', 'b'])).toEqual({
47
+ count: 6,
48
+ operator: 'c',
49
+ motion: 'b',
50
+ isComplete: true,
51
+ isValid: true,
52
+ });
53
+ });
54
+
55
+ it('should handle double operators', () => {
56
+ expect(parseKeys(['d', 'd'])).toEqual({
57
+ count: 1,
58
+ operator: 'd',
59
+ command: 'dd',
60
+ isComplete: true,
61
+ isValid: true,
62
+ });
63
+
64
+ expect(parseKeys(['3', 'y', 'y'])).toEqual({
65
+ count: 3,
66
+ operator: 'y',
67
+ command: 'yy',
68
+ isComplete: true,
69
+ isValid: true,
70
+ });
71
+ });
72
+
73
+ it('should handle text objects after operators', () => {
74
+ expect(parseKeys(['d', 'i', 'w'])).toEqual({
75
+ count: 1,
76
+ operator: 'd',
77
+ textObject: 'iw',
78
+ isComplete: true,
79
+ isValid: true,
80
+ });
81
+
82
+ expect(parseKeys(['2', 'c', 'a', 'w'])).toEqual({
83
+ count: 2,
84
+ operator: 'c',
85
+ textObject: 'aw',
86
+ isComplete: true,
87
+ isValid: true,
88
+ });
89
+ });
90
+
91
+ it('should return incomplete states for partial keys', () => {
92
+ expect(parseKeys(['g'])).toEqual({
93
+ count: 1,
94
+ isComplete: false,
95
+ isValid: true,
96
+ });
97
+
98
+ expect(parseKeys(['d', 'i'])).toEqual({
99
+ count: 1,
100
+ operator: 'd',
101
+ isComplete: false,
102
+ isValid: true,
103
+ });
104
+
105
+ expect(parseKeys(['3', 'c'])).toEqual({
106
+ count: 3,
107
+ operator: 'c',
108
+ isComplete: false,
109
+ isValid: true,
110
+ });
111
+ });
112
+
113
+ it('should return invalid for incorrect sequences', () => {
114
+ expect(parseKeys(['d', 'x'])).toEqual({
115
+ count: 1,
116
+ operator: 'd',
117
+ isComplete: true,
118
+ isValid: false,
119
+ });
120
+ });
121
+ });
package/src/parser.ts ADDED
@@ -0,0 +1,219 @@
1
+ import type { EditorMode } from './index';
2
+
3
+ export interface ParsedCommand {
4
+ count: number;
5
+ operator?: 'd' | 'c' | 'y';
6
+ motion?: string;
7
+ textObject?: string;
8
+ command?: string;
9
+ isComplete: boolean;
10
+ isValid: boolean;
11
+ }
12
+
13
+ export function parseKeys(keys: string[], mode: EditorMode = 'NORMAL'): ParsedCommand {
14
+ const result: ParsedCommand = {
15
+ count: 1,
16
+ isComplete: false,
17
+ isValid: true,
18
+ };
19
+
20
+ if (keys.length === 0) {
21
+ return result;
22
+ }
23
+
24
+ let idx = 0;
25
+
26
+ // 1. Parse first count
27
+ let count1Str = '';
28
+ while (idx < keys.length && /^\d$/.test(keys[idx])) {
29
+ if (keys[idx] === '0' && count1Str === '') {
30
+ break;
31
+ }
32
+ count1Str += keys[idx];
33
+ idx++;
34
+ }
35
+ const count1 = count1Str ? parseInt(count1Str, 10) : 1;
36
+
37
+ if (idx >= keys.length) {
38
+ result.count = count1;
39
+ result.isComplete = false;
40
+ result.isValid = true;
41
+ return result;
42
+ }
43
+
44
+ const remaining = keys.slice(idx);
45
+ const remStr = remaining.join('');
46
+
47
+ if (mode === 'VISUAL') {
48
+ // In Visual mode, d, c, y, x are immediate commands
49
+ const visualCommands = ['d', 'c', 'y', 'x', 'Escape', 'v', 'V', '<C-v>'];
50
+ if (visualCommands.includes(remStr)) {
51
+ result.count = count1;
52
+ result.command = remStr;
53
+ result.isComplete = true;
54
+ result.isValid = true;
55
+ return result;
56
+ }
57
+
58
+ // Motions in Visual mode
59
+ if (remStr === 'g') {
60
+ result.isComplete = false;
61
+ result.isValid = true;
62
+ return result;
63
+ }
64
+ if (remStr === 'gg') {
65
+ result.count = count1;
66
+ result.motion = 'gg';
67
+ result.isComplete = true;
68
+ result.isValid = true;
69
+ return result;
70
+ }
71
+
72
+ const singleKeyMotions = ['h', 'j', 'k', 'l', 'w', 'b', 'e', '0', '$', 'G'];
73
+ if (singleKeyMotions.includes(remStr)) {
74
+ result.count = count1;
75
+ result.motion = remStr;
76
+ result.isComplete = true;
77
+ result.isValid = true;
78
+ return result;
79
+ }
80
+
81
+ result.isValid = false;
82
+ result.isComplete = true;
83
+ return result;
84
+ }
85
+
86
+ // NORMAL mode parsing
87
+ // 2. Parse operator (d, c, y)
88
+ let op: 'd' | 'c' | 'y' | undefined;
89
+ const firstNonDigit = keys[idx];
90
+ if (firstNonDigit === 'd' || firstNonDigit === 'c' || firstNonDigit === 'y') {
91
+ op = firstNonDigit;
92
+ idx++;
93
+ }
94
+
95
+ if (op) {
96
+ // We have an operator
97
+ // 3. Parse optional second count
98
+ let count2Str = '';
99
+ while (idx < keys.length && /^\d$/.test(keys[idx])) {
100
+ if (keys[idx] === '0' && count2Str === '') {
101
+ break;
102
+ }
103
+ count2Str += keys[idx];
104
+ idx++;
105
+ }
106
+ const count2 = count2Str ? parseInt(count2Str, 10) : 1;
107
+ result.count = count1 * count2;
108
+ result.operator = op;
109
+
110
+ if (idx >= keys.length) {
111
+ result.isComplete = false;
112
+ result.isValid = true;
113
+ return result;
114
+ }
115
+
116
+ const remainingOp = keys.slice(idx);
117
+ const remOpStr = remainingOp.join('');
118
+
119
+ // Double operator check (dd, cc, yy)
120
+ if (remOpStr === op) {
121
+ result.command = op + op; // e.g. "dd"
122
+ result.isComplete = true;
123
+ result.isValid = true;
124
+ return result;
125
+ }
126
+
127
+ // Text objects: iw, aw
128
+ if (remOpStr === 'i' || remOpStr === 'a') {
129
+ result.isComplete = false;
130
+ result.isValid = true;
131
+ return result;
132
+ }
133
+ if (remOpStr === 'iw' || remOpStr === 'aw') {
134
+ result.textObject = remOpStr;
135
+ result.isComplete = true;
136
+ result.isValid = true;
137
+ return result;
138
+ }
139
+
140
+ // Motions: h, j, k, l, w, b, e, 0, $, G, gg
141
+ if (remOpStr === 'g') {
142
+ result.isComplete = false;
143
+ result.isValid = true;
144
+ return result;
145
+ }
146
+ if (remOpStr === 'gg') {
147
+ result.motion = 'gg';
148
+ result.isComplete = true;
149
+ result.isValid = true;
150
+ return result;
151
+ }
152
+
153
+ const singleKeyMotions = ['h', 'j', 'k', 'l', 'w', 'b', 'e', '0', '$', 'G'];
154
+ if (singleKeyMotions.includes(remOpStr)) {
155
+ result.motion = remOpStr;
156
+ result.isComplete = true;
157
+ result.isValid = true;
158
+ return result;
159
+ }
160
+
161
+ result.isValid = false;
162
+ result.isComplete = true;
163
+ return result;
164
+ } else {
165
+ // No operator
166
+ result.count = count1;
167
+ const remainingNormal = keys.slice(idx);
168
+ const remNormalStr = remainingNormal.join('');
169
+
170
+ if (remNormalStr === 'g') {
171
+ result.isComplete = false;
172
+ result.isValid = true;
173
+ return result;
174
+ }
175
+ if (remNormalStr === 'gg') {
176
+ result.motion = 'gg';
177
+ result.isComplete = true;
178
+ result.isValid = true;
179
+ return result;
180
+ }
181
+
182
+ const singleKeyMotions = ['h', 'j', 'k', 'l', 'w', 'b', 'e', '0', '$', 'G'];
183
+ if (singleKeyMotions.includes(remNormalStr)) {
184
+ result.motion = remNormalStr;
185
+ result.isComplete = true;
186
+ result.isValid = true;
187
+ return result;
188
+ }
189
+
190
+ const commands = [
191
+ 'i',
192
+ 'I',
193
+ 'a',
194
+ 'A',
195
+ 'o',
196
+ 'O',
197
+ 'v',
198
+ 'V',
199
+ '<C-v>',
200
+ 'u',
201
+ '<C-r>',
202
+ 'x',
203
+ 'p',
204
+ 'P',
205
+ ':',
206
+ 'Escape',
207
+ ];
208
+ if (commands.includes(remNormalStr)) {
209
+ result.command = remNormalStr;
210
+ result.isComplete = true;
211
+ result.isValid = true;
212
+ return result;
213
+ }
214
+
215
+ result.isValid = false;
216
+ result.isComplete = true;
217
+ return result;
218
+ }
219
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "sourceMap": true,
8
+ "rootDir": "./src",
9
+ "outDir": "./dist"
10
+ },
11
+ "include": ["src/**/*"],
12
+ "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
13
+ }