filesystem.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { dirname, join, relative, resolve as pathResolve } from "path"
  3. import { realpathSync } from "fs"
  4. import * as NFS from "fs/promises"
  5. import { lookup } from "mime-types"
  6. import { Effect, FileSystem, Layer, Schema, Context } from "effect"
  7. import type { PlatformError } from "effect/PlatformError"
  8. import { Glob } from "./util/glob"
  9. export namespace AppFileSystem {
  10. export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
  11. method: Schema.String,
  12. cause: Schema.optional(Schema.Defect),
  13. }) {}
  14. export type Error = PlatformError | FileSystemError
  15. export interface DirEntry {
  16. readonly name: string
  17. readonly type: "file" | "directory" | "symlink" | "other"
  18. }
  19. export interface Interface extends FileSystem.FileSystem {
  20. readonly isDir: (path: string) => Effect.Effect<boolean>
  21. readonly isFile: (path: string) => Effect.Effect<boolean>
  22. readonly existsSafe: (path: string) => Effect.Effect<boolean>
  23. readonly readJson: (path: string) => Effect.Effect<unknown, Error>
  24. readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
  25. readonly ensureDir: (path: string) => Effect.Effect<void, Error>
  26. readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
  27. readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
  28. readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
  29. readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
  30. readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
  31. readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
  32. readonly globMatch: (pattern: string, filepath: string) => boolean
  33. }
  34. export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
  35. export const layer = Layer.effect(
  36. Service,
  37. Effect.gen(function* () {
  38. const fs = yield* FileSystem.FileSystem
  39. const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
  40. return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
  41. })
  42. const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
  43. const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
  44. return info?.type === "Directory"
  45. })
  46. const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
  47. const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
  48. return info?.type === "File"
  49. })
  50. const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
  51. return yield* Effect.tryPromise({
  52. try: async () => {
  53. const entries = await NFS.readdir(dirPath, { withFileTypes: true })
  54. return entries.map(
  55. (e): DirEntry => ({
  56. name: e.name,
  57. type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
  58. }),
  59. )
  60. },
  61. catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
  62. })
  63. })
  64. const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
  65. const text = yield* fs.readFileString(path)
  66. return JSON.parse(text)
  67. })
  68. const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
  69. const content = JSON.stringify(data, null, 2)
  70. yield* fs.writeFileString(path, content)
  71. if (mode) yield* fs.chmod(path, mode)
  72. })
  73. const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
  74. yield* fs.makeDirectory(path, { recursive: true })
  75. })
  76. const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
  77. path: string,
  78. content: string | Uint8Array,
  79. mode?: number,
  80. ) {
  81. const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
  82. yield* write.pipe(
  83. Effect.catchIf(
  84. (e) => e.reason._tag === "NotFound",
  85. () =>
  86. Effect.gen(function* () {
  87. yield* fs.makeDirectory(dirname(path), { recursive: true })
  88. yield* write
  89. }),
  90. ),
  91. )
  92. if (mode) yield* fs.chmod(path, mode)
  93. })
  94. const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
  95. return yield* Effect.tryPromise({
  96. try: () => Glob.scan(pattern, options),
  97. catch: (cause) => new FileSystemError({ method: "glob", cause }),
  98. })
  99. })
  100. const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
  101. const result: string[] = []
  102. let current = start
  103. while (true) {
  104. const search = join(current, target)
  105. if (yield* fs.exists(search)) result.push(search)
  106. if (stop === current) break
  107. const parent = dirname(current)
  108. if (parent === current) break
  109. current = parent
  110. }
  111. return result
  112. })
  113. const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
  114. const result: string[] = []
  115. let current = options.start
  116. while (true) {
  117. for (const target of options.targets) {
  118. const search = join(current, target)
  119. if (yield* fs.exists(search)) result.push(search)
  120. }
  121. if (options.stop === current) break
  122. const parent = dirname(current)
  123. if (parent === current) break
  124. current = parent
  125. }
  126. return result
  127. })
  128. const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
  129. const result: string[] = []
  130. let current = start
  131. while (true) {
  132. const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
  133. Effect.catch(() => Effect.succeed([] as string[])),
  134. )
  135. result.push(...matches)
  136. if (stop === current) break
  137. const parent = dirname(current)
  138. if (parent === current) break
  139. current = parent
  140. }
  141. return result
  142. })
  143. return Service.of({
  144. ...fs,
  145. existsSafe,
  146. isDir,
  147. isFile,
  148. readDirectoryEntries,
  149. readJson,
  150. writeJson,
  151. ensureDir,
  152. writeWithDirs,
  153. findUp,
  154. up,
  155. globUp,
  156. glob,
  157. globMatch: Glob.match,
  158. })
  159. }),
  160. )
  161. export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
  162. // Pure helpers that don't need Effect (path manipulation, sync operations)
  163. export function mimeType(p: string): string {
  164. return lookup(p) || "application/octet-stream"
  165. }
  166. export function normalizePath(p: string): string {
  167. if (process.platform !== "win32") return p
  168. const resolved = pathResolve(windowsPath(p))
  169. try {
  170. return realpathSync.native(resolved)
  171. } catch {
  172. return resolved
  173. }
  174. }
  175. export function normalizePathPattern(p: string): string {
  176. if (process.platform !== "win32") return p
  177. if (p === "*") return p
  178. const match = p.match(/^(.*)[\\/]\*$/)
  179. if (!match) return normalizePath(p)
  180. const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
  181. return join(normalizePath(dir), "*")
  182. }
  183. export function resolve(p: string): string {
  184. const resolved = pathResolve(windowsPath(p))
  185. try {
  186. return normalizePath(realpathSync(resolved))
  187. } catch (e: any) {
  188. if (e?.code === "ENOENT") return normalizePath(resolved)
  189. throw e
  190. }
  191. }
  192. export function windowsPath(p: string): string {
  193. if (process.platform !== "win32") return p
  194. return p
  195. .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  196. .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  197. .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  198. .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
  199. }
  200. export function overlaps(a: string, b: string) {
  201. const relA = relative(a, b)
  202. const relB = relative(b, a)
  203. return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
  204. }
  205. export function contains(parent: string, child: string) {
  206. return !relative(parent, child).startsWith("..")
  207. }
  208. }