source dump of claude code
at main 556 lines 16 kB view raw
1/** 2 * Vim Operator Functions 3 * 4 * Pure functions for executing vim operators (delete, change, yank, etc.) 5 */ 6 7import { Cursor } from '../utils/Cursor.js' 8import { firstGrapheme, lastGrapheme } from '../utils/intl.js' 9import { countCharInString } from '../utils/stringUtils.js' 10import { 11 isInclusiveMotion, 12 isLinewiseMotion, 13 resolveMotion, 14} from './motions.js' 15import { findTextObject } from './textObjects.js' 16import type { 17 FindType, 18 Operator, 19 RecordedChange, 20 TextObjScope, 21} from './types.js' 22 23/** 24 * Context for operator execution. 25 */ 26export 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 */ 42export 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 */ 59export 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 */ 80export 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 */ 102export 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 */ 171export 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 */ 199export 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 */ 222export 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 */ 258export 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 */ 294export 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 */ 348export 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 */ 397export 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 */ 425function getLineStartOffset(lines: string[], lineIndex: number): number { 426 return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0) 427} 428 429function 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 */ 482function 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 493function 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 524export 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 541export 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}