- Implemented parsing of `:j`/`:join` and `:j!`/`:join!` ex commands and mapped them to a new `join` command shape with a whitespace-trim flag. - Added VimEngine handling for `join` to concatenate addressed lines (or the current plus next line by default), optionally normalizing whitespace and reporting line count in the status message. - Updated vim prompt guidance, changelog notes, and tests to document and verify both normal and `:join!` join behavior.
383 lines
10 KiB
TypeScript
383 lines
10 KiB
TypeScript
import type { VimExCommand, VimLineRange } from "./types";
|
|
import { VimInputError } from "./types";
|
|
|
|
export interface VimExParseContext {
|
|
currentLine: number;
|
|
lastLine: number;
|
|
}
|
|
|
|
interface ParsedLineAddress {
|
|
line: number;
|
|
nextIndex: number;
|
|
}
|
|
|
|
function clampLine(line: number, context: VimExParseContext): number {
|
|
return Math.min(Math.max(line, 1), Math.max(1, context.lastLine));
|
|
}
|
|
|
|
function readDigits(raw: string, start: number): { digits: string; nextIndex: number } {
|
|
let index = start;
|
|
let digits = "";
|
|
while (index < raw.length) {
|
|
const char = raw[index] ?? "";
|
|
if (!/^\d$/.test(char)) {
|
|
break;
|
|
}
|
|
digits += char;
|
|
index += 1;
|
|
}
|
|
return { digits, nextIndex: index };
|
|
}
|
|
|
|
function parseLineAddress(
|
|
raw: string,
|
|
start: number,
|
|
context: VimExParseContext,
|
|
relativeBase = context.currentLine,
|
|
): ParsedLineAddress | undefined {
|
|
let index = start;
|
|
let line: number | undefined;
|
|
const first = raw[index] ?? "";
|
|
|
|
if (/^\d$/.test(first)) {
|
|
const { digits, nextIndex } = readDigits(raw, index);
|
|
line = Number.parseInt(digits, 10);
|
|
index = nextIndex;
|
|
} else if (first === ".") {
|
|
line = context.currentLine;
|
|
index += 1;
|
|
} else if (first === "$") {
|
|
line = context.lastLine;
|
|
index += 1;
|
|
} else if (first === "+" || first === "-") {
|
|
line = relativeBase;
|
|
} else {
|
|
return undefined;
|
|
}
|
|
|
|
while (index < raw.length) {
|
|
const sign = raw[index];
|
|
if (sign !== "+" && sign !== "-") {
|
|
break;
|
|
}
|
|
index += 1;
|
|
const { digits, nextIndex } = readDigits(raw, index);
|
|
index = nextIndex;
|
|
const offset = digits.length > 0 ? Number.parseInt(digits, 10) : 1;
|
|
line += sign === "+" ? offset : -offset;
|
|
}
|
|
|
|
return { line: clampLine(line, context), nextIndex: index };
|
|
}
|
|
|
|
function parseLineRange(raw: string, context?: VimExParseContext): { range?: VimLineRange | "all"; rest: string } {
|
|
if (raw.startsWith("%")) {
|
|
return { range: "all", rest: raw.slice(1).trimStart() };
|
|
}
|
|
|
|
if (!context) {
|
|
const match = raw.match(/^(\d+)(?:\s*,\s*(\d+))?/);
|
|
if (!match) {
|
|
return { rest: raw };
|
|
}
|
|
|
|
const start = Number.parseInt(match[1] ?? "", 10);
|
|
const end = Number.parseInt(match[2] ?? match[1] ?? "", 10);
|
|
return {
|
|
range: { start, end },
|
|
rest: raw.slice(match[0].length).trimStart(),
|
|
};
|
|
}
|
|
|
|
const first = parseLineAddress(raw, 0, context);
|
|
if (!first) {
|
|
return { rest: raw };
|
|
}
|
|
|
|
let index = first.nextIndex;
|
|
while (raw[index] === " ") {
|
|
index += 1;
|
|
}
|
|
|
|
const separator = raw[index];
|
|
if (separator !== "," && separator !== ";") {
|
|
return {
|
|
range: { start: first.line, end: first.line },
|
|
rest: raw.slice(index).trimStart(),
|
|
};
|
|
}
|
|
|
|
index += 1;
|
|
while (raw[index] === " ") {
|
|
index += 1;
|
|
}
|
|
|
|
const second = parseLineAddress(raw, index, context, separator === ";" ? first.line : context.currentLine);
|
|
if (!second) {
|
|
throw new VimInputError(`Missing line address after ${separator}`);
|
|
}
|
|
|
|
return {
|
|
range: { start: first.line, end: second.line },
|
|
rest: raw.slice(second.nextIndex).trimStart(),
|
|
};
|
|
}
|
|
|
|
function parseDelimitedSegments(raw: string): { pattern: string; replacement: string; flags: string } {
|
|
if (raw.length === 0) {
|
|
throw new VimInputError("Missing substitute delimiter");
|
|
}
|
|
|
|
const delimiter = raw[0] ?? "/";
|
|
const segments: string[] = [];
|
|
let current = "";
|
|
let escaped = false;
|
|
|
|
for (let index = 1; index < raw.length; index += 1) {
|
|
const char = raw[index] ?? "";
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaped = true;
|
|
current += char;
|
|
continue;
|
|
}
|
|
if (char === delimiter && segments.length < 2) {
|
|
segments.push(current);
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
|
|
if (segments.length !== 2) {
|
|
throw new VimInputError("Substitute command must look like :s/pattern/replacement/flags");
|
|
}
|
|
|
|
return {
|
|
pattern: segments[0] ?? "",
|
|
replacement: segments[1] ?? "",
|
|
flags: current.trim(),
|
|
};
|
|
}
|
|
|
|
function parseDestination(raw: string, context?: VimExParseContext): number {
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length === 0) {
|
|
throw new VimInputError("Missing destination");
|
|
}
|
|
|
|
if (/^\d+$/.test(trimmed)) {
|
|
return Number.parseInt(trimmed, 10);
|
|
}
|
|
|
|
if (context) {
|
|
const address = parseLineAddress(trimmed, 0, context);
|
|
if (address && trimmed.slice(address.nextIndex).trim().length === 0) {
|
|
return address.line;
|
|
}
|
|
}
|
|
|
|
const destination = Number.parseInt(trimmed, 10);
|
|
if (Number.isNaN(destination)) {
|
|
throw new VimInputError("Invalid destination");
|
|
}
|
|
return destination;
|
|
}
|
|
|
|
function matchGlobalCommand(rest: string): { pattern: string; command: string; invert: boolean } | undefined {
|
|
const globalMatch = rest.match(/^(g|v|g!|global|global!|vglobal)\s*([/|#])(.+?)\2(.*)$/);
|
|
if (!globalMatch) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
invert: globalMatch[1] === "v" || globalMatch[1] === "vglobal" || globalMatch[1]?.endsWith("!") === true,
|
|
pattern: globalMatch[3] ?? "",
|
|
command: (globalMatch[4] ?? "d").trim() || "d",
|
|
};
|
|
}
|
|
|
|
function matchDestinationCommand(rest: string, prefixes: readonly string[]): string | undefined {
|
|
for (const prefix of prefixes) {
|
|
if (!rest.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
const suffix = rest.slice(prefix.length);
|
|
if (suffix.length === 0) {
|
|
return "";
|
|
}
|
|
if (/^\s/.test(suffix) || /^[\d.$+-]/.test(suffix)) {
|
|
return suffix.trim();
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function parseExCommand(input: string, context?: VimExParseContext): VimExCommand {
|
|
const trimmed = input.trim();
|
|
const normalized = trimmed.startsWith(":") ? trimmed.slice(1).trimStart() : trimmed;
|
|
if (normalized.length === 0) {
|
|
throw new VimInputError("Empty ex command");
|
|
}
|
|
|
|
if (/^\d+$/.test(normalized)) {
|
|
return {
|
|
kind: "goto-line",
|
|
line: Number.parseInt(normalized, 10),
|
|
};
|
|
}
|
|
|
|
if (normalized === "w" || normalized === "write") {
|
|
return { kind: "write", force: false };
|
|
}
|
|
if (normalized === "w!" || normalized === "write!") {
|
|
return { kind: "write", force: true };
|
|
}
|
|
if (normalized === "update" || normalized === "up") {
|
|
return { kind: "update", force: false };
|
|
}
|
|
if (normalized === "update!" || normalized === "up!") {
|
|
return { kind: "update", force: true };
|
|
}
|
|
if (normalized === "wq" || normalized === "x" || normalized === "xit" || normalized === "exit") {
|
|
return { kind: "write-quit", force: false };
|
|
}
|
|
if (normalized === "wq!" || normalized === "x!" || normalized === "xit!" || normalized === "exit!") {
|
|
return { kind: "write-quit", force: true };
|
|
}
|
|
if (normalized === "q" || normalized === "quit") {
|
|
return { kind: "quit", force: false };
|
|
}
|
|
if (normalized === "q!" || normalized === "quit!") {
|
|
return { kind: "quit", force: true };
|
|
}
|
|
if (normalized === "e" || normalized === "edit") {
|
|
return { kind: "edit", force: false };
|
|
}
|
|
if (normalized === "e!" || normalized === "edit!") {
|
|
return { kind: "edit", force: true };
|
|
}
|
|
if (normalized.startsWith("e ") || normalized.startsWith("edit ")) {
|
|
const path = normalized.startsWith("edit ") ? normalized.slice(5).trim() : normalized.slice(2).trim();
|
|
return { kind: "edit", force: false, path };
|
|
}
|
|
if (normalized.startsWith("e! ") || normalized.startsWith("edit! ")) {
|
|
const path = normalized.startsWith("edit! ") ? normalized.slice(6).trim() : normalized.slice(3).trim();
|
|
return { kind: "edit", force: true, path };
|
|
}
|
|
|
|
const global = matchGlobalCommand(normalized);
|
|
if (global) {
|
|
return { kind: "global", ...global };
|
|
}
|
|
|
|
const { range, rest } = parseLineRange(normalized, context);
|
|
if (range && rest.length === 0) {
|
|
if (range === "all") {
|
|
throw new VimInputError(":% requires a following command");
|
|
}
|
|
return {
|
|
kind: "goto-line",
|
|
line: range.start,
|
|
};
|
|
}
|
|
|
|
const rangedGlobal = matchGlobalCommand(rest);
|
|
if (rangedGlobal) {
|
|
return { kind: "global", range, ...rangedGlobal };
|
|
}
|
|
|
|
if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) {
|
|
const flags = rest.slice(4).trim();
|
|
return { kind: "sort", range: range ?? undefined, flags };
|
|
}
|
|
if (rest === "j" || rest === "join" || rest === "j!" || rest === "join!") {
|
|
return { kind: "join", range: range ?? undefined, trimWhitespace: !rest.endsWith("!") };
|
|
}
|
|
|
|
if (rest.startsWith("substitute")) {
|
|
const segments = parseDelimitedSegments(rest.slice("substitute".length));
|
|
return {
|
|
kind: "substitute",
|
|
range,
|
|
pattern: segments.pattern,
|
|
replacement: segments.replacement,
|
|
flags: segments.flags,
|
|
};
|
|
}
|
|
|
|
if (/^s(?:\W|$)/.test(rest)) {
|
|
const segments = parseDelimitedSegments(rest.slice(1));
|
|
return {
|
|
kind: "substitute",
|
|
range,
|
|
pattern: segments.pattern,
|
|
replacement: segments.replacement,
|
|
flags: segments.flags,
|
|
};
|
|
}
|
|
|
|
if (
|
|
rest === "d" ||
|
|
rest === "del" ||
|
|
rest === "delete" ||
|
|
rest.startsWith("d ") ||
|
|
rest.startsWith("del ") ||
|
|
rest.startsWith("delete ")
|
|
) {
|
|
return {
|
|
kind: "delete",
|
|
range,
|
|
};
|
|
}
|
|
|
|
if (
|
|
rest === "y" ||
|
|
rest === "ya" ||
|
|
rest === "yank" ||
|
|
rest.startsWith("y ") ||
|
|
rest.startsWith("ya ") ||
|
|
rest.startsWith("yank ")
|
|
) {
|
|
return {
|
|
kind: "yank",
|
|
range,
|
|
};
|
|
}
|
|
|
|
if (rest === "pu" || rest === "put" || rest === "pu!" || rest === "put!") {
|
|
return {
|
|
kind: "put",
|
|
range,
|
|
before: rest.endsWith("!"),
|
|
};
|
|
}
|
|
|
|
const copyDestination = matchDestinationCommand(rest, ["copy", "co", "t"]);
|
|
if (copyDestination !== undefined) {
|
|
const destination = parseDestination(copyDestination, context);
|
|
return { kind: "copy", range, destination };
|
|
}
|
|
|
|
const moveDestination = matchDestinationCommand(rest, ["move", "mo", "m"]);
|
|
if (moveDestination !== undefined) {
|
|
const destination = parseDestination(moveDestination, context);
|
|
return { kind: "move", range, destination };
|
|
}
|
|
|
|
if (rest === "a" || rest === "append" || rest.startsWith("a ") || rest.startsWith("append ")) {
|
|
const text = rest.startsWith("append") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
|
|
return { kind: "append", range: range === "all" ? undefined : range, text };
|
|
}
|
|
|
|
if (rest === "i" || rest === "insert" || rest.startsWith("i ") || rest.startsWith("insert ")) {
|
|
const text = rest.startsWith("insert") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
|
|
return { kind: "insert-before", range: range === "all" ? undefined : range, text };
|
|
}
|
|
|
|
throw new VimInputError(`Unsupported ex command: ${input}.`);
|
|
}
|