/ src / utils / ripgrep.ts
ripgrep.ts
  1  import { findActualExecutable } from 'spawn-rx'
  2  import { memoize } from 'lodash-es'
  3  import { fileURLToPath, resolve } from 'node:url'
  4  import * as path from 'path'
  5  import { logError } from './log.js'
  6  import { execFileNoThrow } from './execFileNoThrow.js'
  7  import { execFile } from 'child_process'
  8  import debug from 'debug'
  9  
 10  const __filename = fileURLToPath(import.meta.url)
 11  const __dirname = resolve(
 12    __filename,
 13    process.env.NODE_ENV === 'test' ? '../..' : '.',
 14  )
 15  
 16  const d = debug('claude:ripgrep')
 17  
 18  const useBuiltinRipgrep = !!process.env.USE_BUILTIN_RIPGREP
 19  if (useBuiltinRipgrep) {
 20    d('Using builtin ripgrep because USE_BUILTIN_RIPGREP is set')
 21  }
 22  
 23  const ripgrepPath = memoize(() => {
 24    const { cmd } = findActualExecutable('rg', [])
 25    d(`ripgrep initially resolved as: ${cmd}`)
 26  
 27    if (cmd !== 'rg' && !useBuiltinRipgrep) {
 28      // NB: If we're able to find ripgrep in $PATH, cmd will be an absolute
 29      // path rather than just returning 'rg'
 30      return cmd
 31    } else {
 32      // Use the one we ship in-box
 33      const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
 34      if (process.platform === 'win32') {
 35        // NB: Ripgrep doesn't ship an aarch64 binary for Windows, boooooo
 36        return path.resolve(rgRoot, 'x64-win32', 'rg.exe')
 37      }
 38  
 39      const ret = path.resolve(
 40        rgRoot,
 41        `${process.arch}-${process.platform}`,
 42        'rg',
 43      )
 44  
 45      d('internal ripgrep resolved as: %s', ret)
 46      return ret
 47    }
 48  })
 49  
 50  export async function ripGrep(
 51    args: string[],
 52    target: string,
 53    abortSignal: AbortSignal,
 54  ): Promise<string[]> {
 55    await codesignRipgrepIfNecessary()
 56    const rg = ripgrepPath()
 57    d('ripgrep called: %s %o', rg, target, args)
 58  
 59    // NB: When running interactively, ripgrep does not require a path as its last
 60    // argument, but when run non-interactively, it will hang unless a path or file
 61    // pattern is provided
 62    return new Promise(resolve => {
 63      execFile(
 64        ripgrepPath(),
 65        [...args, target],
 66        {
 67          maxBuffer: 1_000_000,
 68          signal: abortSignal,
 69          timeout: 10_000,
 70        },
 71        (error, stdout) => {
 72          if (error) {
 73            // Exit code 1 from ripgrep means "no matches found" - this is normal
 74            if (error.code !== 1) {
 75              d('ripgrep error: %o', error)
 76              logError(error)
 77            }
 78            resolve([])
 79          } else {
 80            d('ripgrep succeeded with %s', stdout)
 81            resolve(stdout.trim().split('\n').filter(Boolean))
 82          }
 83        },
 84      )
 85    })
 86  }
 87  
 88  // NB: We do something tricky here. We know that ripgrep processes common
 89  // ignore files for us, so we just ripgrep for any character, which matches
 90  // all non-empty files
 91  export async function listAllContentFiles(
 92    path: string,
 93    abortSignal: AbortSignal,
 94    limit: number,
 95  ): Promise<string[]> {
 96    try {
 97      d('listAllContentFiles called: %s', path)
 98      return (await ripGrep(['-l', '.', path], path, abortSignal)).slice(0, limit)
 99    } catch (e) {
100      d('listAllContentFiles failed: %o', e)
101  
102      logError(e)
103      return []
104    }
105  }
106  
107  let alreadyDoneSignCheck = false
108  async function codesignRipgrepIfNecessary() {
109    if (process.platform !== 'darwin' || alreadyDoneSignCheck) {
110      return
111    }
112  
113    alreadyDoneSignCheck = true
114  
115    // First, check to see if ripgrep is already signed
116    d('checking if ripgrep is already signed')
117    const lines = (
118      await execFileNoThrow(
119        'codesign',
120        ['-vv', '-d', ripgrepPath()],
121        undefined,
122        undefined,
123        false,
124      )
125    ).stdout.split('\n')
126  
127    const needsSigned = lines.find(line => line.includes('linker-signed'))
128    if (!needsSigned) {
129      d('seems to be already signed')
130      return
131    }
132  
133    try {
134      d('signing ripgrep')
135      const signResult = await execFileNoThrow('codesign', [
136        '--sign',
137        '-',
138        '--force',
139        '--preserve-metadata=entitlements,requirements,flags,runtime',
140        ripgrepPath(),
141      ])
142  
143      if (signResult.code !== 0) {
144        d('failed to sign ripgrep: %o', signResult)
145        logError(
146          `Failed to sign ripgrep: ${signResult.stdout} ${signResult.stderr}`,
147        )
148      }
149  
150      d('removing quarantine')
151      const quarantineResult = await execFileNoThrow('xattr', [
152        '-d',
153        'com.apple.quarantine',
154        ripgrepPath(),
155      ])
156  
157      if (quarantineResult.code !== 0) {
158        d('failed to remove quarantine: %o', quarantineResult)
159        logError(
160          `Failed to remove quarantine: ${quarantineResult.stdout} ${quarantineResult.stderr}`,
161        )
162      }
163    } catch (e) {
164      d('failed during sign: %o', e)
165      logError(e)
166    }
167  }