/ vim / textObjects.ts
textObjects.ts
  1  /**
  2   * Vim Text Object Finding
  3   *
  4   * Functions for finding text object boundaries (iw, aw, i", a(, etc.)
  5   */
  6  
  7  import {
  8    isVimPunctuation,
  9    isVimWhitespace,
 10    isVimWordChar,
 11  } from '../utils/Cursor.js'
 12  import { getGraphemeSegmenter } from '../utils/intl.js'
 13  
 14  export type TextObjectRange = { start: number; end: number } | null
 15  
 16  /**
 17   * Delimiter pairs for text objects.
 18   */
 19  const PAIRS: Record<string, [string, string]> = {
 20    '(': ['(', ')'],
 21    ')': ['(', ')'],
 22    b: ['(', ')'],
 23    '[': ['[', ']'],
 24    ']': ['[', ']'],
 25    '{': ['{', '}'],
 26    '}': ['{', '}'],
 27    B: ['{', '}'],
 28    '<': ['<', '>'],
 29    '>': ['<', '>'],
 30    '"': ['"', '"'],
 31    "'": ["'", "'"],
 32    '`': ['`', '`'],
 33  }
 34  
 35  /**
 36   * Find a text object at the given position.
 37   */
 38  export function findTextObject(
 39    text: string,
 40    offset: number,
 41    objectType: string,
 42    isInner: boolean,
 43  ): TextObjectRange {
 44    if (objectType === 'w')
 45      return findWordObject(text, offset, isInner, isVimWordChar)
 46    if (objectType === 'W')
 47      return findWordObject(text, offset, isInner, ch => !isVimWhitespace(ch))
 48  
 49    const pair = PAIRS[objectType]
 50    if (pair) {
 51      const [open, close] = pair
 52      return open === close
 53        ? findQuoteObject(text, offset, open, isInner)
 54        : findBracketObject(text, offset, open, close, isInner)
 55    }
 56  
 57    return null
 58  }
 59  
 60  function findWordObject(
 61    text: string,
 62    offset: number,
 63    isInner: boolean,
 64    isWordChar: (ch: string) => boolean,
 65  ): TextObjectRange {
 66    // Pre-segment into graphemes for grapheme-safe iteration
 67    const graphemes: Array<{ segment: string; index: number }> = []
 68    for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
 69      graphemes.push({ segment, index })
 70    }
 71  
 72    // Find which grapheme index the offset falls in
 73    let graphemeIdx = graphemes.length - 1
 74    for (let i = 0; i < graphemes.length; i++) {
 75      const g = graphemes[i]!
 76      const nextStart =
 77        i + 1 < graphemes.length ? graphemes[i + 1]!.index : text.length
 78      if (offset >= g.index && offset < nextStart) {
 79        graphemeIdx = i
 80        break
 81      }
 82    }
 83  
 84    const graphemeAt = (idx: number): string => graphemes[idx]?.segment ?? ''
 85    const offsetAt = (idx: number): number =>
 86      idx < graphemes.length ? graphemes[idx]!.index : text.length
 87    const isWs = (idx: number): boolean => isVimWhitespace(graphemeAt(idx))
 88    const isWord = (idx: number): boolean => isWordChar(graphemeAt(idx))
 89    const isPunct = (idx: number): boolean => isVimPunctuation(graphemeAt(idx))
 90  
 91    let startIdx = graphemeIdx
 92    let endIdx = graphemeIdx
 93  
 94    if (isWord(graphemeIdx)) {
 95      while (startIdx > 0 && isWord(startIdx - 1)) startIdx--
 96      while (endIdx < graphemes.length && isWord(endIdx)) endIdx++
 97    } else if (isWs(graphemeIdx)) {
 98      while (startIdx > 0 && isWs(startIdx - 1)) startIdx--
 99      while (endIdx < graphemes.length && isWs(endIdx)) endIdx++
100      return { start: offsetAt(startIdx), end: offsetAt(endIdx) }
101    } else if (isPunct(graphemeIdx)) {
102      while (startIdx > 0 && isPunct(startIdx - 1)) startIdx--
103      while (endIdx < graphemes.length && isPunct(endIdx)) endIdx++
104    }
105  
106    if (!isInner) {
107      // Include surrounding whitespace
108      if (endIdx < graphemes.length && isWs(endIdx)) {
109        while (endIdx < graphemes.length && isWs(endIdx)) endIdx++
110      } else if (startIdx > 0 && isWs(startIdx - 1)) {
111        while (startIdx > 0 && isWs(startIdx - 1)) startIdx--
112      }
113    }
114  
115    return { start: offsetAt(startIdx), end: offsetAt(endIdx) }
116  }
117  
118  function findQuoteObject(
119    text: string,
120    offset: number,
121    quote: string,
122    isInner: boolean,
123  ): TextObjectRange {
124    const lineStart = text.lastIndexOf('\n', offset - 1) + 1
125    const lineEnd = text.indexOf('\n', offset)
126    const effectiveEnd = lineEnd === -1 ? text.length : lineEnd
127    const line = text.slice(lineStart, effectiveEnd)
128    const posInLine = offset - lineStart
129  
130    const positions: number[] = []
131    for (let i = 0; i < line.length; i++) {
132      if (line[i] === quote) positions.push(i)
133    }
134  
135    // Pair quotes correctly: 0-1, 2-3, 4-5, etc.
136    for (let i = 0; i < positions.length - 1; i += 2) {
137      const qs = positions[i]!
138      const qe = positions[i + 1]!
139      if (qs <= posInLine && posInLine <= qe) {
140        return isInner
141          ? { start: lineStart + qs + 1, end: lineStart + qe }
142          : { start: lineStart + qs, end: lineStart + qe + 1 }
143      }
144    }
145  
146    return null
147  }
148  
149  function findBracketObject(
150    text: string,
151    offset: number,
152    open: string,
153    close: string,
154    isInner: boolean,
155  ): TextObjectRange {
156    let depth = 0
157    let start = -1
158  
159    for (let i = offset; i >= 0; i--) {
160      if (text[i] === close && i !== offset) depth++
161      else if (text[i] === open) {
162        if (depth === 0) {
163          start = i
164          break
165        }
166        depth--
167      }
168    }
169    if (start === -1) return null
170  
171    depth = 0
172    let end = -1
173    for (let i = start + 1; i < text.length; i++) {
174      if (text[i] === open) depth++
175      else if (text[i] === close) {
176        if (depth === 0) {
177          end = i
178          break
179        }
180        depth--
181      }
182    }
183    if (end === -1) return null
184  
185    return isInner ? { start: start + 1, end } : { start, end: end + 1 }
186  }