location-filesystem.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { fileURLToPath } from "url"
  4. import { describe, expect } from "bun:test"
  5. import { Effect, Exit, Layer } from "effect"
  6. import { FSUtil } from "@opencode-ai/core/fs-util"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { FileSystem } from "@opencode-ai/core/filesystem"
  9. import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
  10. import { ProjectReference } from "@opencode-ai/core/project-reference"
  11. import { Repository } from "@opencode-ai/core/repository"
  12. import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { location } from "./fixture/location"
  15. import { it } from "./lib/effect"
  16. const inertReferences = ProjectReference.Service.of({
  17. list: () => Effect.succeed([]),
  18. get: () => Effect.succeed(undefined),
  19. resolveMention: () => Effect.succeed(undefined),
  20. ensurePath: () => Effect.void,
  21. containsManagedPath: () => Effect.succeed(false),
  22. })
  23. function provide(directory: string, references = inertReferences) {
  24. return Effect.provide(
  25. FileSystem.layer.pipe(
  26. Layer.provide(
  27. Layer.mergeAll(
  28. FSUtil.defaultLayer,
  29. Ripgrep.defaultLayer,
  30. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  31. Layer.succeed(ProjectReference.Service, references),
  32. ),
  33. ),
  34. ),
  35. )
  36. }
  37. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  38. return Effect.acquireRelease(
  39. Effect.promise(() => tmpdir()),
  40. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  41. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  42. }
  43. describe("FileSystem", () => {
  44. it.live("reads text and binary files", () =>
  45. withTmp((directory) =>
  46. Effect.gen(function* () {
  47. yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
  48. yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
  49. const service = yield* FileSystem.Service
  50. expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
  51. type: "text",
  52. content: "hello",
  53. mime: "text/plain",
  54. })
  55. expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
  56. type: "binary",
  57. content: "AAEC",
  58. encoding: "base64",
  59. mime: "application/octet-stream",
  60. })
  61. }).pipe(provide(directory)),
  62. ),
  63. )
  64. it.live("lists direct children with relative paths and resolved URIs", () =>
  65. withTmp((directory) =>
  66. Effect.gen(function* () {
  67. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  68. yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
  69. const service = yield* FileSystem.Service
  70. const entries = yield* service.list()
  71. expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
  72. {
  73. path: RelativePath.make("src"),
  74. type: "directory",
  75. mime: "application/x-directory",
  76. },
  77. {
  78. path: RelativePath.make("README.md"),
  79. type: "file",
  80. mime: "text/markdown",
  81. },
  82. ])
  83. expect(
  84. yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
  85. ).toEqual(
  86. yield* Effect.promise(() =>
  87. Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
  88. ),
  89. )
  90. }).pipe(provide(directory)),
  91. ),
  92. )
  93. it.live("rejects paths outside the location", () =>
  94. withTmp((directory) =>
  95. Effect.gen(function* () {
  96. const service = yield* FileSystem.Service
  97. expect(
  98. Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
  99. ).toBe(true)
  100. }).pipe(provide(directory)),
  101. ),
  102. )
  103. it.live("finds files and directories", () =>
  104. withTmp((directory) =>
  105. Effect.gen(function* () {
  106. yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
  107. yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
  108. const service = yield* FileSystem.Service
  109. expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
  110. RelativePath.make(path.join("src", "index.ts")),
  111. ])
  112. expect((yield* service.find({ query: "src", type: "directory" })).map((item) => item.path)).toEqual([
  113. RelativePath.make("src"),
  114. ])
  115. }).pipe(provide(directory)),
  116. ),
  117. )
  118. it.live("greps file contents", () =>
  119. withTmp((directory) =>
  120. Effect.gen(function* () {
  121. yield* Effect.promise(() => fs.writeFile(path.join(directory, "index.ts"), "const needle = true\n"))
  122. const service = yield* FileSystem.Service
  123. expect(yield* service.grep({ pattern: "needle" })).toEqual([
  124. {
  125. path: RelativePath.make("index.ts"),
  126. lines: "const needle = true\n",
  127. line: 1,
  128. offset: 0,
  129. submatches: [{ text: "needle", start: 6, end: 12 }],
  130. },
  131. ])
  132. }).pipe(provide(directory)),
  133. ),
  134. )
  135. it.live("reads and lists paths relative to a local project reference", () =>
  136. withTmp((directory) => {
  137. const docs = path.join(directory, "docs")
  138. return Effect.gen(function* () {
  139. yield* Effect.promise(async () => {
  140. await fs.mkdir(docs)
  141. await fs.writeFile(path.join(docs, "README.md"), "docs")
  142. })
  143. const service = yield* FileSystem.Service
  144. expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
  145. type: "text",
  146. content: "docs",
  147. })
  148. expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
  149. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  150. }),
  151. )
  152. it.live("materializes Git references before filesystem access", () =>
  153. withTmp((directory) => {
  154. const docs = path.join(directory, "docs")
  155. const ensured: string[] = []
  156. return Effect.gen(function* () {
  157. yield* Effect.promise(async () => {
  158. await fs.mkdir(docs)
  159. await fs.writeFile(path.join(docs, "README.md"), "docs")
  160. })
  161. expect(
  162. yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
  163. ).toMatchObject({ content: "docs" })
  164. expect(ensured).toEqual([docs])
  165. }).pipe(
  166. provide(
  167. directory,
  168. references(
  169. {
  170. sdk: {
  171. name: "sdk",
  172. kind: "git",
  173. repository: "owner/repo",
  174. reference: Repository.parseRemote("owner/repo"),
  175. path: docs,
  176. },
  177. },
  178. (target) => Effect.sync(() => ensured.push(target ?? "")),
  179. ),
  180. ),
  181. )
  182. }),
  183. )
  184. it.live("rejects unknown, invalid, and escaping project reference paths", () =>
  185. withTmp((directory) => {
  186. const docs = path.join(directory, "docs")
  187. return Effect.gen(function* () {
  188. yield* Effect.promise(() => fs.mkdir(docs))
  189. const service = yield* FileSystem.Service
  190. expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
  191. expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
  192. expect(
  193. Exit.isFailure(
  194. yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
  195. ),
  196. ).toBe(true)
  197. }).pipe(
  198. provide(
  199. directory,
  200. references({
  201. docs: { name: "docs", kind: "local", path: docs },
  202. invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
  203. }),
  204. ),
  205. )
  206. }),
  207. )
  208. it.live("rejects aliases when project references are disabled", () =>
  209. withTmp((directory) =>
  210. Effect.gen(function* () {
  211. expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
  212. true,
  213. )
  214. }).pipe(provide(directory)),
  215. ),
  216. )
  217. it.live("rejects symlink escapes from project references", () =>
  218. withTmp((directory) => {
  219. const docs = path.join(directory, "docs")
  220. const outside = path.join(directory, "outside.txt")
  221. return Effect.gen(function* () {
  222. if (process.platform === "win32") return
  223. yield* Effect.promise(async () => {
  224. await fs.mkdir(docs)
  225. await fs.writeFile(outside, "outside")
  226. await fs.symlink(outside, path.join(docs, "link.txt"))
  227. })
  228. expect(
  229. Exit.isFailure(
  230. yield* (yield* FileSystem.Service)
  231. .read({ reference: "docs", path: RelativePath.make("link.txt") })
  232. .pipe(Effect.exit),
  233. ),
  234. ).toBe(true)
  235. }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
  236. }),
  237. )
  238. })
  239. function references(
  240. entries: Record<string, ProjectReference.Resolved>,
  241. ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
  242. ) {
  243. return ProjectReference.Service.of({
  244. list: () => Effect.succeed(Object.values(entries)),
  245. get: (name) => Effect.succeed(entries[name]),
  246. resolveMention: () => Effect.succeed(undefined),
  247. ensurePath,
  248. containsManagedPath: () => Effect.succeed(false),
  249. })
  250. }