fs-util.ts 9.4 KB

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