search.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { mkdir, mkdtemp, 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 } from 'vitest'
  5. import {
  6. activeAtToken,
  7. formatFileMention,
  8. WorkspaceFileSearch,
  9. } from '../src/search.ts'
  10. const searches: WorkspaceFileSearch[] = []
  11. const roots: string[] = []
  12. async function workspace(): Promise<string> {
  13. const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
  14. roots.push(root)
  15. await mkdir(join(root, 'src'), { recursive: true })
  16. await mkdir(join(root, 'docs'), { recursive: true })
  17. await mkdir(join(root, '.hidden'), { recursive: true })
  18. await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true })
  19. await writeFile(join(root, 'README.md'), 'readme')
  20. await writeFile(join(root, 'src', 'tui.spec.ts'), 'test')
  21. await writeFile(join(root, 'src', 'terminal-view.ts'), 'view')
  22. await writeFile(join(root, 'docs', 'design notes.md'), 'design')
  23. await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden')
  24. await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored')
  25. try {
  26. await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts'))
  27. } catch {
  28. // Windows may deny symlink creation without Developer Mode; the product
  29. // still skips every non-file/non-directory Dirent on platforms that expose one.
  30. }
  31. return root
  32. }
  33. function search(root: string, overrides: Partial<ConstructorParameters<typeof WorkspaceFileSearch>[1]> = {}): WorkspaceFileSearch {
  34. const instance = new WorkspaceFileSearch(root, {
  35. maxResults: overrides.maxResults ?? 20,
  36. maxEntries: overrides.maxEntries ?? 10_000,
  37. excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'],
  38. })
  39. searches.push(instance)
  40. return instance
  41. }
  42. afterEach(async () => {
  43. for (const instance of searches.splice(0)) instance.dispose()
  44. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  45. })
  46. describe('file-reference grammar', () => {
  47. it('recognizes boundary and quoted mentions without treating emails as references', () => {
  48. expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
  49. expect(activeAtToken('read @"docs/design n', 20)).toEqual({
  50. prefix: '@"docs/design n',
  51. query: 'docs/design n',
  52. quoted: true,
  53. })
  54. expect(activeAtToken('mail a@b.test', 13)).toBeUndefined()
  55. expect(activeAtToken('done @src/x" next', 17)).toBeUndefined()
  56. })
  57. it('formats files, directories, quotes, and rejects unsafe editor values', () => {
  58. expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts')
  59. expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
  60. expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
  61. .toBe('@"docs/design notes.md"')
  62. expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false))
  63. .toBe('@"docs/design notes/')
  64. expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
  65. expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
  66. expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()
  67. expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined()
  68. })
  69. })
  70. describe('WorkspaceFileSearch', () => {
  71. it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => {
  72. const root = await workspace()
  73. const files = search(root)
  74. const signal = new AbortController().signal
  75. expect(await files.list('', signal)).toEqual([
  76. { path: 'docs', kind: 'directory' },
  77. { path: 'src', kind: 'directory' },
  78. { path: 'README.md', kind: 'file' },
  79. ])
  80. expect(await files.list('src/', signal)).toEqual([
  81. { path: 'src/terminal-view.ts', kind: 'file' },
  82. { path: 'src/tui.spec.ts', kind: 'file' },
  83. ])
  84. expect(await files.list('src/ts', signal)).toEqual([
  85. { path: 'src/tui.spec.ts', kind: 'file' },
  86. { path: 'src/terminal-view.ts', kind: 'file' },
  87. ])
  88. expect(await files.list('docs/design n', signal)).toEqual([
  89. { path: 'docs/design notes.md', kind: 'file' },
  90. ])
  91. expect(await files.list('node_modules/', signal)).toEqual([])
  92. expect(await files.list('.hidden/', signal)).toEqual([
  93. { path: '.hidden/secret.txt', kind: 'file' },
  94. ])
  95. const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/`
  96. expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([
  97. { path: `${absoluteSrc}tui.spec.ts`, kind: 'file' },
  98. { path: `${absoluteSrc}terminal-view.ts`, kind: 'file' },
  99. ])
  100. expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
  101. expect(await files.list('../', signal)).toEqual([])
  102. expect(await files.list('README.md/', signal)).toEqual([])
  103. })
  104. it('does not traverse directory symlinks during direct completion', async () => {
  105. const root = await workspace()
  106. const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
  107. roots.push(outside)
  108. await writeFile(join(outside, 'outside-secret.txt'), 'secret')
  109. await symlink(
  110. outside,
  111. join(root, 'escape'),
  112. process.platform === 'win32' ? 'junction' : 'dir',
  113. )
  114. const files = search(root)
  115. const signal = new AbortController().signal
  116. expect(await files.list('escape/', signal)).toEqual([])
  117. expect(await files.list('escape/outside', signal)).toEqual([])
  118. })
  119. it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
  120. const root = await workspace()
  121. await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper')
  122. const files = search(root, { maxResults: 2 })
  123. const signal = new AbortController().signal
  124. expect(await files.list('tspc', signal)).toEqual([
  125. { path: 'src/tspc-helper.ts', kind: 'file' },
  126. { path: 'src/tui.spec.ts', kind: 'file' },
  127. ])
  128. expect(await files.list('README.md', signal)).toEqual([
  129. { path: 'README.md', kind: 'file' },
  130. ])
  131. expect(await files.list('terminal', signal)).toEqual([
  132. { path: 'src/terminal-view.ts', kind: 'file' },
  133. ])
  134. expect(await files.list('secret', signal)).toEqual([])
  135. expect(await files.list('.hidden', signal)).toEqual([
  136. { path: '.hidden', kind: 'directory' },
  137. { path: '.hidden/secret.txt', kind: 'file' },
  138. ])
  139. })
  140. it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => {
  141. const root = await workspace()
  142. const capped = search(root, { maxEntries: 2 })
  143. const signal = new AbortController().signal
  144. expect(await capped.list('README', signal)).toEqual([
  145. { path: 'README.md', kind: 'file' },
  146. ])
  147. const files = search(root)
  148. expect(await files.list('fresh-file', signal)).toEqual([])
  149. await writeFile(join(root, 'fresh-file.ts'), 'fresh')
  150. expect(await files.list('fresh-file', signal)).toEqual([])
  151. files.invalidate()
  152. expect(await files.list('fresh-file', signal)).toEqual([
  153. { path: 'fresh-file.ts', kind: 'file' },
  154. ])
  155. files.dispose()
  156. expect(await files.list('fresh-file', signal)).toEqual([])
  157. files.dispose()
  158. })
  159. it('cancels individual callers, skips missing directories, and validates limits', async () => {
  160. const root = await workspace()
  161. expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
  162. expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries')
  163. expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames')
  164. const files = search(root)
  165. expect(await files.list('missing/', new AbortController().signal)).toEqual([])
  166. const preAborted = new AbortController()
  167. preAborted.abort(new Error('pre-aborted'))
  168. await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted')
  169. files.invalidate()
  170. const running = new AbortController()
  171. const pending = files.list('tui', running.signal)
  172. running.abort(new Error('superseded'))
  173. await expect(pending).rejects.toThrow('superseded')
  174. files.invalidate()
  175. const nonErrorAbort = new AbortController()
  176. const nonErrorPending = files.list('tui', nonErrorAbort.signal)
  177. nonErrorAbort.abort('cancelled')
  178. await expect(nonErrorPending).rejects.toThrow('file search aborted')
  179. })
  180. })