feat(coding-agent/vim): added Vim ex and motion features for copy, move, and sort
- Added support for :copy, :move, and :sort ex commands with range selection and destination/flag handling. - Stored last character-find state to enable ; and , repeat motions for f/F/t/T searches. - Implemented Yank, R-mode, and gg/gU/gu command handling in normal-mode execution paths.
This commit is contained in:
@@ -133,5 +133,24 @@ export function parseExCommand(input: string): VimExCommand {
|
||||
};
|
||||
}
|
||||
|
||||
if (rest.startsWith("t ") || /^t\d/.test(rest) || rest.startsWith("copy ") || /^copy\d/.test(rest)) {
|
||||
const destStr = rest.startsWith("copy") ? rest.slice(4).trim() : rest.slice(1).trim();
|
||||
const destination = parseInt(destStr, 10);
|
||||
if (Number.isNaN(destination)) throw new VimInputError("Invalid destination for :copy");
|
||||
return { kind: "copy", range, destination };
|
||||
}
|
||||
|
||||
if (rest.startsWith("m ") || /^m\d/.test(rest) || rest.startsWith("move ") || /^move\d/.test(rest)) {
|
||||
const destStr = rest.startsWith("move") ? rest.slice(4).trim() : rest.slice(1).trim();
|
||||
const destination = parseInt(destStr, 10);
|
||||
if (Number.isNaN(destination)) throw new VimInputError("Invalid destination for :move");
|
||||
return { kind: "move", range, destination };
|
||||
}
|
||||
|
||||
if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) {
|
||||
const flags = rest.slice(4).trim();
|
||||
return { kind: "sort", range: range ?? undefined, flags };
|
||||
}
|
||||
|
||||
throw new VimInputError(`Unsupported ex command: ${input}`);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ export class VimEngine {
|
||||
selectionAnchor: Position | null = null;
|
||||
register: VimRegister = { kind: "char", text: "" };
|
||||
lastSearch: VimSearchState | null = null;
|
||||
lastCharFind: { char: string; mode: "f" | "F" | "t" | "T" } | null = null;
|
||||
lastCommand?: string;
|
||||
statusMessage?: string;
|
||||
diagnostics?: FileDiagnosticsResult;
|
||||
@@ -278,6 +279,7 @@ export class VimEngine {
|
||||
next.selectionAnchor = this.selectionAnchor ? clonePosition(this.selectionAnchor) : null;
|
||||
next.register = { ...this.register };
|
||||
next.lastSearch = this.lastSearch ? { ...this.lastSearch } : null;
|
||||
next.lastCharFind = this.lastCharFind ? { ...this.lastCharFind } : null;
|
||||
next.lastCommand = this.lastCommand;
|
||||
next.statusMessage = this.statusMessage;
|
||||
next.diagnostics = this.diagnostics;
|
||||
@@ -801,7 +803,8 @@ export class VimEngine {
|
||||
case "0":
|
||||
case "$":
|
||||
case "^":
|
||||
case "g":
|
||||
case ";":
|
||||
case ",":
|
||||
case "G":
|
||||
case "f":
|
||||
case "F":
|
||||
@@ -1016,6 +1019,63 @@ export class VimEngine {
|
||||
col: this.buffer.cursor.col,
|
||||
});
|
||||
return nextIndex + 1;
|
||||
case "Esc":
|
||||
return nextIndex + 1;
|
||||
case "Y": {
|
||||
const start = this.buffer.cursor.line;
|
||||
const end = this.buffer.clampLine(start + count - 1);
|
||||
this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") };
|
||||
this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`;
|
||||
return nextIndex + 1;
|
||||
}
|
||||
case "R":
|
||||
await this.#startInsertChange(["R"], undefined, false);
|
||||
return nextIndex + 1;
|
||||
case "g": {
|
||||
const gNext = tokens[nextIndex + 1];
|
||||
if (!gNext) {
|
||||
throw new VimError("g requires a second key", token);
|
||||
}
|
||||
if (gNext.value === "g") {
|
||||
this.buffer.setCursor({ line: hasCount ? Math.max(0, count - 1) : 0, col: 0 });
|
||||
return nextIndex + 2;
|
||||
}
|
||||
if (gNext.value === "U" || gNext.value === "u") {
|
||||
const caseOp = gNext.value;
|
||||
const {
|
||||
count: motionCount,
|
||||
hasCount: hasMotionCount,
|
||||
nextIndex: motionStart,
|
||||
} = this.#readCount(tokens, nextIndex + 2);
|
||||
const motionToken = tokens[motionStart];
|
||||
if (!motionToken) {
|
||||
throw new VimError(`g${caseOp} requires a motion`, gNext);
|
||||
}
|
||||
if ((motionToken.value === "U" && caseOp === "U") || (motionToken.value === "u" && caseOp === "u")) {
|
||||
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
||||
await this.#applyAtomicChange(["g", caseOp, motionToken.value], () => {
|
||||
const start = this.buffer.cursor.line;
|
||||
const end = this.buffer.clampLine(start + effectiveCount - 1);
|
||||
for (let line = start; line <= end; line++) {
|
||||
const content = this.buffer.getLine(line);
|
||||
this.buffer.replaceLine(line, caseOp === "U" ? content.toUpperCase() : content.toLowerCase());
|
||||
}
|
||||
});
|
||||
return motionStart + 1;
|
||||
}
|
||||
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
||||
const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount);
|
||||
const range = this.#resolveMotionRange(motion);
|
||||
await this.#applyAtomicChange(["g", caseOp], () => {
|
||||
const text = this.buffer.getText();
|
||||
const slice = text.slice(range.start, range.end);
|
||||
const transformed = caseOp === "U" ? slice.toUpperCase() : slice.toLowerCase();
|
||||
this.buffer.replaceOffsets(range.start, range.end, transformed, range.start);
|
||||
});
|
||||
return motion.nextIndex;
|
||||
}
|
||||
throw new VimError(`Unsupported g command: g${gNext.display}`, gNext);
|
||||
}
|
||||
default:
|
||||
throw new VimError(`Unsupported command: ${token.display}`, token);
|
||||
}
|
||||
@@ -1335,6 +1395,7 @@ export class VimEngine {
|
||||
if (!searchToken || searchToken.value.length !== 1) {
|
||||
throw new VimError(`${token.value} requires a literal character`, token);
|
||||
}
|
||||
this.lastCharFind = { char: searchToken.value, mode: token.value as "f" | "F" | "t" | "T" };
|
||||
const line = this.buffer.getLine(this.buffer.cursor.line);
|
||||
const cursorCol = this.buffer.cursor.col;
|
||||
let matchIndex = -1;
|
||||
@@ -1404,6 +1465,44 @@ export class VimEngine {
|
||||
target: { line: Math.max(0, this.viewportStart - 1 + 39), col: 0 },
|
||||
linewise: true,
|
||||
};
|
||||
case ";":
|
||||
case ",": {
|
||||
if (!this.lastCharFind) {
|
||||
throw new VimError("No previous character search", token);
|
||||
}
|
||||
let mode = this.lastCharFind.mode;
|
||||
if (token.value === ",") {
|
||||
const reverseMap: Record<string, "f" | "F" | "t" | "T"> = { f: "F", F: "f", t: "T", T: "t" };
|
||||
mode = reverseMap[mode]!;
|
||||
}
|
||||
const line = this.buffer.getLine(this.buffer.cursor.line);
|
||||
const cursorCol = this.buffer.cursor.col;
|
||||
let matchIndex = -1;
|
||||
if (mode === "f" || mode === "t") {
|
||||
let start = cursorCol + 1;
|
||||
for (let step = 0; step < count; step += 1) {
|
||||
matchIndex = line.indexOf(this.lastCharFind.char, start);
|
||||
if (matchIndex === -1) break;
|
||||
start = matchIndex + 1;
|
||||
}
|
||||
if (matchIndex !== -1 && mode === "t") matchIndex -= 1;
|
||||
} else {
|
||||
let start = Math.max(0, cursorCol - 1);
|
||||
for (let step = 0; step < count; step += 1) {
|
||||
matchIndex = line.lastIndexOf(this.lastCharFind.char, start);
|
||||
if (matchIndex === -1) break;
|
||||
start = matchIndex - 1;
|
||||
}
|
||||
if (matchIndex !== -1 && mode === "T") matchIndex += 1;
|
||||
}
|
||||
if (matchIndex === -1) {
|
||||
throw new VimError(`Character not found: ${this.lastCharFind.char}`, token);
|
||||
}
|
||||
return {
|
||||
nextIndex: index + 1,
|
||||
target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) },
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new VimError(`Unsupported motion: ${token.display}`, token);
|
||||
}
|
||||
@@ -1709,6 +1808,59 @@ export class VimEngine {
|
||||
this.statusMessage = `Deleted ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
||||
return;
|
||||
}
|
||||
case "copy": {
|
||||
const totalLines = this.buffer.lineCount();
|
||||
const range =
|
||||
command.range === "all"
|
||||
? { start: 1, end: totalLines }
|
||||
: (command.range ?? { start: this.buffer.cursor.line + 1, end: this.buffer.cursor.line + 1 });
|
||||
const dest = Math.max(0, Math.min(command.destination, totalLines));
|
||||
await this.#applyAtomicChange([":copy"], () => {
|
||||
const lines = this.buffer.lines.slice(range.start - 1, range.end);
|
||||
this.buffer.insertLines(dest, lines);
|
||||
});
|
||||
this.statusMessage = `Copied ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
||||
return;
|
||||
}
|
||||
case "move": {
|
||||
const totalLines = this.buffer.lineCount();
|
||||
const range =
|
||||
command.range === "all"
|
||||
? { start: 1, end: totalLines }
|
||||
: (command.range ?? { start: this.buffer.cursor.line + 1, end: this.buffer.cursor.line + 1 });
|
||||
const dest = Math.max(0, Math.min(command.destination, totalLines));
|
||||
await this.#applyAtomicChange([":move"], () => {
|
||||
const lines = this.buffer.lines.splice(range.start - 1, range.end - range.start + 1);
|
||||
const adjustedDest = dest > range.end - 1 ? dest - lines.length : dest;
|
||||
this.buffer.lines.splice(adjustedDest, 0, ...lines);
|
||||
if (this.buffer.lines.length === 0) this.buffer.lines = [""];
|
||||
this.buffer.setCursor({ line: adjustedDest, col: 0 });
|
||||
});
|
||||
this.statusMessage = `Moved ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
||||
return;
|
||||
}
|
||||
case "sort": {
|
||||
const totalLines = this.buffer.lineCount();
|
||||
const range = command.range === "all" || !command.range ? { start: 1, end: totalLines } : command.range;
|
||||
const startLine = Math.max(1, Math.min(range.start, totalLines));
|
||||
const endLine = Math.max(startLine, Math.min(range.end, totalLines));
|
||||
const reverse = command.flags.includes("!");
|
||||
const ignoreCase = command.flags.includes("i");
|
||||
await this.#applyAtomicChange([":sort"], () => {
|
||||
const slice = this.buffer.lines.slice(startLine - 1, endLine);
|
||||
slice.sort((a, b) => {
|
||||
const left = ignoreCase ? a.toLowerCase() : a;
|
||||
const right = ignoreCase ? b.toLowerCase() : b;
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
});
|
||||
if (reverse) slice.reverse();
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
this.buffer.lines[startLine - 1 + i] = slice[i]!;
|
||||
}
|
||||
});
|
||||
this.statusMessage = `Sorted ${endLine - startLine + 1} line${endLine === startLine ? "" : "s"}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,10 @@ export type VimExCommand =
|
||||
| { kind: "edit"; force: boolean; path?: string }
|
||||
| { kind: "goto-line"; line: number }
|
||||
| { kind: "substitute"; range?: VimLineRange | "all"; pattern: string; replacement: string; flags: string }
|
||||
| { kind: "delete"; range?: VimLineRange };
|
||||
| { kind: "delete"; range?: VimLineRange | "all" }
|
||||
| { kind: "copy"; range?: VimLineRange | "all"; destination: number }
|
||||
| { kind: "move"; range?: VimLineRange | "all"; destination: number }
|
||||
| { kind: "sort"; range?: VimLineRange | "all"; flags: string };
|
||||
|
||||
export class VimInputError extends Error {
|
||||
location?: { sequenceIndex: number; offset: number };
|
||||
|
||||
Reference in New Issue
Block a user