Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/smooth-architecture-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"vimsplain": minor
---

**Internal Architecture Refactor:** The core parser has been completely rewritten from a monolithic loop into a highly performant, isolated Mode-based Handler architecture (Normal, Visual, Insert, Search, Command).
**New Exports:** Added `VimMode` and `ParsingContext` types to the public API for developers who want to inspect or hook into the parser's internal state machine.
**Bulletproof Reliability:** The parser is now backed by extensive property-based fuzzing and headless CodeMirror integration testing to guarantee 100% accurate, regression-free explanations.
40 changes: 36 additions & 4 deletions packages/vimsplain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ pnpm add vimsplain

Parses a Vim keystroke sequence and returns structured explanations for each command.

Handles four parsing modes:
Handles five parsing modes:

- **Normal mode** — motions, operators, text objects
- **Insert mode** — after `i`, `a`, `cw`, etc., accumulates typed text until `[Esc]`
- **Visual mode** — after `v`, `V`, or `[C-v]`, supports visual selection operators
- **Search mode** — after `/` or `?`, accumulates pattern until `[Enter]`
- **Ex mode** — after `:`, accumulates command until `[Enter]`

Expand Down Expand Up @@ -434,21 +435,52 @@ SPECIAL_KEYS.CTRL_I // "[C-i]"

<!-- COMMANDS_TABLE_END -->

### Visual Mode Operators

<!-- VISUAL_COMMANDS_TABLE_START -->

| Keystroke | Description |
|-----------|-------------|
| `d` | delete selection |
| `D` | delete selection |
| `c` | change selection |
| `C` | change selection |
| `y` | yank selection |
| `Y` | yank selection |
| `x` | delete selection |
| `X` | delete selection |
| `s` | change selection |
| `S` | change selection |
| `~` | toggle case of selection |
| `>` | indent selection |
| `<` | dedent selection |
| `=` | auto-indent selection |
| `J` | join selection |
| `p` | paste over selection |
| `P` | paste over selection |
| `gc` | toggle comment selection |
| `gu` | lowercase selection |
| `gU` | uppercase selection |
| `g~` | toggle case of selection |
| `gq` | format selection |

<!-- VISUAL_COMMANDS_TABLE_END -->

## Contributing

Issues and PRs welcome. The command definitions live in `src/vimsplain.ts` as a `NORMAL_COMMANDS` array — adding new commands is a one-liner:
Issues and PRs welcome. The command definitions live in `src/handlers/normal.ts` (as a `NORMAL_COMMANDS` array) and `src/handlers/visual.ts` (as `VISUAL_OPERATORS`). Adding new normal commands is a one-liner:

```ts
{ pattern: /^gf/, description: "go to file under cursor", isMotion: false }
```

After adding, removing, or renaming entries in `NORMAL_COMMANDS`, regenerate the Supported Commands table above:
After adding, removing, or renaming entries, regenerate the Supported Commands tables above:

```bash
pnpm gen:commands
```

This rewrites the table between the `<!-- COMMANDS_TABLE_START -->` / `<!-- COMMANDS_TABLE_END -->` markers. Context-aware behavior added via separate maps (e.g. visual mode operators) is not captured by the script — document those manually.
This rewrites the tables between their respective `<!-- ..._TABLE_START -->` and `<!-- ..._TABLE_END -->` markers.

## Publishing a new version

Expand Down
5 changes: 5 additions & 0 deletions packages/vimsplain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@
"node": ">=18"
},
"devDependencies": {
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.11",
"@replit/codemirror-vim": "^6.3.0",
"@vitest/coverage-v8": "^4.1.0",
"fast-check": "^4.6.0",
"jsdom": "^27.4.0",
"tsdown": "^0.21.2",
"tsx": "^4.19.0",
"typescript": "~5.9.3",
Expand Down
65 changes: 61 additions & 4 deletions packages/vimsplain/scripts/gen-commands-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

// Read the vimsplain source
// Read the normal handler source
const src = readFileSync(
join(import.meta.dirname, "../src/vimsplain.ts"),
join(import.meta.dirname, "../src/handlers/normal.ts"),
"utf8",
);

Expand Down Expand Up @@ -71,16 +71,64 @@ const body = entries

const table = header + body;

// Read the visual handler source
const visualSrc = readFileSync(
join(import.meta.dirname, "../src/handlers/visual.ts"),
"utf8",
);

const visualEntries: Array<{ keystroke: string; description: string }> = [];

// Parse VISUAL_OPERATORS block
const visualOpMatch = visualSrc.match(
/export const VISUAL_OPERATORS: Record<string, string> = \{([^}]+)\};/,
);
if (visualOpMatch) {
const block = visualOpMatch[1];
const entriesRaw = [
...block.matchAll(/(["'])?([^"':\s]+)\1?:\s*(["'])(.+?)\3,/g),
];
for (const match of entriesRaw) {
visualEntries.push({ keystroke: match[2], description: match[4] });
}
}

// Parse VISUAL_G_OPERATORS block
const visualGOpMatch = visualSrc.match(
/export const VISUAL_G_OPERATORS: Record<string, string> = \{([^}]+)\};/,
);
if (visualGOpMatch) {
const block = visualGOpMatch[1];
const entriesRaw = [
...block.matchAll(/(["'])?([^"':\s]+)\1?:\s*(["'])(.+?)\3,/g),
];
for (const match of entriesRaw) {
visualEntries.push({ keystroke: `g${match[2]}`, description: match[4] });
}
}

const visualBody = visualEntries
.map((e) => `| \`${e.keystroke}\` | ${e.description} |`)
.join("\n");

const visualTable = header + visualBody;

// Inject into README between markers
const readmePath = join(import.meta.dirname, "../README.md");
const readme = readFileSync(readmePath, "utf8");
// Use a function replacement to avoid special `$` replacement patterns in the table content
const updated = readme.replace(
let updated = readme.replace(
/<!-- COMMANDS_TABLE_START -->[\s\S]*?<!-- COMMANDS_TABLE_END -->/,
() =>
`<!-- COMMANDS_TABLE_START -->\n\n${table}\n\n<!-- COMMANDS_TABLE_END -->`,
);

updated = updated.replace(
/<!-- VISUAL_COMMANDS_TABLE_START -->[\s\S]*?<!-- VISUAL_COMMANDS_TABLE_END -->/,
() =>
`<!-- VISUAL_COMMANDS_TABLE_START -->\n\n${visualTable}\n\n<!-- VISUAL_COMMANDS_TABLE_END -->`,
);

// Check that markers exist at all
if (!/<!-- COMMANDS_TABLE_START -->/.test(readme)) {
console.error(
Expand All @@ -89,5 +137,14 @@ if (!/<!-- COMMANDS_TABLE_START -->/.test(readme)) {
process.exit(1);
}

if (!/<!-- VISUAL_COMMANDS_TABLE_START -->/.test(readme)) {
console.error(
"Could not find VISUAL_COMMANDS_TABLE markers in README.md. Make sure the markers are present.",
);
process.exit(1);
}

writeFileSync(readmePath, updated);
console.log(`✓ Updated README.md with ${entries.length} commands.`);
console.log(
`✓ Updated README.md with ${entries.length} normal commands and ${visualEntries.length} visual commands.`,
);
48 changes: 48 additions & 0 deletions packages/vimsplain/src/handlers/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ParsingContext } from "../vimsplain.types.js";
import { SPECIAL_KEYS } from "../vimsplain.types.js";

/** Known ex commands and their explanations */
const EX_COMMANDS: Record<string, string> = {
w: "write file",
q: "quit",
wq: "write and quit",
"q!": "force quit (discard changes)",
"wq!": "force write and quit",
x: "write and quit",
e: "edit file",
noh: "clear search highlights",
nohl: "clear search highlights",
"set nu": "show line numbers",
"set nonu": "hide line numbers",
"set rnu": "show relative line numbers",
"set nornu": "hide relative line numbers",
};

export function explainExCommand(cmd: string): string {
const trimmed = cmd.trim();
if (trimmed in EX_COMMANDS) {
return EX_COMMANDS[trimmed] as string;
}
if (/^s\//.test(trimmed)) {
return "substitute";
}
return `run ex command '${trimmed}'`;
}

export function handleCommandMode(context: ParsingContext): void {
// Check for [Enter] to complete ex command
if (context.remaining.startsWith(SPECIAL_KEYS.ENTER)) {
const explanation = explainExCommand(context.exBuffer);
context.commands.push({
matched: `:${context.exBuffer}`,
explanation,
});
context.activeMode = "Normal";
context.remaining = context.remaining.slice(SPECIAL_KEYS.ENTER.length);
return;
}

// In ex mode, accumulate command characters
context.exBuffer += context.remaining[0];
context.remaining = context.remaining.slice(1);
}
101 changes: 101 additions & 0 deletions packages/vimsplain/src/handlers/insert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { ParsingContext } from "../vimsplain.types.js";
import { SPECIAL_KEYS } from "../vimsplain.types.js";

export function handleInsertMode(context: ParsingContext): void {
// Check for [Esc] to exit insert mode
if (context.remaining.startsWith(SPECIAL_KEYS.ESCAPE)) {
if (context.insertBuffer.length > 0) {
context.commands.push({
matched: context.insertBuffer,
explanation: `type "${context.insertBuffer}"`,
});
}
context.commands.push({
matched: SPECIAL_KEYS.ESCAPE,
explanation: "exit insert mode",
});
context.activeMode = "Normal";
context.remaining = context.remaining.slice(SPECIAL_KEYS.ESCAPE.length);
return;
}

// Check for [Backspace] in insert mode (display separately)
if (context.remaining.startsWith(SPECIAL_KEYS.BACKSPACE)) {
if (context.insertBuffer.length > 0) {
context.commands.push({
matched: context.insertBuffer,
explanation: `type "${context.insertBuffer}"`,
});
context.insertBuffer = "";
}
context.commands.push({
matched: SPECIAL_KEYS.BACKSPACE,
explanation: "delete character",
});
context.remaining = context.remaining.slice(SPECIAL_KEYS.BACKSPACE.length);
return;
}

// Check for [Delete] in insert mode
if (context.remaining.startsWith(SPECIAL_KEYS.DELETE)) {
if (context.insertBuffer.length > 0) {
context.commands.push({
matched: context.insertBuffer,
explanation: `type "${context.insertBuffer}"`,
});
context.insertBuffer = "";
}
context.commands.push({
matched: SPECIAL_KEYS.DELETE,
explanation: "delete char under cursor",
});
context.remaining = context.remaining.slice(SPECIAL_KEYS.DELETE.length);
return;
}

// Check for [Enter] in insert mode (display separately)
if (context.remaining.startsWith(SPECIAL_KEYS.ENTER)) {
if (context.insertBuffer.length > 0) {
context.commands.push({
matched: context.insertBuffer,
explanation: `type "${context.insertBuffer}"`,
});
context.insertBuffer = "";
}
context.commands.push({
matched: SPECIAL_KEYS.ENTER,
explanation: "new line",
});
context.remaining = context.remaining.slice(SPECIAL_KEYS.ENTER.length);
return;
}

// Check for arrow keys in insert mode (flush buffer and log motion)
const arrowKey = [
SPECIAL_KEYS.ARROW_UP,
SPECIAL_KEYS.ARROW_DOWN,
SPECIAL_KEYS.ARROW_LEFT,
SPECIAL_KEYS.ARROW_RIGHT,
].find((key) => context.remaining.startsWith(key));

if (arrowKey) {
if (context.insertBuffer.length > 0) {
context.commands.push({
matched: context.insertBuffer,
explanation: `type "${context.insertBuffer}"`,
});
context.insertBuffer = "";
}
const direction = arrowKey.slice(1, -1).toLowerCase();
context.commands.push({
matched: arrowKey,
explanation: `move ${direction}`,
});
context.remaining = context.remaining.slice(arrowKey.length);
return;
}

// In insert mode, accumulate characters
context.insertBuffer += context.remaining[0];
context.remaining = context.remaining.slice(1);
}
Loading