vim-sim 1.0.5 → 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() {
@@ -5525,6 +5532,152 @@ var ParagraphTextObject = class extends TextObject {
5525
5532
  }
5526
5533
  };
5527
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
+
5528
5681
  // src/commands/misc.ts
5529
5682
  var JumpForward = class extends Command {
5530
5683
  key = "<C-i>";
@@ -6534,6 +6687,10 @@ var Session = class _Session {
6534
6687
  }
6535
6688
  handleKey(key) {
6536
6689
  key = _Session.normalizeKey(key);
6690
+ if (this.state.pendingConfirm !== null) {
6691
+ this.handleConfirmMode(key);
6692
+ return;
6693
+ }
6537
6694
  if (this.state.mode === 1 /* INSERT */) {
6538
6695
  this.handleInsertMode(key);
6539
6696
  return;
@@ -6565,6 +6722,7 @@ var Session = class _Session {
6565
6722
  const stateBefore = this.state;
6566
6723
  result.context.registerManager = this.registerManager;
6567
6724
  result.context.indentationManager = this.indentationManager;
6725
+ result.context.lastSubstitute = this.state.lastSubstitute;
6568
6726
  this.state = result.command.execute(this.state, result.context);
6569
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
6570
6728
  stateBefore.visualAnchor && (this.state.cursor.line !== stateBefore.cursor.line || this.state.cursor.column !== stateBefore.cursor.column)) {
@@ -6714,24 +6872,331 @@ var Session = class _Session {
6714
6872
  };
6715
6873
  }
6716
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 ─────────────────────────────────────────────
6717
7150
  executeCommand(command) {
6718
- const cmd = command.trim();
7151
+ const raw = command.trim();
7152
+ const [rangeStr, cmd] = this.splitRangeAndCommand(raw);
6719
7153
  const setMatch = cmd.match(/^set\s+(.+)$/);
6720
7154
  if (setMatch) {
6721
- const setCommand = setMatch[1].trim();
6722
- this.configManager.parseSetCommand(setCommand);
7155
+ this.configManager.parseSetCommand(setMatch[1].trim());
6723
7156
  return;
6724
7157
  }
6725
- if (cmd === "q" || cmd === "quit") {
7158
+ if (/^s[^a-zA-Z]/.test(cmd)) {
7159
+ this.executeSubstitute(rangeStr, cmd);
6726
7160
  return;
6727
7161
  }
6728
- if (cmd === "w" || cmd === "write") {
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);
6729
7166
  return;
6730
7167
  }
6731
- if (cmd === "wq" || cmd === "x") {
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);
7176
+ return;
7177
+ }
7178
+ if (cmd.startsWith("!")) {
7179
+ this.executeShellFilter(rangeStr || "%", cmd.slice(1).trim());
7180
+ return;
7181
+ }
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
+ };
6732
7193
  return;
6733
7194
  }
6734
- 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+)$/);
6735
7200
  if (lineMatch) {
6736
7201
  const targetLine = parseInt(lineMatch[1], 10) - 1;
6737
7202
  const lines = this.state.buffer.content.split("\n");