1import {
2 FileFinder,
3 type FileItem,
4 type GrepCursor,
5 type GrepOptions,
6 type GrepResult,
7} from "@ff-labs/fff-node";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { afterEach, expect, it } from "@effect/vitest";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import { vi } from "vite-plus/test";
16
17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
19
20const testLayer = VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer));
21
22afterEach(() => {
23 vi.restoreAllMocks();
24});
25
26function fileItem(relativePath: string): FileItem {
27 return {
28 relativePath,
29 fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1),
30 size: 1,
31 modified: 0,
32 accessFrecencyScore: 0,
33 modificationFrecencyScore: 0,
34 totalFrecencyScore: 0,
35 gitStatus: "clean",
36 };
37}
38
39it.effect("filters image searches before applying the result limit", () =>
40 Effect.scoped(
41 Effect.gen(function* () {
42 const items = [
43 ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)),
44 fileItem("public/icon.svg"),
45 ];
46 const fileSearch = vi.fn(() => ({
47 ok: true as const,
48 value: {
49 items,
50 scores: [],
51 totalMatched: items.length,
52 totalFiles: items.length,
53 },
54 }));
55 const finder = {
56 destroy: vi.fn(),
57 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
58 fileSearch,
59 } as unknown as FileFinder;
60 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
61
62 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
63 const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true);
64 const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true);
65
66 expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
67 expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
68 expect(fileSearch).toHaveBeenCalledTimes(2);
69 expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 });
70 }),
71 ).pipe(Effect.provide(testLayer)),
72);
73
74it.effect("fetches past a first page of Git-ignored results", () =>
75 Effect.scoped(
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-search-ignored-" });
80 const vcsProcess = yield* VcsProcess.VcsProcess;
81 yield* vcsProcess.run({ operation: "test.gitInit", command: "git", args: ["init"], cwd });
82 yield* fs.writeFileString(path.join(cwd, ".git", "info", "exclude"), "hidden/\n");
83 yield* fs.makeDirectory(path.join(cwd, "hidden"));
84 yield* fs.writeFileString(path.join(cwd, "hidden", "a.ts"), "");
85
86 const items = [
87 fileItem("hidden/a.ts"),
88 fileItem("hidden/b.ts"),
89 fileItem("hidden/c.ts"),
90 fileItem("src/a.ts"),
91 ];
92 const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({
93 ok: true as const,
94 value: {
95 items: items.slice(0, options?.pageSize),
96 scores: [],
97 totalMatched: items.length,
98 totalFiles: items.length,
99 },
100 }));
101 const finder = {
102 destroy: vi.fn(),
103 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
104 fileSearch,
105 } as unknown as FileFinder;
106 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
107
108 const searchIndex = yield* WorkspaceSearchIndex.make(cwd);
109 const result = yield* searchIndex.search("a", 1, "file", false);
110
111 expect(result).toEqual({ entries: [{ kind: "file", path: "src/a.ts" }], truncated: false });
112 }),
113 ).pipe(Effect.provide(testLayer)),
114);
115
116// A backslash is a file name character on Linux and macOS, so only Windows may treat it as a separator.
117it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
118 "keeps a backslash in a POSIX folder name as part of the search result path",
119 () =>
120 Effect.scoped(
121 Effect.gen(function* () {
122 const items = [fileItem("dir\\one/report.txt")];
123 const finder = {
124 destroy: vi.fn(),
125 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
126 fileSearch: vi.fn(() => ({
127 ok: true as const,
128 value: { items, scores: [], totalMatched: 1, totalFiles: 1 },
129 })),
130 } as unknown as FileFinder;
131 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
132
133 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
134 const result = yield* searchIndex.search("report", 50, "file");
135
136 expect(result.entries).toEqual([{ kind: "file", path: "dir\\one/report.txt" }]);
137 }),
138 ).pipe(Effect.provide(testLayer)),
139);
140
141it.effect("continues whole-word searches after a filtered grep page", () =>
142 Effect.scoped(
143 Effect.gen(function* () {
144 const nextCursor = {
145 __brand: "GrepCursor",
146 _offset: 1,
147 } as GrepCursor;
148 const grepResult = (
149 lineContent: string,
150 matchRanges: Array<[number, number]>,
151 cursor: GrepCursor | null,
152 ): GrepResult => ({
153 items: [
154 {
155 relativePath: "src/words.ts",
156 fileName: "words.ts",
157 gitStatus: "unmodified",
158 size: lineContent.length,
159 modified: 0,
160 isBinary: false,
161 totalFrecencyScore: 0,
162 accessFrecencyScore: 0,
163 modificationFrecencyScore: 0,
164 lineNumber: 1,
165 col: 0,
166 byteOffset: 0,
167 lineContent,
168 matchRanges,
169 },
170 ],
171 totalMatched: 1,
172 totalFilesSearched: 1,
173 totalFiles: 1,
174 filteredFileCount: 1,
175 nextCursor: cursor,
176 });
177 const grep = vi.fn((_query: string, options?: GrepOptions) =>
178 options?.cursor
179 ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) }
180 : {
181 ok: true as const,
182 value: grepResult("needleSuffix", [[0, 6]], nextCursor),
183 },
184 );
185 const finder = {
186 destroy: vi.fn(),
187 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
188 grep,
189 } as unknown as FileFinder;
190 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
191
192 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content");
193 const result = yield* searchIndex.searchContents({
194 query: "needle",
195 limit: 1,
196 caseSensitive: true,
197 wholeWord: true,
198 useRegex: false,
199 });
200
201 expect(result).toEqual({
202 matches: [
203 {
204 path: "src/words.ts",
205 lineNumber: 1,
206 lineContent: "needle",
207 matchRanges: [{ start: 0, end: 6 }],
208 },
209 ],
210 truncated: false,
211 });
212 expect(grep).toHaveBeenCalledTimes(2);
213 expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor);
214 }),
215 ).pipe(Effect.provide(testLayer)),
216);
217
218// fff reads HOME on Linux and macOS but the user profile on Windows, so a temp HOME cannot stand in there.
219it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
220 "refuses to index a workspace rooted at the home directory",
221 () =>
222 Effect.scoped(
223 Effect.gen(function* () {
224 const fs = yield* FileSystem.FileSystem;
225 const path = yield* Path.Path;
226 const home = yield* fs.realPath(
227 yield* fs.makeTempDirectoryScoped({ prefix: "t3-search-home-" }),
228 );
229 yield* fs.makeDirectory(path.join(home, "OneDrive", "Remix"), { recursive: true });
230 yield* fs.writeFileString(path.join(home, "OneDrive", "Remix", "part.stl"), "solid part\n");
231 yield* Effect.acquireRelease(
232 Effect.sync(() => vi.stubEnv("HOME", home)),
233 () => Effect.sync(() => vi.unstubAllEnvs()),
234 );
235
236 const result = yield* WorkspaceSearchIndex.make(home).pipe(
237 Effect.flatMap((searchIndex) => searchIndex.search("stl", 50, undefined, false)),
238 Effect.map(({ entries }) => entries),
239 Effect.catchTags({
240 WorkspaceSearchIndexCreateFailed: (error) => Effect.succeed(error._tag),
241 }),
242 );
243
244 expect(result).toBe("WorkspaceSearchIndexCreateFailed");
245 }),
246 ).pipe(Effect.provide(testLayer)),
247);