search.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { chmod, mkdir, mkdtemp, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. activeAtToken,
  7. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  8. formatFileMention,
  9. WorkspaceFileSearch,
  10. } from '../src/search.ts'
  11. const fsControl = vi.hoisted(() => ({
  12. /** Absolute path whose `readdir` rejects; the injectable stand-in for chmod 0. */
  13. denyReaddir: undefined as string | undefined,
  14. }))
  15. vi.mock('node:fs/promises', async (importOriginal) => {
  16. const actual = await importOriginal<typeof import('node:fs/promises')>()
  17. return {
  18. ...actual,
  19. readdir: vi.fn((async (path: unknown, ...rest: never[]) => {
  20. if (fsControl.denyReaddir !== undefined && String(path) === fsControl.denyReaddir) {
  21. throw Object.assign(new Error('EACCES: injected unreadable directory'), { code: 'EACCES' })
  22. }
  23. return (actual.readdir as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
  24. }) as typeof actual.readdir),
  25. }
  26. })
  27. const searches: WorkspaceFileSearch[] = []
  28. const roots: string[] = []
  29. /** Permission-stripped directories; restored before cleanup can remove them. */
  30. const locks: string[] = []
  31. async function workspace(): Promise<string> {
  32. const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
  33. roots.push(root)
  34. await mkdir(join(root, 'src'), { recursive: true })
  35. await mkdir(join(root, 'docs'), { recursive: true })
  36. await mkdir(join(root, '.hidden'), { recursive: true })
  37. await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true })
  38. await writeFile(join(root, 'README.md'), 'readme')
  39. await writeFile(join(root, 'src', 'tui.spec.ts'), 'test')
  40. await writeFile(join(root, 'src', 'terminal-view.ts'), 'view')
  41. await writeFile(join(root, 'docs', 'design notes.md'), 'design')
  42. await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden')
  43. await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored')
  44. try {
  45. await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts'))
  46. } catch {
  47. // Windows may deny symlink creation without Developer Mode; the product
  48. // still skips every non-file/non-directory Dirent on platforms that expose one.
  49. }
  50. return root
  51. }
  52. function search(root: string, overrides: Partial<ConstructorParameters<typeof WorkspaceFileSearch>[1]> = {}): WorkspaceFileSearch {
  53. const instance = new WorkspaceFileSearch(root, {
  54. maxResults: overrides.maxResults ?? 20,
  55. maxEntries: overrides.maxEntries ?? 10_000,
  56. excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'],
  57. })
  58. searches.push(instance)
  59. return instance
  60. }
  61. afterEach(async () => {
  62. for (const locked of locks.splice(0)) await chmod(locked, 0o700).catch(() => undefined)
  63. for (const instance of searches.splice(0)) instance.dispose()
  64. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  65. vi.mocked(readdir).mockClear()
  66. })
  67. describe('file-reference grammar', () => {
  68. it('recognizes boundary and quoted mentions without treating emails as references', () => {
  69. expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
  70. expect(activeAtToken('read @"docs/design n', 20)).toEqual({
  71. prefix: '@"docs/design n',
  72. query: 'docs/design n',
  73. quoted: true,
  74. })
  75. expect(activeAtToken('mail a@b.test', 13)).toBeUndefined()
  76. expect(activeAtToken('done @src/x" next', 17)).toBeUndefined()
  77. })
  78. it('formats files, directories, quotes, and rejects unsafe editor values', () => {
  79. expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts')
  80. expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
  81. expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
  82. .toBe('@"docs/design notes.md"')
  83. expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false))
  84. .toBe('@"docs/design notes/')
  85. expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
  86. expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
  87. expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()
  88. expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined()
  89. })
  90. })
  91. describe('WorkspaceFileSearch', () => {
  92. it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => {
  93. const root = await workspace()
  94. const files = search(root)
  95. const signal = new AbortController().signal
  96. expect(await files.list('', signal)).toEqual([
  97. { path: 'docs', kind: 'directory' },
  98. { path: 'src', kind: 'directory' },
  99. { path: 'README.md', kind: 'file' },
  100. ])
  101. expect(await files.list('src/', signal)).toEqual([
  102. { path: 'src/terminal-view.ts', kind: 'file' },
  103. { path: 'src/tui.spec.ts', kind: 'file' },
  104. ])
  105. expect(await files.list('src/ts', signal)).toEqual([
  106. { path: 'src/tui.spec.ts', kind: 'file' },
  107. { path: 'src/terminal-view.ts', kind: 'file' },
  108. ])
  109. expect(await files.list('docs/design n', signal)).toEqual([
  110. { path: 'docs/design notes.md', kind: 'file' },
  111. ])
  112. expect(await files.list('node_modules/', signal)).toEqual([])
  113. expect(await files.list('.hidden/', signal)).toEqual([
  114. { path: '.hidden/secret.txt', kind: 'file' },
  115. ])
  116. const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/`
  117. expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([
  118. { path: `${absoluteSrc}tui.spec.ts`, kind: 'file' },
  119. { path: `${absoluteSrc}terminal-view.ts`, kind: 'file' },
  120. ])
  121. expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
  122. expect(await files.list('../', signal)).toEqual([])
  123. expect(await files.list('README.md/', signal)).toEqual([])
  124. })
  125. it('does not traverse directory symlinks during direct completion', async () => {
  126. const root = await workspace()
  127. const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
  128. roots.push(outside)
  129. await writeFile(join(outside, 'outside-secret.txt'), 'secret')
  130. await symlink(
  131. outside,
  132. join(root, 'escape'),
  133. process.platform === 'win32' ? 'junction' : 'dir',
  134. )
  135. const files = search(root)
  136. const signal = new AbortController().signal
  137. expect(await files.list('escape/', signal)).toEqual([])
  138. expect(await files.list('escape/outside', signal)).toEqual([])
  139. })
  140. it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
  141. const root = await workspace()
  142. await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper')
  143. const files = search(root, { maxResults: 2 })
  144. const signal = new AbortController().signal
  145. expect(await files.list('tspc', signal)).toEqual([
  146. { path: 'src/tspc-helper.ts', kind: 'file' },
  147. { path: 'src/tui.spec.ts', kind: 'file' },
  148. ])
  149. expect(await files.list('README.md', signal)).toEqual([
  150. { path: 'README.md', kind: 'file' },
  151. ])
  152. expect(await files.list('terminal', signal)).toEqual([
  153. { path: 'src/terminal-view.ts', kind: 'file' },
  154. ])
  155. expect(await files.list('secret', signal)).toEqual([])
  156. expect(await files.list('.hidden', signal)).toEqual([
  157. { path: '.hidden', kind: 'directory' },
  158. { path: '.hidden/secret.txt', kind: 'file' },
  159. ])
  160. })
  161. it('serves an invalidated index while its replacement builds, then swaps it in', async () => {
  162. const root = await workspace()
  163. const files = search(root)
  164. const signal = new AbortController().signal
  165. expect(await files.list('fresh-file', signal)).toEqual([])
  166. await writeFile(join(root, 'fresh-file.ts'), 'fresh')
  167. // No invalidation: the settled traversal is still the answer.
  168. expect(await files.list('fresh-file', signal)).toEqual([])
  169. files.invalidate()
  170. // The stale entries answer this query; the rebuild runs behind the caret.
  171. expect(await files.list('fresh-file', signal)).toEqual([])
  172. await vi.waitFor(async () => {
  173. expect(await files.list('fresh-file', signal)).toEqual([
  174. { path: 'fresh-file.ts', kind: 'file' },
  175. ])
  176. })
  177. files.dispose()
  178. expect(await files.list('fresh-file', signal)).toEqual([])
  179. files.dispose()
  180. })
  181. it('keeps the stale entries when the workspace root is unreadable, and retries once it returns', async () => {
  182. const root = await workspace()
  183. const files = search(root)
  184. const signal = new AbortController().signal
  185. expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
  186. const rootReads = () => vi.mocked(readdir).mock.calls.filter(([path]) => String(path) === root).length
  187. const initialReads = rootReads()
  188. // A root that vanishes under a live index: an unreadable branch costs its
  189. // own candidates, but an unreadable root must not be published as an
  190. // empty workspace over entries that are still good.
  191. await rm(root, { recursive: true, force: true })
  192. files.invalidate()
  193. expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
  194. await vi.waitFor(async () => {
  195. expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
  196. expect(rootReads()).toBeGreaterThan(initialReads + 1)
  197. })
  198. // A retry can read the root as soon as it exists; publish its files together.
  199. const replacement = await mkdtemp(`${root}-replacement-`)
  200. roots.push(replacement)
  201. await writeFile(join(replacement, 'restored.ts'), 'restored')
  202. await rename(replacement, root)
  203. await vi.waitFor(async () => {
  204. expect(await files.list('restored', signal)).toEqual([{ path: 'restored.ts', kind: 'file' }])
  205. })
  206. })
  207. // chmod 0 can only deny directory reads on POSIX to a non-root owner:
  208. // Windows exposes no directory permission bits for readdir, and root
  209. // bypasses them. Where the fixture stays readable the sealed candidate is
  210. // indexed, so the unreadable-branch behavior is pinned on POSIX non-root.
  211. it.runIf(
  212. process.getuid !== undefined && process.getuid() !== 0,
  213. )('lets an unreadable subtree cost only its own candidates', async () => {
  214. const root = await workspace()
  215. const locked = join(root, 'locked')
  216. await mkdir(locked, { recursive: true })
  217. await writeFile(join(locked, 'sealed.ts'), 'sealed')
  218. await chmod(locked, 0o000)
  219. locks.push(locked)
  220. const files = search(root)
  221. const signal = new AbortController().signal
  222. // The branch itself yields nothing, and the rest of the tree still does.
  223. expect(await files.list('sealed', signal)).toEqual([])
  224. expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
  225. // The directory is still offered: only reading through it fails.
  226. expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }])
  227. })
  228. // The chmod-0 fixture above cannot be built on Windows (no directory
  229. // permission bits) or as root (bits are bypassed). An injected readdir
  230. // failure keeps the unreadable-branch behavior covered on every platform.
  231. it('lets an injected readdir failure cost only its own candidates', async () => {
  232. const root = await workspace()
  233. const locked = join(root, 'locked')
  234. await mkdir(locked, { recursive: true })
  235. await writeFile(join(locked, 'sealed.ts'), 'sealed')
  236. fsControl.denyReaddir = locked
  237. try {
  238. const files = search(root)
  239. const signal = new AbortController().signal
  240. // The branch itself yields nothing, and the rest of the tree still does.
  241. expect(await files.list('sealed', signal)).toEqual([])
  242. expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
  243. // The directory is still offered: only reading through it fails.
  244. expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }])
  245. } finally {
  246. fsControl.denyReaddir = undefined
  247. }
  248. })
  249. it('enforces the entry cap', async () => {
  250. const root = await workspace()
  251. const capped = search(root, { maxEntries: 2 })
  252. expect(await capped.list('README', new AbortController().signal)).toEqual([
  253. { path: 'README.md', kind: 'file' },
  254. ])
  255. })
  256. it('never traverses an excluded build output, so generated twins cannot outrank sources', async () => {
  257. const root = await workspace()
  258. await mkdir(join(root, 'dist'), { recursive: true })
  259. await writeFile(join(root, 'dist', 'terminal-view.js'), 'built')
  260. const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
  261. expect(await files.list('terminal-view', new AbortController().signal)).toEqual([
  262. { path: 'src/terminal-view.ts', kind: 'file' },
  263. ])
  264. expect(await files.list('dist/', new AbortController().signal)).toEqual([])
  265. })
  266. it('still offers a `lib` tree, where several ecosystems keep their sources', async () => {
  267. const root = await workspace()
  268. await mkdir(join(root, 'lib'), { recursive: true })
  269. await writeFile(join(root, 'lib', 'gem-entry.rb'), 'source')
  270. const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
  271. expect(await files.list('gem-entry', new AbortController().signal)).toEqual([
  272. { path: 'lib/gem-entry.rb', kind: 'file' },
  273. ])
  274. })
  275. it('cancels individual callers, skips missing directories, and validates limits', async () => {
  276. const root = await workspace()
  277. expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
  278. expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries')
  279. expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames')
  280. const files = search(root)
  281. expect(await files.list('missing/', new AbortController().signal)).toEqual([])
  282. const preAborted = new AbortController()
  283. preAborted.abort(new Error('pre-aborted'))
  284. await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted')
  285. files.invalidate()
  286. const running = new AbortController()
  287. const pending = files.list('tui', running.signal)
  288. running.abort(new Error('superseded'))
  289. await expect(pending).rejects.toThrow('superseded')
  290. files.invalidate()
  291. const nonErrorAbort = new AbortController()
  292. const nonErrorPending = files.list('tui', nonErrorAbort.signal)
  293. nonErrorAbort.abort('cancelled')
  294. await expect(nonErrorPending).rejects.toThrow('file search aborted')
  295. })
  296. })