vim-sim 1.0.4 → 1.0.6

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/dist/index.d.ts CHANGED
@@ -811,6 +811,13 @@ interface VimConfig {
811
811
  undolevels: number;
812
812
  clipboard: 'unnamed' | 'unnamedplus' | '';
813
813
  }
814
+ /**
815
+ * Shell filter hook — set this on the ConfigManager instance directly.
816
+ * Called for :%!cmd and !! operators.
817
+ * Receives the shell command string and the input text; returns the filtered output.
818
+ * If not set, shell filter commands are no-ops.
819
+ */
820
+ type FilterCommandFn = (cmd: string, input: string) => string;
814
821
  type VimConfigKey = keyof VimConfig;
815
822
  /**
816
823
  * Metadata about a config option
@@ -830,6 +837,11 @@ interface VimConfigMetadata {
830
837
  declare class ConfigManager {
831
838
  private config;
832
839
  private listeners;
840
+ /**
841
+ * Shell filter callback for :%!cmd and !! operators.
842
+ * Set this directly: configManager.filterCommand = (cmd, input) => ...
843
+ */
844
+ filterCommand: FilterCommandFn | undefined;
833
845
  constructor(initialConfig?: Partial<VimConfig>);
834
846
  /**
835
847
  * Get a config value
@@ -906,6 +918,25 @@ interface LastCommand {
906
918
  count?: number;
907
919
  register?: string;
908
920
  }
921
+ interface LastSubstitute {
922
+ pattern: string;
923
+ replacement: string;
924
+ global: boolean;
925
+ ignoreCase: boolean;
926
+ }
927
+ interface ConfirmMatch {
928
+ line: number;
929
+ startCol: number;
930
+ endCol: number;
931
+ matchText: string;
932
+ replacementText: string;
933
+ }
934
+ interface PendingConfirmSubstitute {
935
+ matches: ConfirmMatch[];
936
+ currentIndex: number;
937
+ appliedIndices: number[];
938
+ originalContent: string;
939
+ }
909
940
  declare class State {
910
941
  buffer: Buffer;
911
942
  cursor: Cursor;
@@ -929,7 +960,9 @@ declare class State {
929
960
  undoTree: UndoTree | null;
930
961
  lastCommand: LastCommand | null;
931
962
  configManager: ConfigManager | null;
932
- constructor(buffer: Buffer, cursor: Cursor, selection: Range | null, mode: Mode, commandLine?: string, desiredColumn?: number | null, viewport?: VimViewport, visualAnchor?: Cursor | null, lastVisualSelection?: LastVisualSelection | null, recordingRegister?: string | null, lastMacroRegister?: string | null, foldManager?: FoldManager, markManager?: MarkManager, jumpListManager?: JumpListManager, fileSystem?: FileSystemManager, windowManager?: WindowManager, lastSearch?: SearchState | null, spellChecker?: SpellChecker, completionManager?: CompletionManager, undoTree?: UndoTree | null, lastCommand?: LastCommand | null, configManager?: ConfigManager | null);
963
+ lastSubstitute: LastSubstitute | null;
964
+ pendingConfirm: PendingConfirmSubstitute | null;
965
+ constructor(buffer: Buffer, cursor: Cursor, selection: Range | null, mode: Mode, commandLine?: string, desiredColumn?: number | null, viewport?: VimViewport, visualAnchor?: Cursor | null, lastVisualSelection?: LastVisualSelection | null, recordingRegister?: string | null, lastMacroRegister?: string | null, foldManager?: FoldManager, markManager?: MarkManager, jumpListManager?: JumpListManager, fileSystem?: FileSystemManager, windowManager?: WindowManager, lastSearch?: SearchState | null, spellChecker?: SpellChecker, completionManager?: CompletionManager, undoTree?: UndoTree | null, lastCommand?: LastCommand | null, configManager?: ConfigManager | null, lastSubstitute?: LastSubstitute | null, pendingConfirm?: PendingConfirmSubstitute | null);
933
966
  }
934
967
 
935
968
  declare abstract class TextObject {
@@ -1026,6 +1059,7 @@ interface CommandContext {
1026
1059
  indentationManager?: IndentationManager;
1027
1060
  args?: string;
1028
1061
  range?: Range;
1062
+ lastSubstitute?: LastSubstitute | null;
1029
1063
  }
1030
1064
  declare abstract class Command {
1031
1065
  abstract key: string;
@@ -1139,6 +1173,28 @@ declare class Session {
1139
1173
  handleKey(key: string): void;
1140
1174
  private handleInsertMode;
1141
1175
  private handleCommandMode;
1176
+ private resolveAddress;
1177
+ /**
1178
+ * Resolve a vim range string to [startLine, endLine] (0-indexed, inclusive).
1179
+ * Returns null when the range string contains no range (use defaults for the command).
1180
+ */
1181
+ private resolveRange;
1182
+ /**
1183
+ * Split a raw ex command string into [rangeStr, commandBody].
1184
+ * Handles: %, ., $, N, N,M, .,$ etc.
1185
+ */
1186
+ private splitRangeAndCommand;
1187
+ private executeMoveLines;
1188
+ private executeSubstitute;
1189
+ private executeGlobal;
1190
+ /** Execute a single ex sub-command (used by :g/:v) with cursor on `currentLine`. */
1191
+ private executeSubCommand;
1192
+ private executeShellFilter;
1193
+ /**
1194
+ * Handle key input while a confirm-substitution is pending.
1195
+ * Keys: y (apply), n (skip), a (apply all), q (quit/cancel remaining), l (apply this + quit)
1196
+ */
1197
+ private handleConfirmMode;
1142
1198
  private executeCommand;
1143
1199
  private match;
1144
1200
  private tryMatch;
package/dist/index.js CHANGED
@@ -1525,6 +1525,11 @@ var CompletionManager = class _CompletionManager {
1525
1525
  var ConfigManager = class _ConfigManager {
1526
1526
  config;
1527
1527
  listeners = /* @__PURE__ */ new Map();
1528
+ /**
1529
+ * Shell filter callback for :%!cmd and !! operators.
1530
+ * Set this directly: configManager.filterCommand = (cmd, input) => ...
1531
+ */
1532
+ filterCommand = void 0;
1528
1533
  constructor(initialConfig) {
1529
1534
  this.config = {
1530
1535
  // Display
@@ -1856,7 +1861,7 @@ var CONFIG_METADATA = {
1856
1861
 
1857
1862
  // src/core/state.ts
1858
1863
  var State = class {
1859
- constructor(buffer, cursor, selection, mode, commandLine = "", desiredColumn = null, viewport = new VimViewport(), visualAnchor = null, lastVisualSelection = null, recordingRegister = null, lastMacroRegister = null, foldManager = new FoldManager(), markManager = new MarkManager(), jumpListManager = new JumpListManager(), fileSystem = new FileSystemManager(), windowManager = new WindowManager(), lastSearch = null, spellChecker = new SpellChecker(), completionManager = new CompletionManager(), undoTree = null, lastCommand = null, configManager = null) {
1864
+ constructor(buffer, cursor, selection, mode, commandLine = "", desiredColumn = null, viewport = new VimViewport(), visualAnchor = null, lastVisualSelection = null, recordingRegister = null, lastMacroRegister = null, foldManager = new FoldManager(), markManager = new MarkManager(), jumpListManager = new JumpListManager(), fileSystem = new FileSystemManager(), windowManager = new WindowManager(), lastSearch = null, spellChecker = new SpellChecker(), completionManager = new CompletionManager(), undoTree = null, lastCommand = null, configManager = null, lastSubstitute = null, pendingConfirm = null) {
1860
1865
  this.buffer = buffer;
1861
1866
  this.cursor = cursor;
1862
1867
  this.selection = selection;
@@ -1879,6 +1884,8 @@ var State = class {
1879
1884
  this.undoTree = undoTree;
1880
1885
  this.lastCommand = lastCommand;
1881
1886
  this.configManager = configManager;
1887
+ this.lastSubstitute = lastSubstitute;
1888
+ this.pendingConfirm = pendingConfirm;
1882
1889
  }
1883
1890
  };
1884
1891
  function createEmptyState() {
@@ -3929,24 +3936,38 @@ function pasteCharacterwise(state, content, position) {
3929
3936
  const lines = state.buffer.content.split("\n");
3930
3937
  const currentLineIndex = state.cursor.line;
3931
3938
  const currentLine2 = lines[currentLineIndex] ?? "";
3932
- let newLine;
3933
- let newColumn;
3934
- if (position === "after") {
3935
- const beforeCursor = currentLine2.substring(0, state.cursor.column + 1);
3936
- const afterCursor = currentLine2.substring(state.cursor.column + 1);
3937
- newLine = beforeCursor + content + afterCursor;
3938
- newColumn = state.cursor.column + content.length;
3939
- } else {
3940
- const beforeCursor = currentLine2.substring(0, state.cursor.column);
3941
- const afterCursor = currentLine2.substring(state.cursor.column);
3942
- newLine = beforeCursor + content + afterCursor;
3943
- newColumn = state.cursor.column + content.length - 1;
3944
- }
3945
- lines[currentLineIndex] = newLine;
3939
+ const contentLines = content.split("\n");
3940
+ const splitPoint = position === "after" ? state.cursor.column + 1 : state.cursor.column;
3941
+ const beforeCursor = currentLine2.substring(0, splitPoint);
3942
+ const afterCursor = currentLine2.substring(splitPoint);
3943
+ if (contentLines.length === 1) {
3944
+ const newLine = beforeCursor + content + afterCursor;
3945
+ const newColumn = position === "after" ? state.cursor.column + content.length : state.cursor.column + content.length - 1;
3946
+ lines[currentLineIndex] = newLine;
3947
+ return {
3948
+ ...state,
3949
+ buffer: new Buffer(lines.join("\n")),
3950
+ cursor: new Cursor(currentLineIndex, Math.max(0, newColumn))
3951
+ };
3952
+ }
3953
+ const firstContentLine = contentLines[0];
3954
+ const lastContentLine = contentLines[contentLines.length - 1];
3955
+ const middleContentLines = contentLines.slice(1, -1);
3956
+ const newFirstLine = beforeCursor + firstContentLine;
3957
+ const newLastLine = lastContentLine + afterCursor;
3958
+ const newLines = [
3959
+ ...lines.slice(0, currentLineIndex),
3960
+ newFirstLine,
3961
+ ...middleContentLines,
3962
+ newLastLine,
3963
+ ...lines.slice(currentLineIndex + 1)
3964
+ ];
3965
+ const newCursorLine = currentLineIndex + contentLines.length - 1;
3966
+ const newCursorColumn = Math.max(0, lastContentLine.length - 1);
3946
3967
  return {
3947
3968
  ...state,
3948
- buffer: new Buffer(lines.join("\n")),
3949
- cursor: new Cursor(currentLineIndex, newColumn)
3969
+ buffer: new Buffer(newLines.join("\n")),
3970
+ cursor: new Cursor(newCursorLine, newCursorColumn)
3950
3971
  };
3951
3972
  }
3952
3973
  var p = class extends Command {
@@ -5511,6 +5532,152 @@ var ParagraphTextObject = class extends TextObject {
5511
5532
  }
5512
5533
  };
5513
5534
 
5535
+ // src/utils/substitute.ts
5536
+ function substitute(content, startLine, endLine, options) {
5537
+ const lines = content.split("\n");
5538
+ const modifiedLines = [];
5539
+ let totalReplacements = 0;
5540
+ const flags = (options.global ? "g" : "") + (options.ignoreCase ? "i" : "");
5541
+ const jsPattern = vimPatternToRegex(options.pattern);
5542
+ const regex = new RegExp(jsPattern, flags);
5543
+ for (let i = startLine; i <= endLine && i < lines.length; i++) {
5544
+ const line = lines[i];
5545
+ if (line === void 0) continue;
5546
+ const newLine = line.replace(regex, (match, ...args) => {
5547
+ totalReplacements++;
5548
+ return expandReplacement(options.replacement, match, args);
5549
+ });
5550
+ if (newLine !== line) {
5551
+ lines[i] = newLine;
5552
+ modifiedLines.push(i);
5553
+ }
5554
+ }
5555
+ return {
5556
+ newContent: lines.join("\n"),
5557
+ replacements: totalReplacements,
5558
+ lines: modifiedLines
5559
+ };
5560
+ }
5561
+ function findSubstituteMatches(content, startLine, endLine, options) {
5562
+ const lines = content.split("\n");
5563
+ const matches = [];
5564
+ const flags = "g" + (options.ignoreCase ? "i" : "");
5565
+ const jsPattern = vimPatternToRegex(options.pattern);
5566
+ const regex = new RegExp(jsPattern, flags);
5567
+ for (let i = startLine; i <= endLine && i < lines.length; i++) {
5568
+ const line = lines[i];
5569
+ if (line === void 0) continue;
5570
+ regex.lastIndex = 0;
5571
+ let match;
5572
+ let foundOnLine = false;
5573
+ while ((match = regex.exec(line)) !== null) {
5574
+ if (foundOnLine && !options.global) break;
5575
+ const groups = match.slice(1);
5576
+ matches.push({
5577
+ line: i,
5578
+ startCol: match.index,
5579
+ endCol: match.index + match[0].length,
5580
+ matchText: match[0],
5581
+ replacementText: expandReplacement(options.replacement, match[0], groups)
5582
+ });
5583
+ foundOnLine = true;
5584
+ if (match[0].length === 0) regex.lastIndex++;
5585
+ }
5586
+ }
5587
+ return matches;
5588
+ }
5589
+ function applySubstituteMatches(content, matches, appliedIndices) {
5590
+ const lines = content.split("\n");
5591
+ const sorted = Array.from(appliedIndices).sort((a, b2) => {
5592
+ const ma = matches[a];
5593
+ const mb = matches[b2];
5594
+ if (ma.line !== mb.line) return mb.line - ma.line;
5595
+ return mb.startCol - ma.startCol;
5596
+ });
5597
+ for (const idx of sorted) {
5598
+ const m = matches[idx];
5599
+ const line = lines[m.line] ?? "";
5600
+ lines[m.line] = line.slice(0, m.startCol) + m.replacementText + line.slice(m.endCol);
5601
+ }
5602
+ return lines.join("\n");
5603
+ }
5604
+ function evaluateVimExpr(expr, match, groups) {
5605
+ expr = expr.trim();
5606
+ const submatchRe = /^submatch\((\d+)\)$/;
5607
+ const submatchM = submatchRe.exec(expr);
5608
+ if (submatchM) {
5609
+ const n2 = parseInt(submatchM[1], 10);
5610
+ if (n2 === 0) return match;
5611
+ const g = groups[n2 - 1];
5612
+ return typeof g === "string" ? g : "";
5613
+ }
5614
+ const toupperM = /^toupper\((.+)\)$/.exec(expr);
5615
+ if (toupperM) return evaluateVimExpr(toupperM[1], match, groups).toUpperCase();
5616
+ const tolowerM = /^tolower\((.+)\)$/.exec(expr);
5617
+ if (tolowerM) return evaluateVimExpr(tolowerM[1], match, groups).toLowerCase();
5618
+ const strM = /^['"](.*)['"]$/.exec(expr);
5619
+ if (strM) return strM[1];
5620
+ return "";
5621
+ }
5622
+ function expandReplacement(replacement, match, groups) {
5623
+ let result = replacement;
5624
+ result = result.replace(/\\=([^\\]+)/g, (_, expr) => evaluateVimExpr(expr, match, groups));
5625
+ result = result.replace(/\\0/g, match);
5626
+ for (let i = 1; i <= 9; i++) {
5627
+ const group = groups[i - 1];
5628
+ if (group !== void 0) {
5629
+ result = result.replace(new RegExp(`\\\\${i}`, "g"), String(group));
5630
+ }
5631
+ }
5632
+ result = result.replace(/\\u(.)/g, (_, char) => char.toUpperCase());
5633
+ result = result.replace(/\\U(.*?)(?:\\E|$)/g, (_, text) => text.toUpperCase());
5634
+ result = result.replace(/\\l(.)/g, (_, char) => char.toLowerCase());
5635
+ result = result.replace(/\\L(.*?)(?:\\E|$)/g, (_, text) => text.toLowerCase());
5636
+ result = result.replace(/\\n/g, "\n");
5637
+ result = result.replace(/\\t/g, " ");
5638
+ result = result.replace(/\\r/g, "\r");
5639
+ result = result.replace(/\\\\/g, "\\");
5640
+ return result;
5641
+ }
5642
+ function parseSubstituteCommand(command) {
5643
+ const match = command.match(/^s(.)(.*?)\1(.*?)\1?([gicGIC]*)$/);
5644
+ if (!match) {
5645
+ return null;
5646
+ }
5647
+ const [, , pattern, replacement, flags] = match;
5648
+ return {
5649
+ pattern: pattern ?? "",
5650
+ replacement: replacement ?? "",
5651
+ global: (flags ?? "").toLowerCase().includes("g"),
5652
+ ignoreCase: (flags ?? "").toLowerCase().includes("i"),
5653
+ confirmEach: (flags ?? "").toLowerCase().includes("c")
5654
+ };
5655
+ }
5656
+ function vimPatternToRegex(vimPattern) {
5657
+ let p2 = vimPattern;
5658
+ p2 = p2.replace(/\\\(/g, "(");
5659
+ p2 = p2.replace(/\\\)/g, ")");
5660
+ p2 = p2.replace(/\\\|/g, "|");
5661
+ p2 = p2.replace(/\\=/g, "?");
5662
+ p2 = p2.replace(/\\\+/g, "+");
5663
+ p2 = p2.replace(/\\\{/g, "{");
5664
+ p2 = p2.replace(/\\\}/g, "}");
5665
+ p2 = p2.replace(/\\</g, "\\b");
5666
+ p2 = p2.replace(/\\>/g, "\\b");
5667
+ p2 = p2.replace(/\\s/g, "\\s");
5668
+ p2 = p2.replace(/\\S/g, "\\S");
5669
+ p2 = p2.replace(/\\d/g, "\\d");
5670
+ p2 = p2.replace(/\\D/g, "\\D");
5671
+ p2 = p2.replace(/\\w/g, "\\w");
5672
+ p2 = p2.replace(/\\W/g, "\\W");
5673
+ p2 = p2.replace(/\\a/g, "[a-zA-Z]");
5674
+ p2 = p2.replace(/\\A/g, "[^a-zA-Z]");
5675
+ p2 = p2.replace(/\\l/g, "[a-z]");
5676
+ p2 = p2.replace(/\\u/g, "[A-Z]");
5677
+ p2 = p2.replace(/\\n/g, "\\n");
5678
+ return p2;
5679
+ }
5680
+
5514
5681
  // src/commands/misc.ts
5515
5682
  var JumpForward = class extends Command {
5516
5683
  key = "<C-i>";
@@ -6520,6 +6687,10 @@ var Session = class _Session {
6520
6687
  }
6521
6688
  handleKey(key) {
6522
6689
  key = _Session.normalizeKey(key);
6690
+ if (this.state.pendingConfirm !== null) {
6691
+ this.handleConfirmMode(key);
6692
+ return;
6693
+ }
6523
6694
  if (this.state.mode === 1 /* INSERT */) {
6524
6695
  this.handleInsertMode(key);
6525
6696
  return;
@@ -6551,6 +6722,7 @@ var Session = class _Session {
6551
6722
  const stateBefore = this.state;
6552
6723
  result.context.registerManager = this.registerManager;
6553
6724
  result.context.indentationManager = this.indentationManager;
6725
+ result.context.lastSubstitute = this.state.lastSubstitute;
6554
6726
  this.state = result.command.execute(this.state, result.context);
6555
6727
  if ((stateBefore.mode === 2 /* VISUAL */ || stateBefore.mode === 3 /* VISUAL_LINE */ || stateBefore.mode === 4 /* VISUAL_BLOCK */) && this.state.mode === stateBefore.mode && // Still in same visual mode
6556
6728
  stateBefore.visualAnchor && (this.state.cursor.line !== stateBefore.cursor.line || this.state.cursor.column !== stateBefore.cursor.column)) {
@@ -6700,24 +6872,331 @@ var Session = class _Session {
6700
6872
  };
6701
6873
  }
6702
6874
  }
6875
+ // ── Ex-command range helpers ──────────────────────────────────────────────
6876
+ resolveAddress(addr, lines, currentLine2) {
6877
+ addr = addr.trim();
6878
+ if (addr === "" || addr === ".") return currentLine2;
6879
+ if (addr === "$") return lines.length - 1;
6880
+ const n2 = parseInt(addr, 10);
6881
+ if (!isNaN(n2)) return Math.max(0, Math.min(n2 - 1, lines.length - 1));
6882
+ return currentLine2;
6883
+ }
6884
+ /**
6885
+ * Resolve a vim range string to [startLine, endLine] (0-indexed, inclusive).
6886
+ * Returns null when the range string contains no range (use defaults for the command).
6887
+ */
6888
+ resolveRange(rangeStr, lines, currentLine2) {
6889
+ if (rangeStr === "") return null;
6890
+ if (rangeStr === "%") return [0, lines.length - 1];
6891
+ if (rangeStr === ".") return [currentLine2, currentLine2];
6892
+ if (rangeStr === "$") return [lines.length - 1, lines.length - 1];
6893
+ const commaIdx = rangeStr.indexOf(",");
6894
+ if (commaIdx !== -1) {
6895
+ const start = this.resolveAddress(rangeStr.slice(0, commaIdx), lines, currentLine2);
6896
+ const end = this.resolveAddress(rangeStr.slice(commaIdx + 1), lines, currentLine2);
6897
+ return [Math.min(start, end), Math.max(start, end)];
6898
+ }
6899
+ const single = this.resolveAddress(rangeStr, lines, currentLine2);
6900
+ return [single, single];
6901
+ }
6902
+ /**
6903
+ * Split a raw ex command string into [rangeStr, commandBody].
6904
+ * Handles: %, ., $, N, N,M, .,$ etc.
6905
+ */
6906
+ splitRangeAndCommand(cmd) {
6907
+ const rangeRe = /^(%|\.|(?:\d+|\.|\.?\$?)(?:,(?:\d+|\.|\.?\$?))?)/;
6908
+ const m = rangeRe.exec(cmd);
6909
+ if (m) {
6910
+ const range = m[0];
6911
+ const rest = cmd.slice(range.length).trimStart();
6912
+ return [range, rest];
6913
+ }
6914
+ return ["", cmd];
6915
+ }
6916
+ // ── :m (move) ──────────────────────────────────────────────────────────────
6917
+ executeMoveLines(startLine, endLine, dest) {
6918
+ const lines = this.state.buffer.content.split("\n");
6919
+ const count = endLine - startLine + 1;
6920
+ const toMove = lines.splice(startLine, count);
6921
+ let adjustedDest = dest;
6922
+ if (dest >= startLine) adjustedDest -= count;
6923
+ adjustedDest = Math.max(-1, Math.min(adjustedDest, lines.length - 1));
6924
+ lines.splice(adjustedDest + 1, 0, ...toMove);
6925
+ const newCursorLine = Math.min(adjustedDest + count, lines.length - 1);
6926
+ this.state = {
6927
+ ...this.state,
6928
+ buffer: new Buffer(lines.join("\n")),
6929
+ cursor: new Cursor(Math.max(0, newCursorLine), 0),
6930
+ desiredColumn: null
6931
+ };
6932
+ }
6933
+ // ── :s (substitute) ────────────────────────────────────────────────────────
6934
+ executeSubstitute(rangeStr, subcmd) {
6935
+ const parsed = parseSubstituteCommand(subcmd);
6936
+ if (!parsed) return;
6937
+ const lines = this.state.buffer.content.split("\n");
6938
+ const currentLine2 = this.state.cursor.line;
6939
+ let startLine = currentLine2;
6940
+ let endLine = currentLine2;
6941
+ if (rangeStr !== "") {
6942
+ const resolved = this.resolveRange(rangeStr, lines, currentLine2);
6943
+ if (resolved) {
6944
+ [startLine, endLine] = resolved;
6945
+ }
6946
+ }
6947
+ const lastSub = {
6948
+ pattern: parsed.pattern,
6949
+ replacement: parsed.replacement,
6950
+ global: parsed.global,
6951
+ ignoreCase: parsed.ignoreCase
6952
+ };
6953
+ if (parsed.confirmEach) {
6954
+ const matches = findSubstituteMatches(
6955
+ this.state.buffer.content,
6956
+ startLine,
6957
+ endLine,
6958
+ { pattern: parsed.pattern, replacement: parsed.replacement, global: parsed.global, ignoreCase: parsed.ignoreCase }
6959
+ );
6960
+ if (matches.length === 0) return;
6961
+ const pendingConfirm = {
6962
+ matches,
6963
+ currentIndex: 0,
6964
+ appliedIndices: [],
6965
+ originalContent: this.state.buffer.content
6966
+ };
6967
+ this.state = { ...this.state, lastSubstitute: lastSub, pendingConfirm };
6968
+ return;
6969
+ }
6970
+ const result = substitute(
6971
+ this.state.buffer.content,
6972
+ startLine,
6973
+ endLine,
6974
+ { pattern: parsed.pattern, replacement: parsed.replacement, global: parsed.global, ignoreCase: parsed.ignoreCase }
6975
+ );
6976
+ if (result.replacements === 0) {
6977
+ this.state = { ...this.state, lastSubstitute: lastSub };
6978
+ return;
6979
+ }
6980
+ const lastModifiedLine = result.lines[result.lines.length - 1] ?? currentLine2;
6981
+ this.state = {
6982
+ ...this.state,
6983
+ buffer: new Buffer(result.newContent),
6984
+ cursor: new Cursor(lastModifiedLine, 0),
6985
+ desiredColumn: null,
6986
+ lastSubstitute: lastSub
6987
+ };
6988
+ }
6989
+ // ── :g / :v (global) ───────────────────────────────────────────────────────
6990
+ executeGlobal(rangeStr, subcmd, invert) {
6991
+ const firstChar = subcmd[0];
6992
+ if (!firstChar) return;
6993
+ const parts = subcmd.slice(1).split(firstChar);
6994
+ if (parts.length < 2) return;
6995
+ const [pattern, ...cmdParts] = parts;
6996
+ const cmd = cmdParts.join(firstChar);
6997
+ const lines = this.state.buffer.content.split("\n");
6998
+ const currentLine2 = this.state.cursor.line;
6999
+ const resolved = this.resolveRange(rangeStr, lines, currentLine2) ?? [0, lines.length - 1];
7000
+ const [startLine, endLine] = resolved;
7001
+ let regex;
7002
+ try {
7003
+ regex = new RegExp(pattern ?? "", "g");
7004
+ } catch {
7005
+ return;
7006
+ }
7007
+ const matchingLines = [];
7008
+ for (let i = startLine; i <= endLine; i++) {
7009
+ const line = lines[i] ?? "";
7010
+ regex.lastIndex = 0;
7011
+ const matches = regex.test(line);
7012
+ if (matches !== invert) {
7013
+ matchingLines.push(i);
7014
+ }
7015
+ }
7016
+ let lineOffset = 0;
7017
+ for (const origLine of matchingLines) {
7018
+ const adjustedLine = origLine + lineOffset;
7019
+ const prevLineCount = this.state.buffer.content.split("\n").length;
7020
+ const currentLines = this.state.buffer.content.split("\n");
7021
+ const safeAdjusted = Math.max(0, Math.min(adjustedLine, currentLines.length - 1));
7022
+ this.state = {
7023
+ ...this.state,
7024
+ cursor: new Cursor(safeAdjusted, 0)
7025
+ };
7026
+ this.executeSubCommand(cmd, safeAdjusted);
7027
+ const newLineCount = this.state.buffer.content.split("\n").length;
7028
+ lineOffset += newLineCount - prevLineCount;
7029
+ }
7030
+ }
7031
+ /** Execute a single ex sub-command (used by :g/:v) with cursor on `currentLine`. */
7032
+ executeSubCommand(subcmd, currentLine2) {
7033
+ const trimmed = subcmd.trim();
7034
+ if (trimmed === "") return;
7035
+ if (trimmed === "d" || trimmed === "delete") {
7036
+ const lines = this.state.buffer.content.split("\n");
7037
+ if (currentLine2 >= lines.length) return;
7038
+ lines.splice(currentLine2, 1);
7039
+ const newLine = Math.min(currentLine2, lines.length - 1);
7040
+ this.state = {
7041
+ ...this.state,
7042
+ buffer: new Buffer(lines.join("\n")),
7043
+ cursor: new Cursor(Math.max(0, newLine), 0),
7044
+ desiredColumn: null
7045
+ };
7046
+ return;
7047
+ }
7048
+ const moveM = /^m(?:ove)?\s*(.*)$/.exec(trimmed);
7049
+ if (moveM) {
7050
+ const addrStr = (moveM[1] ?? "").trim();
7051
+ const lines = this.state.buffer.content.split("\n");
7052
+ const dest = addrStr === "0" ? -1 : this.resolveAddress(addrStr, lines, currentLine2);
7053
+ this.executeMoveLines(currentLine2, currentLine2, dest === -1 ? -1 : dest);
7054
+ return;
7055
+ }
7056
+ const subsM = /^s(.)/.exec(trimmed);
7057
+ if (subsM) {
7058
+ this.executeSubstitute(".", trimmed);
7059
+ return;
7060
+ }
7061
+ if (trimmed === "p" || trimmed === "print") return;
7062
+ }
7063
+ // ── Shell filter (%!cmd) ───────────────────────────────────────────────────
7064
+ executeShellFilter(rangeStr, shellCmd) {
7065
+ const filterFn = this.configManager.filterCommand;
7066
+ if (!filterFn) return;
7067
+ const lines = this.state.buffer.content.split("\n");
7068
+ const currentLine2 = this.state.cursor.line;
7069
+ const [startLine, endLine] = this.resolveRange(rangeStr, lines, currentLine2) ?? [0, lines.length - 1];
7070
+ const input = lines.slice(startLine, endLine + 1).join("\n");
7071
+ let output;
7072
+ try {
7073
+ output = filterFn(shellCmd, input);
7074
+ } catch {
7075
+ return;
7076
+ }
7077
+ const outputLines = output.split("\n");
7078
+ const newLines = [
7079
+ ...lines.slice(0, startLine),
7080
+ ...outputLines,
7081
+ ...lines.slice(endLine + 1)
7082
+ ];
7083
+ this.state = {
7084
+ ...this.state,
7085
+ buffer: new Buffer(newLines.join("\n")),
7086
+ cursor: new Cursor(Math.min(startLine + outputLines.length - 1, newLines.length - 1), 0),
7087
+ desiredColumn: null
7088
+ };
7089
+ }
7090
+ // ── Confirm-substitution mode ─────────────────────────────────────────────
7091
+ /**
7092
+ * Handle key input while a confirm-substitution is pending.
7093
+ * Keys: y (apply), n (skip), a (apply all), q (quit/cancel remaining), l (apply this + quit)
7094
+ */
7095
+ handleConfirmMode(key) {
7096
+ const pending = this.state.pendingConfirm;
7097
+ const { matches, currentIndex, appliedIndices } = pending;
7098
+ const advance = (applied) => {
7099
+ const nextIndex = currentIndex + 1;
7100
+ if (nextIndex >= matches.length) {
7101
+ const newContent = applySubstituteMatches(
7102
+ pending.originalContent,
7103
+ matches,
7104
+ new Set(applied)
7105
+ );
7106
+ this.state = {
7107
+ ...this.state,
7108
+ buffer: new Buffer(newContent),
7109
+ pendingConfirm: null,
7110
+ cursor: new Cursor(matches[applied[applied.length - 1] ?? 0]?.line ?? this.state.cursor.line, 0),
7111
+ desiredColumn: null
7112
+ };
7113
+ } else {
7114
+ this.state = {
7115
+ ...this.state,
7116
+ pendingConfirm: { ...pending, currentIndex: nextIndex, appliedIndices: applied }
7117
+ };
7118
+ }
7119
+ };
7120
+ const finishNow = (applied) => {
7121
+ const newContent = applySubstituteMatches(
7122
+ pending.originalContent,
7123
+ matches,
7124
+ new Set(applied)
7125
+ );
7126
+ const lastApplied = applied[applied.length - 1];
7127
+ this.state = {
7128
+ ...this.state,
7129
+ buffer: new Buffer(newContent),
7130
+ pendingConfirm: null,
7131
+ cursor: new Cursor(lastApplied !== void 0 ? matches[lastApplied]?.line ?? this.state.cursor.line : this.state.cursor.line, 0),
7132
+ desiredColumn: null
7133
+ };
7134
+ };
7135
+ if (key === "y") {
7136
+ advance([...appliedIndices, currentIndex]);
7137
+ } else if (key === "n") {
7138
+ advance([...appliedIndices]);
7139
+ } else if (key === "a") {
7140
+ const allApplied = [...appliedIndices];
7141
+ for (let i = currentIndex; i < matches.length; i++) allApplied.push(i);
7142
+ finishNow(allApplied);
7143
+ } else if (key === "l") {
7144
+ finishNow([...appliedIndices, currentIndex]);
7145
+ } else if (key === "q" || key === "<Esc>") {
7146
+ finishNow([...appliedIndices]);
7147
+ }
7148
+ }
7149
+ // ── Main ex-command dispatcher ─────────────────────────────────────────────
6703
7150
  executeCommand(command) {
6704
- const cmd = command.trim();
7151
+ const raw = command.trim();
7152
+ const [rangeStr, cmd] = this.splitRangeAndCommand(raw);
6705
7153
  const setMatch = cmd.match(/^set\s+(.+)$/);
6706
7154
  if (setMatch) {
6707
- const setCommand = setMatch[1].trim();
6708
- this.configManager.parseSetCommand(setCommand);
7155
+ this.configManager.parseSetCommand(setMatch[1].trim());
7156
+ return;
7157
+ }
7158
+ if (/^s[^a-zA-Z]/.test(cmd)) {
7159
+ this.executeSubstitute(rangeStr, cmd);
7160
+ return;
7161
+ }
7162
+ const globalM = /^(g|v)([^a-zA-Z].+)$/.exec(cmd);
7163
+ if (globalM) {
7164
+ const invert = globalM[1] === "v";
7165
+ this.executeGlobal(rangeStr, globalM[2], invert);
6709
7166
  return;
6710
7167
  }
6711
- if (cmd === "q" || cmd === "quit") {
7168
+ const moveM = /^m(?:ove)?\s*(.*)$/.exec(cmd);
7169
+ if (moveM) {
7170
+ const addrStr = (moveM[1] ?? "").trim();
7171
+ const lines = this.state.buffer.content.split("\n");
7172
+ const currentLine2 = this.state.cursor.line;
7173
+ const [startLine, endLine] = this.resolveRange(rangeStr, lines, currentLine2) ?? [currentLine2, currentLine2];
7174
+ const dest = addrStr === "0" ? -1 : this.resolveAddress(addrStr, lines, currentLine2);
7175
+ this.executeMoveLines(startLine, endLine, dest);
6712
7176
  return;
6713
7177
  }
6714
- if (cmd === "w" || cmd === "write") {
7178
+ if (cmd.startsWith("!")) {
7179
+ this.executeShellFilter(rangeStr || "%", cmd.slice(1).trim());
6715
7180
  return;
6716
7181
  }
6717
- if (cmd === "wq" || cmd === "x") {
7182
+ if (cmd === "d" || cmd === "delete") {
7183
+ const lines = this.state.buffer.content.split("\n");
7184
+ const currentLine2 = this.state.cursor.line;
7185
+ const [startLine, endLine] = this.resolveRange(rangeStr, lines, currentLine2) ?? [currentLine2, currentLine2];
7186
+ lines.splice(startLine, endLine - startLine + 1);
7187
+ this.state = {
7188
+ ...this.state,
7189
+ buffer: new Buffer(lines.join("\n")),
7190
+ cursor: new Cursor(Math.max(0, Math.min(startLine, lines.length - 1)), 0),
7191
+ desiredColumn: null
7192
+ };
6718
7193
  return;
6719
7194
  }
6720
- const lineMatch = cmd.match(/^(\d+)$/);
7195
+ if (cmd === "q" || cmd === "quit") return;
7196
+ if (cmd === "w" || cmd === "write") return;
7197
+ if (cmd === "wq" || cmd === "x") return;
7198
+ const lineNavStr = cmd !== "" ? cmd : rangeStr;
7199
+ const lineMatch = lineNavStr.match(/^(\d+)$/);
6721
7200
  if (lineMatch) {
6722
7201
  const targetLine = parseInt(lineMatch[1], 10) - 1;
6723
7202
  const lines = this.state.buffer.content.split("\n");