/ vim / operators.ts
operators.ts
  1  /**
  2   * Vim Operator Functions
  3   *
  4   * Pure functions for executing vim operators (delete, change, yank, etc.)
  5   */
  6  
  7  import { Cursor } from '../utils/Cursor.js'
  8  import { firstGrapheme, lastGrapheme } from '../utils/intl.js'
  9  import { countCharInString } from '../utils/stringUtils.js'
 10  import {
 11    isInclusiveMotion,
 12    isLinewiseMotion,
 13    resolveMotion,
 14  } from './motions.js'
 15  import { findTextObject } from './textObjects.js'
 16  import type {
 17    FindType,
 18    Operator,
 19    RecordedChange,
 20    TextObjScope,
 21  } from './types.js'
 22  
 23  /**
 24   * Context for operator execution.
 25   */
 26  export type OperatorContext = {
 27    cursor: Cursor
 28    text: string
 29    setText: (text: string) => void
 30    setOffset: (offset: number) => void
 31    enterInsert: (offset: number) => void
 32    getRegister: () => string
 33    setRegister: (content: string, linewise: boolean) => void
 34    getLastFind: () => { type: FindType; char: string } | null
 35    setLastFind: (type: FindType, char: string) => void
 36    recordChange: (change: RecordedChange) => void
 37  }
 38  
 39  /**
 40   * Execute an operator with a simple motion.
 41   */
 42  export function executeOperatorMotion(
 43    op: Operator,
 44    motion: string,
 45    count: number,
 46    ctx: OperatorContext,
 47  ): void {
 48    const target = resolveMotion(motion, ctx.cursor, count)
 49    if (target.equals(ctx.cursor)) return
 50  
 51    const range = getOperatorRange(ctx.cursor, target, motion, op, count)
 52    applyOperator(op, range.from, range.to, ctx, range.linewise)
 53    ctx.recordChange({ type: 'operator', op, motion, count })
 54  }
 55  
 56  /**
 57   * Execute an operator with a find motion.
 58   */
 59  export function executeOperatorFind(
 60    op: Operator,
 61    findType: FindType,
 62    char: string,
 63    count: number,
 64    ctx: OperatorContext,
 65  ): void {
 66    const targetOffset = ctx.cursor.findCharacter(char, findType, count)
 67    if (targetOffset === null) return
 68  
 69    const target = new Cursor(ctx.cursor.measuredText, targetOffset)
 70    const range = getOperatorRangeForFind(ctx.cursor, target, findType)
 71  
 72    applyOperator(op, range.from, range.to, ctx)
 73    ctx.setLastFind(findType, char)
 74    ctx.recordChange({ type: 'operatorFind', op, find: findType, char, count })
 75  }
 76  
 77  /**
 78   * Execute an operator with a text object.
 79   */
 80  export function executeOperatorTextObj(
 81    op: Operator,
 82    scope: TextObjScope,
 83    objType: string,
 84    count: number,
 85    ctx: OperatorContext,
 86  ): void {
 87    const range = findTextObject(
 88      ctx.text,
 89      ctx.cursor.offset,
 90      objType,
 91      scope === 'inner',
 92    )
 93    if (!range) return
 94  
 95    applyOperator(op, range.start, range.end, ctx)
 96    ctx.recordChange({ type: 'operatorTextObj', op, objType, scope, count })
 97  }
 98  
 99  /**
100   * Execute a line operation (dd, cc, yy).
101   */
102  export function executeLineOp(
103    op: Operator,
104    count: number,
105    ctx: OperatorContext,
106  ): void {
107    const text = ctx.text
108    const lines = text.split('\n')
109    // Calculate logical line by counting newlines before cursor offset
110    // (cursor.getPosition() returns wrapped line which is wrong for this)
111    const currentLine = countCharInString(text.slice(0, ctx.cursor.offset), '\n')
112    const linesToAffect = Math.min(count, lines.length - currentLine)
113    const lineStart = ctx.cursor.startOfLogicalLine().offset
114    let lineEnd = lineStart
115    for (let i = 0; i < linesToAffect; i++) {
116      const nextNewline = text.indexOf('\n', lineEnd)
117      lineEnd = nextNewline === -1 ? text.length : nextNewline + 1
118    }
119  
120    let content = text.slice(lineStart, lineEnd)
121    // Ensure linewise content ends with newline for paste detection
122    if (!content.endsWith('\n')) {
123      content = content + '\n'
124    }
125    ctx.setRegister(content, true)
126  
127    if (op === 'yank') {
128      ctx.setOffset(lineStart)
129    } else if (op === 'delete') {
130      let deleteStart = lineStart
131      const deleteEnd = lineEnd
132  
133      // If deleting to end of file and there's a preceding newline, include it
134      // This ensures deleting the last line doesn't leave a trailing newline
135      if (
136        deleteEnd === text.length &&
137        deleteStart > 0 &&
138        text[deleteStart - 1] === '\n'
139      ) {
140        deleteStart -= 1
141      }
142  
143      const newText = text.slice(0, deleteStart) + text.slice(deleteEnd)
144      ctx.setText(newText || '')
145      const maxOff = Math.max(
146        0,
147        newText.length - (lastGrapheme(newText).length || 1),
148      )
149      ctx.setOffset(Math.min(deleteStart, maxOff))
150    } else if (op === 'change') {
151      // For single line, just clear it
152      if (lines.length === 1) {
153        ctx.setText('')
154        ctx.enterInsert(0)
155      } else {
156        // Delete all affected lines, replace with single empty line, enter insert
157        const beforeLines = lines.slice(0, currentLine)
158        const afterLines = lines.slice(currentLine + linesToAffect)
159        const newText = [...beforeLines, '', ...afterLines].join('\n')
160        ctx.setText(newText)
161        ctx.enterInsert(lineStart)
162      }
163    }
164  
165    ctx.recordChange({ type: 'operator', op, motion: op[0]!, count })
166  }
167  
168  /**
169   * Execute delete character (x command).
170   */
171  export function executeX(count: number, ctx: OperatorContext): void {
172    const from = ctx.cursor.offset
173  
174    if (from >= ctx.text.length) return
175  
176    // Advance by graphemes, not code units
177    let endCursor = ctx.cursor
178    for (let i = 0; i < count && !endCursor.isAtEnd(); i++) {
179      endCursor = endCursor.right()
180    }
181    const to = endCursor.offset
182  
183    const deleted = ctx.text.slice(from, to)
184    const newText = ctx.text.slice(0, from) + ctx.text.slice(to)
185  
186    ctx.setRegister(deleted, false)
187    ctx.setText(newText)
188    const maxOff = Math.max(
189      0,
190      newText.length - (lastGrapheme(newText).length || 1),
191    )
192    ctx.setOffset(Math.min(from, maxOff))
193    ctx.recordChange({ type: 'x', count })
194  }
195  
196  /**
197   * Execute replace character (r command).
198   */
199  export function executeReplace(
200    char: string,
201    count: number,
202    ctx: OperatorContext,
203  ): void {
204    let offset = ctx.cursor.offset
205    let newText = ctx.text
206  
207    for (let i = 0; i < count && offset < newText.length; i++) {
208      const graphemeLen = firstGrapheme(newText.slice(offset)).length || 1
209      newText =
210        newText.slice(0, offset) + char + newText.slice(offset + graphemeLen)
211      offset += char.length
212    }
213  
214    ctx.setText(newText)
215    ctx.setOffset(Math.max(0, offset - char.length))
216    ctx.recordChange({ type: 'replace', char, count })
217  }
218  
219  /**
220   * Execute toggle case (~ command).
221   */
222  export function executeToggleCase(count: number, ctx: OperatorContext): void {
223    const startOffset = ctx.cursor.offset
224  
225    if (startOffset >= ctx.text.length) return
226  
227    let newText = ctx.text
228    let offset = startOffset
229    let toggled = 0
230  
231    while (offset < newText.length && toggled < count) {
232      const grapheme = firstGrapheme(newText.slice(offset))
233      const graphemeLen = grapheme.length
234  
235      const toggledGrapheme =
236        grapheme === grapheme.toUpperCase()
237          ? grapheme.toLowerCase()
238          : grapheme.toUpperCase()
239  
240      newText =
241        newText.slice(0, offset) +
242        toggledGrapheme +
243        newText.slice(offset + graphemeLen)
244      offset += toggledGrapheme.length
245      toggled++
246    }
247  
248    ctx.setText(newText)
249    // Cursor moves to position after the last toggled character
250    // At end of line, cursor can be at the "end" position
251    ctx.setOffset(offset)
252    ctx.recordChange({ type: 'toggleCase', count })
253  }
254  
255  /**
256   * Execute join lines (J command).
257   */
258  export function executeJoin(count: number, ctx: OperatorContext): void {
259    const text = ctx.text
260    const lines = text.split('\n')
261    const { line: currentLine } = ctx.cursor.getPosition()
262  
263    if (currentLine >= lines.length - 1) return
264  
265    const linesToJoin = Math.min(count, lines.length - currentLine - 1)
266    let joinedLine = lines[currentLine]!
267    const cursorPos = joinedLine.length
268  
269    for (let i = 1; i <= linesToJoin; i++) {
270      const nextLine = (lines[currentLine + i] ?? '').trimStart()
271      if (nextLine.length > 0) {
272        if (!joinedLine.endsWith(' ') && joinedLine.length > 0) {
273          joinedLine += ' '
274        }
275        joinedLine += nextLine
276      }
277    }
278  
279    const newLines = [
280      ...lines.slice(0, currentLine),
281      joinedLine,
282      ...lines.slice(currentLine + linesToJoin + 1),
283    ]
284  
285    const newText = newLines.join('\n')
286    ctx.setText(newText)
287    ctx.setOffset(getLineStartOffset(newLines, currentLine) + cursorPos)
288    ctx.recordChange({ type: 'join', count })
289  }
290  
291  /**
292   * Execute paste (p/P command).
293   */
294  export function executePaste(
295    after: boolean,
296    count: number,
297    ctx: OperatorContext,
298  ): void {
299    const register = ctx.getRegister()
300    if (!register) return
301  
302    const isLinewise = register.endsWith('\n')
303    const content = isLinewise ? register.slice(0, -1) : register
304  
305    if (isLinewise) {
306      const text = ctx.text
307      const lines = text.split('\n')
308      const { line: currentLine } = ctx.cursor.getPosition()
309  
310      const insertLine = after ? currentLine + 1 : currentLine
311      const contentLines = content.split('\n')
312      const repeatedLines: string[] = []
313      for (let i = 0; i < count; i++) {
314        repeatedLines.push(...contentLines)
315      }
316  
317      const newLines = [
318        ...lines.slice(0, insertLine),
319        ...repeatedLines,
320        ...lines.slice(insertLine),
321      ]
322  
323      const newText = newLines.join('\n')
324      ctx.setText(newText)
325      ctx.setOffset(getLineStartOffset(newLines, insertLine))
326    } else {
327      const textToInsert = content.repeat(count)
328      const insertPoint =
329        after && ctx.cursor.offset < ctx.text.length
330          ? ctx.cursor.measuredText.nextOffset(ctx.cursor.offset)
331          : ctx.cursor.offset
332  
333      const newText =
334        ctx.text.slice(0, insertPoint) +
335        textToInsert +
336        ctx.text.slice(insertPoint)
337      const lastGr = lastGrapheme(textToInsert)
338      const newOffset = insertPoint + textToInsert.length - (lastGr.length || 1)
339  
340      ctx.setText(newText)
341      ctx.setOffset(Math.max(insertPoint, newOffset))
342    }
343  }
344  
345  /**
346   * Execute indent (>> command).
347   */
348  export function executeIndent(
349    dir: '>' | '<',
350    count: number,
351    ctx: OperatorContext,
352  ): void {
353    const text = ctx.text
354    const lines = text.split('\n')
355    const { line: currentLine } = ctx.cursor.getPosition()
356    const linesToAffect = Math.min(count, lines.length - currentLine)
357    const indent = '  ' // Two spaces
358  
359    for (let i = 0; i < linesToAffect; i++) {
360      const lineIdx = currentLine + i
361      const line = lines[lineIdx] ?? ''
362  
363      if (dir === '>') {
364        lines[lineIdx] = indent + line
365      } else if (line.startsWith(indent)) {
366        lines[lineIdx] = line.slice(indent.length)
367      } else if (line.startsWith('\t')) {
368        lines[lineIdx] = line.slice(1)
369      } else {
370        // Remove as much leading whitespace as possible up to indent length
371        let removed = 0
372        let idx = 0
373        while (
374          idx < line.length &&
375          removed < indent.length &&
376          /\s/.test(line[idx]!)
377        ) {
378          removed++
379          idx++
380        }
381        lines[lineIdx] = line.slice(idx)
382      }
383    }
384  
385    const newText = lines.join('\n')
386    const currentLineText = lines[currentLine] ?? ''
387    const firstNonBlank = (currentLineText.match(/^\s*/)?.[0] ?? '').length
388  
389    ctx.setText(newText)
390    ctx.setOffset(getLineStartOffset(lines, currentLine) + firstNonBlank)
391    ctx.recordChange({ type: 'indent', dir, count })
392  }
393  
394  /**
395   * Execute open line (o/O command).
396   */
397  export function executeOpenLine(
398    direction: 'above' | 'below',
399    ctx: OperatorContext,
400  ): void {
401    const text = ctx.text
402    const lines = text.split('\n')
403    const { line: currentLine } = ctx.cursor.getPosition()
404  
405    const insertLine = direction === 'below' ? currentLine + 1 : currentLine
406    const newLines = [
407      ...lines.slice(0, insertLine),
408      '',
409      ...lines.slice(insertLine),
410    ]
411  
412    const newText = newLines.join('\n')
413    ctx.setText(newText)
414    ctx.enterInsert(getLineStartOffset(newLines, insertLine))
415    ctx.recordChange({ type: 'openLine', direction })
416  }
417  
418  // ============================================================================
419  // Internal Helpers
420  // ============================================================================
421  
422  /**
423   * Calculate the offset of a line's start position.
424   */
425  function getLineStartOffset(lines: string[], lineIndex: number): number {
426    return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0)
427  }
428  
429  function getOperatorRange(
430    cursor: Cursor,
431    target: Cursor,
432    motion: string,
433    op: Operator,
434    count: number,
435  ): { from: number; to: number; linewise: boolean } {
436    let from = Math.min(cursor.offset, target.offset)
437    let to = Math.max(cursor.offset, target.offset)
438    let linewise = false
439  
440    // Special case: cw/cW changes to end of word, not start of next word
441    if (op === 'change' && (motion === 'w' || motion === 'W')) {
442      // For cw with count, move forward (count-1) words, then find end of that word
443      let wordCursor = cursor
444      for (let i = 0; i < count - 1; i++) {
445        wordCursor =
446          motion === 'w' ? wordCursor.nextVimWord() : wordCursor.nextWORD()
447      }
448      const wordEnd =
449        motion === 'w' ? wordCursor.endOfVimWord() : wordCursor.endOfWORD()
450      to = cursor.measuredText.nextOffset(wordEnd.offset)
451    } else if (isLinewiseMotion(motion)) {
452      // Linewise motions extend to include entire lines
453      linewise = true
454      const text = cursor.text
455      const nextNewline = text.indexOf('\n', to)
456      if (nextNewline === -1) {
457        // Deleting to end of file - include the preceding newline if exists
458        to = text.length
459        if (from > 0 && text[from - 1] === '\n') {
460          from -= 1
461        }
462      } else {
463        to = nextNewline + 1
464      }
465    } else if (isInclusiveMotion(motion) && cursor.offset <= target.offset) {
466      to = cursor.measuredText.nextOffset(to)
467    }
468  
469    // Word motions can land inside an [Image #N] chip; extend the range to
470    // cover the whole chip so dw/cw/yw never leave a partial placeholder.
471    from = cursor.snapOutOfImageRef(from, 'start')
472    to = cursor.snapOutOfImageRef(to, 'end')
473  
474    return { from, to, linewise }
475  }
476  
477  /**
478   * Get the range for a find-based operator.
479   * Note: _findType is unused because Cursor.findCharacter already adjusts
480   * the offset for t/T motions. All find types are treated as inclusive here.
481   */
482  function getOperatorRangeForFind(
483    cursor: Cursor,
484    target: Cursor,
485    _findType: FindType,
486  ): { from: number; to: number } {
487    const from = Math.min(cursor.offset, target.offset)
488    const maxOffset = Math.max(cursor.offset, target.offset)
489    const to = cursor.measuredText.nextOffset(maxOffset)
490    return { from, to }
491  }
492  
493  function applyOperator(
494    op: Operator,
495    from: number,
496    to: number,
497    ctx: OperatorContext,
498    linewise: boolean = false,
499  ): void {
500    let content = ctx.text.slice(from, to)
501    // Ensure linewise content ends with newline for paste detection
502    if (linewise && !content.endsWith('\n')) {
503      content = content + '\n'
504    }
505    ctx.setRegister(content, linewise)
506  
507    if (op === 'yank') {
508      ctx.setOffset(from)
509    } else if (op === 'delete') {
510      const newText = ctx.text.slice(0, from) + ctx.text.slice(to)
511      ctx.setText(newText)
512      const maxOff = Math.max(
513        0,
514        newText.length - (lastGrapheme(newText).length || 1),
515      )
516      ctx.setOffset(Math.min(from, maxOff))
517    } else if (op === 'change') {
518      const newText = ctx.text.slice(0, from) + ctx.text.slice(to)
519      ctx.setText(newText)
520      ctx.enterInsert(from)
521    }
522  }
523  
524  export function executeOperatorG(
525    op: Operator,
526    count: number,
527    ctx: OperatorContext,
528  ): void {
529    // count=1 means no count given, target = end of file
530    // otherwise target = line N
531    const target =
532      count === 1 ? ctx.cursor.startOfLastLine() : ctx.cursor.goToLine(count)
533  
534    if (target.equals(ctx.cursor)) return
535  
536    const range = getOperatorRange(ctx.cursor, target, 'G', op, count)
537    applyOperator(op, range.from, range.to, ctx, range.linewise)
538    ctx.recordChange({ type: 'operator', op, motion: 'G', count })
539  }
540  
541  export function executeOperatorGg(
542    op: Operator,
543    count: number,
544    ctx: OperatorContext,
545  ): void {
546    // count=1 means no count given, target = first line
547    // otherwise target = line N
548    const target =
549      count === 1 ? ctx.cursor.startOfFirstLine() : ctx.cursor.goToLine(count)
550  
551    if (target.equals(ctx.cursor)) return
552  
553    const range = getOperatorRange(ctx.cursor, target, 'gg', op, count)
554    applyOperator(op, range.from, range.to, ctx, range.linewise)
555    ctx.recordChange({ type: 'operator', op, motion: 'gg', count })
556  }