Download src/vim/operators.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/vim/operators.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/vim/operators.ts
-
curl -L -o operators.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/vim/operators.ts
16 kB
| /** | |
| * Vim Operator Functions | |
| * | |
| * Pure functions for executing vim operators (delete, change, yank, etc.) | |
| */ | |
| import { Cursor } from '../utils/Cursor.js' | |
| import { firstGrapheme, lastGrapheme } from '../utils/intl.js' | |
| import { countCharInString } from '../utils/stringUtils.js' | |
| import { | |
| isInclusiveMotion, | |
| isLinewiseMotion, | |
| resolveMotion, | |
| } from './motions.js' | |
| import { findTextObject } from './textObjects.js' | |
| import type { | |
| FindType, | |
| Operator, | |
| RecordedChange, | |
| TextObjScope, | |
| } from './types.js' | |
| /** | |
| * Context for operator execution. | |
| */ | |
| export type OperatorContext = { | |
| cursor: Cursor | |
| text: string | |
| setText: (text: string) => void | |
| setOffset: (offset: number) => void | |
| enterInsert: (offset: number) => void | |
| getRegister: () => string | |
| setRegister: (content: string, linewise: boolean) => void | |
| getLastFind: () => { type: FindType; char: string } | null | |
| setLastFind: (type: FindType, char: string) => void | |
| recordChange: (change: RecordedChange) => void | |
| } | |
| /** | |
| * Execute an operator with a simple motion. | |
| */ | |
| export function executeOperatorMotion( | |
| op: Operator, | |
| motion: string, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const target = resolveMotion(motion, ctx.cursor, count) | |
| if (target.equals(ctx.cursor)) return | |
| const range = getOperatorRange(ctx.cursor, target, motion, op, count) | |
| applyOperator(op, range.from, range.to, ctx, range.linewise) | |
| ctx.recordChange({ type: 'operator', op, motion, count }) | |
| } | |
| /** | |
| * Execute an operator with a find motion. | |
| */ | |
| export function executeOperatorFind( | |
| op: Operator, | |
| findType: FindType, | |
| char: string, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const targetOffset = ctx.cursor.findCharacter(char, findType, count) | |
| if (targetOffset === null) return | |
| const target = new Cursor(ctx.cursor.measuredText, targetOffset) | |
| const range = getOperatorRangeForFind(ctx.cursor, target, findType) | |
| applyOperator(op, range.from, range.to, ctx) | |
| ctx.setLastFind(findType, char) | |
| ctx.recordChange({ type: 'operatorFind', op, find: findType, char, count }) | |
| } | |
| /** | |
| * Execute an operator with a text object. | |
| */ | |
| export function executeOperatorTextObj( | |
| op: Operator, | |
| scope: TextObjScope, | |
| objType: string, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const range = findTextObject( | |
| ctx.text, | |
| ctx.cursor.offset, | |
| objType, | |
| scope === 'inner', | |
| ) | |
| if (!range) return | |
| applyOperator(op, range.start, range.end, ctx) | |
| ctx.recordChange({ type: 'operatorTextObj', op, objType, scope, count }) | |
| } | |
| /** | |
| * Execute a line operation (dd, cc, yy). | |
| */ | |
| export function executeLineOp( | |
| op: Operator, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const text = ctx.text | |
| const lines = text.split('\n') | |
| // Calculate logical line by counting newlines before cursor offset | |
| // (cursor.getPosition() returns wrapped line which is wrong for this) | |
| const currentLine = countCharInString(text.slice(0, ctx.cursor.offset), '\n') | |
| const linesToAffect = Math.min(count, lines.length - currentLine) | |
| const lineStart = ctx.cursor.startOfLogicalLine().offset | |
| let lineEnd = lineStart | |
| for (let i = 0; i < linesToAffect; i++) { | |
| const nextNewline = text.indexOf('\n', lineEnd) | |
| lineEnd = nextNewline === -1 ? text.length : nextNewline + 1 | |
| } | |
| let content = text.slice(lineStart, lineEnd) | |
| // Ensure linewise content ends with newline for paste detection | |
| if (!content.endsWith('\n')) { | |
| content = content + '\n' | |
| } | |
| ctx.setRegister(content, true) | |
| if (op === 'yank') { | |
| ctx.setOffset(lineStart) | |
| } else if (op === 'delete') { | |
| let deleteStart = lineStart | |
| const deleteEnd = lineEnd | |
| // If deleting to end of file and there's a preceding newline, include it | |
| // This ensures deleting the last line doesn't leave a trailing newline | |
| if ( | |
| deleteEnd === text.length && | |
| deleteStart > 0 && | |
| text[deleteStart - 1] === '\n' | |
| ) { | |
| deleteStart -= 1 | |
| } | |
| const newText = text.slice(0, deleteStart) + text.slice(deleteEnd) | |
| ctx.setText(newText || '') | |
| const maxOff = Math.max( | |
| 0, | |
| newText.length - (lastGrapheme(newText).length || 1), | |
| ) | |
| ctx.setOffset(Math.min(deleteStart, maxOff)) | |
| } else if (op === 'change') { | |
| // For single line, just clear it | |
| if (lines.length === 1) { | |
| ctx.setText('') | |
| ctx.enterInsert(0) | |
| } else { | |
| // Delete all affected lines, replace with single empty line, enter insert | |
| const beforeLines = lines.slice(0, currentLine) | |
| const afterLines = lines.slice(currentLine + linesToAffect) | |
| const newText = [...beforeLines, '', ...afterLines].join('\n') | |
| ctx.setText(newText) | |
| ctx.enterInsert(lineStart) | |
| } | |
| } | |
| ctx.recordChange({ type: 'operator', op, motion: op[0]!, count }) | |
| } | |
| /** | |
| * Execute delete character (x command). | |
| */ | |
| export function executeX(count: number, ctx: OperatorContext): void { | |
| const from = ctx.cursor.offset | |
| if (from >= ctx.text.length) return | |
| // Advance by graphemes, not code units | |
| let endCursor = ctx.cursor | |
| for (let i = 0; i < count && !endCursor.isAtEnd(); i++) { | |
| endCursor = endCursor.right() | |
| } | |
| const to = endCursor.offset | |
| const deleted = ctx.text.slice(from, to) | |
| const newText = ctx.text.slice(0, from) + ctx.text.slice(to) | |
| ctx.setRegister(deleted, false) | |
| ctx.setText(newText) | |
| const maxOff = Math.max( | |
| 0, | |
| newText.length - (lastGrapheme(newText).length || 1), | |
| ) | |
| ctx.setOffset(Math.min(from, maxOff)) | |
| ctx.recordChange({ type: 'x', count }) | |
| } | |
| /** | |
| * Execute replace character (r command). | |
| */ | |
| export function executeReplace( | |
| char: string, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| let offset = ctx.cursor.offset | |
| let newText = ctx.text | |
| for (let i = 0; i < count && offset < newText.length; i++) { | |
| const graphemeLen = firstGrapheme(newText.slice(offset)).length || 1 | |
| newText = | |
| newText.slice(0, offset) + char + newText.slice(offset + graphemeLen) | |
| offset += char.length | |
| } | |
| ctx.setText(newText) | |
| ctx.setOffset(Math.max(0, offset - char.length)) | |
| ctx.recordChange({ type: 'replace', char, count }) | |
| } | |
| /** | |
| * Execute toggle case (~ command). | |
| */ | |
| export function executeToggleCase(count: number, ctx: OperatorContext): void { | |
| const startOffset = ctx.cursor.offset | |
| if (startOffset >= ctx.text.length) return | |
| let newText = ctx.text | |
| let offset = startOffset | |
| let toggled = 0 | |
| while (offset < newText.length && toggled < count) { | |
| const grapheme = firstGrapheme(newText.slice(offset)) | |
| const graphemeLen = grapheme.length | |
| const toggledGrapheme = | |
| grapheme === grapheme.toUpperCase() | |
| ? grapheme.toLowerCase() | |
| : grapheme.toUpperCase() | |
| newText = | |
| newText.slice(0, offset) + | |
| toggledGrapheme + | |
| newText.slice(offset + graphemeLen) | |
| offset += toggledGrapheme.length | |
| toggled++ | |
| } | |
| ctx.setText(newText) | |
| // Cursor moves to position after the last toggled character | |
| // At end of line, cursor can be at the "end" position | |
| ctx.setOffset(offset) | |
| ctx.recordChange({ type: 'toggleCase', count }) | |
| } | |
| /** | |
| * Execute join lines (J command). | |
| */ | |
| export function executeJoin(count: number, ctx: OperatorContext): void { | |
| const text = ctx.text | |
| const lines = text.split('\n') | |
| const { line: currentLine } = ctx.cursor.getPosition() | |
| if (currentLine >= lines.length - 1) return | |
| const linesToJoin = Math.min(count, lines.length - currentLine - 1) | |
| let joinedLine = lines[currentLine]! | |
| const cursorPos = joinedLine.length | |
| for (let i = 1; i <= linesToJoin; i++) { | |
| const nextLine = (lines[currentLine + i] ?? '').trimStart() | |
| if (nextLine.length > 0) { | |
| if (!joinedLine.endsWith(' ') && joinedLine.length > 0) { | |
| joinedLine += ' ' | |
| } | |
| joinedLine += nextLine | |
| } | |
| } | |
| const newLines = [ | |
| ...lines.slice(0, currentLine), | |
| joinedLine, | |
| ...lines.slice(currentLine + linesToJoin + 1), | |
| ] | |
| const newText = newLines.join('\n') | |
| ctx.setText(newText) | |
| ctx.setOffset(getLineStartOffset(newLines, currentLine) + cursorPos) | |
| ctx.recordChange({ type: 'join', count }) | |
| } | |
| /** | |
| * Execute paste (p/P command). | |
| */ | |
| export function executePaste( | |
| after: boolean, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const register = ctx.getRegister() | |
| if (!register) return | |
| const isLinewise = register.endsWith('\n') | |
| const content = isLinewise ? register.slice(0, -1) : register | |
| if (isLinewise) { | |
| const text = ctx.text | |
| const lines = text.split('\n') | |
| const { line: currentLine } = ctx.cursor.getPosition() | |
| const insertLine = after ? currentLine + 1 : currentLine | |
| const contentLines = content.split('\n') | |
| const repeatedLines: string[] = [] | |
| for (let i = 0; i < count; i++) { | |
| repeatedLines.push(...contentLines) | |
| } | |
| const newLines = [ | |
| ...lines.slice(0, insertLine), | |
| ...repeatedLines, | |
| ...lines.slice(insertLine), | |
| ] | |
| const newText = newLines.join('\n') | |
| ctx.setText(newText) | |
| ctx.setOffset(getLineStartOffset(newLines, insertLine)) | |
| } else { | |
| const textToInsert = content.repeat(count) | |
| const insertPoint = | |
| after && ctx.cursor.offset < ctx.text.length | |
| ? ctx.cursor.measuredText.nextOffset(ctx.cursor.offset) | |
| : ctx.cursor.offset | |
| const newText = | |
| ctx.text.slice(0, insertPoint) + | |
| textToInsert + | |
| ctx.text.slice(insertPoint) | |
| const lastGr = lastGrapheme(textToInsert) | |
| const newOffset = insertPoint + textToInsert.length - (lastGr.length || 1) | |
| ctx.setText(newText) | |
| ctx.setOffset(Math.max(insertPoint, newOffset)) | |
| } | |
| } | |
| /** | |
| * Execute indent (>> command). | |
| */ | |
| export function executeIndent( | |
| dir: '>' | '<', | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| const text = ctx.text | |
| const lines = text.split('\n') | |
| const { line: currentLine } = ctx.cursor.getPosition() | |
| const linesToAffect = Math.min(count, lines.length - currentLine) | |
| const indent = ' ' // Two spaces | |
| for (let i = 0; i < linesToAffect; i++) { | |
| const lineIdx = currentLine + i | |
| const line = lines[lineIdx] ?? '' | |
| if (dir === '>') { | |
| lines[lineIdx] = indent + line | |
| } else if (line.startsWith(indent)) { | |
| lines[lineIdx] = line.slice(indent.length) | |
| } else if (line.startsWith('\t')) { | |
| lines[lineIdx] = line.slice(1) | |
| } else { | |
| // Remove as much leading whitespace as possible up to indent length | |
| let removed = 0 | |
| let idx = 0 | |
| while ( | |
| idx < line.length && | |
| removed < indent.length && | |
| /\s/.test(line[idx]!) | |
| ) { | |
| removed++ | |
| idx++ | |
| } | |
| lines[lineIdx] = line.slice(idx) | |
| } | |
| } | |
| const newText = lines.join('\n') | |
| const currentLineText = lines[currentLine] ?? '' | |
| const firstNonBlank = (currentLineText.match(/^\s*/)?.[0] ?? '').length | |
| ctx.setText(newText) | |
| ctx.setOffset(getLineStartOffset(lines, currentLine) + firstNonBlank) | |
| ctx.recordChange({ type: 'indent', dir, count }) | |
| } | |
| /** | |
| * Execute open line (o/O command). | |
| */ | |
| export function executeOpenLine( | |
| direction: 'above' | 'below', | |
| ctx: OperatorContext, | |
| ): void { | |
| const text = ctx.text | |
| const lines = text.split('\n') | |
| const { line: currentLine } = ctx.cursor.getPosition() | |
| const insertLine = direction === 'below' ? currentLine + 1 : currentLine | |
| const newLines = [ | |
| ...lines.slice(0, insertLine), | |
| '', | |
| ...lines.slice(insertLine), | |
| ] | |
| const newText = newLines.join('\n') | |
| ctx.setText(newText) | |
| ctx.enterInsert(getLineStartOffset(newLines, insertLine)) | |
| ctx.recordChange({ type: 'openLine', direction }) | |
| } | |
| // ============================================================================ | |
| // Internal Helpers | |
| // ============================================================================ | |
| /** | |
| * Calculate the offset of a line's start position. | |
| */ | |
| function getLineStartOffset(lines: string[], lineIndex: number): number { | |
| return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0) | |
| } | |
| function getOperatorRange( | |
| cursor: Cursor, | |
| target: Cursor, | |
| motion: string, | |
| op: Operator, | |
| count: number, | |
| ): { from: number; to: number; linewise: boolean } { | |
| let from = Math.min(cursor.offset, target.offset) | |
| let to = Math.max(cursor.offset, target.offset) | |
| let linewise = false | |
| // Special case: cw/cW changes to end of word, not start of next word | |
| if (op === 'change' && (motion === 'w' || motion === 'W')) { | |
| // For cw with count, move forward (count-1) words, then find end of that word | |
| let wordCursor = cursor | |
| for (let i = 0; i < count - 1; i++) { | |
| wordCursor = | |
| motion === 'w' ? wordCursor.nextVimWord() : wordCursor.nextWORD() | |
| } | |
| const wordEnd = | |
| motion === 'w' ? wordCursor.endOfVimWord() : wordCursor.endOfWORD() | |
| to = cursor.measuredText.nextOffset(wordEnd.offset) | |
| } else if (isLinewiseMotion(motion)) { | |
| // Linewise motions extend to include entire lines | |
| linewise = true | |
| const text = cursor.text | |
| const nextNewline = text.indexOf('\n', to) | |
| if (nextNewline === -1) { | |
| // Deleting to end of file - include the preceding newline if exists | |
| to = text.length | |
| if (from > 0 && text[from - 1] === '\n') { | |
| from -= 1 | |
| } | |
| } else { | |
| to = nextNewline + 1 | |
| } | |
| } else if (isInclusiveMotion(motion) && cursor.offset <= target.offset) { | |
| to = cursor.measuredText.nextOffset(to) | |
| } | |
| // Word motions can land inside an [Image #N] chip; extend the range to | |
| // cover the whole chip so dw/cw/yw never leave a partial placeholder. | |
| from = cursor.snapOutOfImageRef(from, 'start') | |
| to = cursor.snapOutOfImageRef(to, 'end') | |
| return { from, to, linewise } | |
| } | |
| /** | |
| * Get the range for a find-based operator. | |
| * Note: _findType is unused because Cursor.findCharacter already adjusts | |
| * the offset for t/T motions. All find types are treated as inclusive here. | |
| */ | |
| function getOperatorRangeForFind( | |
| cursor: Cursor, | |
| target: Cursor, | |
| _findType: FindType, | |
| ): { from: number; to: number } { | |
| const from = Math.min(cursor.offset, target.offset) | |
| const maxOffset = Math.max(cursor.offset, target.offset) | |
| const to = cursor.measuredText.nextOffset(maxOffset) | |
| return { from, to } | |
| } | |
| function applyOperator( | |
| op: Operator, | |
| from: number, | |
| to: number, | |
| ctx: OperatorContext, | |
| linewise: boolean = false, | |
| ): void { | |
| let content = ctx.text.slice(from, to) | |
| // Ensure linewise content ends with newline for paste detection | |
| if (linewise && !content.endsWith('\n')) { | |
| content = content + '\n' | |
| } | |
| ctx.setRegister(content, linewise) | |
| if (op === 'yank') { | |
| ctx.setOffset(from) | |
| } else if (op === 'delete') { | |
| const newText = ctx.text.slice(0, from) + ctx.text.slice(to) | |
| ctx.setText(newText) | |
| const maxOff = Math.max( | |
| 0, | |
| newText.length - (lastGrapheme(newText).length || 1), | |
| ) | |
| ctx.setOffset(Math.min(from, maxOff)) | |
| } else if (op === 'change') { | |
| const newText = ctx.text.slice(0, from) + ctx.text.slice(to) | |
| ctx.setText(newText) | |
| ctx.enterInsert(from) | |
| } | |
| } | |
| export function executeOperatorG( | |
| op: Operator, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| // count=1 means no count given, target = end of file | |
| // otherwise target = line N | |
| const target = | |
| count === 1 ? ctx.cursor.startOfLastLine() : ctx.cursor.goToLine(count) | |
| if (target.equals(ctx.cursor)) return | |
| const range = getOperatorRange(ctx.cursor, target, 'G', op, count) | |
| applyOperator(op, range.from, range.to, ctx, range.linewise) | |
| ctx.recordChange({ type: 'operator', op, motion: 'G', count }) | |
| } | |
| export function executeOperatorGg( | |
| op: Operator, | |
| count: number, | |
| ctx: OperatorContext, | |
| ): void { | |
| // count=1 means no count given, target = first line | |
| // otherwise target = line N | |
| const target = | |
| count === 1 ? ctx.cursor.startOfFirstLine() : ctx.cursor.goToLine(count) | |
| if (target.equals(ctx.cursor)) return | |
| const range = getOperatorRange(ctx.cursor, target, 'gg', op, count) | |
| applyOperator(op, range.from, range.to, ctx, range.linewise) | |
| ctx.recordChange({ type: 'operator', op, motion: 'gg', count }) | |
| } | |