filesystem.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. export * as FileSystem from "./filesystem"
  2. import path from "path"
  3. import { pathToFileURL } from "url"
  4. import { Context, Effect, Layer, Option, Schema } from "effect"
  5. import { EventV2 } from "./event"
  6. import { FSUtil } from "./fs-util"
  7. import { Location } from "./location"
  8. import { PositiveInt, RelativePath } from "./schema"
  9. import { FileSystemSearch } from "./filesystem/search"
  10. import { Entry, Match } from "./filesystem/schema"
  11. export { Entry, Match, Submatch } from "./filesystem/schema"
  12. export const ReadInput = Schema.Struct({
  13. path: RelativePath,
  14. })
  15. export type ReadInput = typeof ReadInput.Type
  16. export const Content = Schema.Struct({
  17. uri: Schema.String,
  18. name: Schema.String.pipe(Schema.optional),
  19. content: Schema.String,
  20. encoding: Schema.Literals(["utf8", "base64"]),
  21. mime: Schema.String,
  22. }).annotate({ identifier: "FileSystem.Content" })
  23. export type Content = typeof Content.Type
  24. export const ListInput = Schema.Struct({
  25. path: RelativePath.pipe(Schema.optional),
  26. })
  27. export type ListInput = typeof ListInput.Type
  28. export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
  29. query: Schema.String,
  30. type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
  31. limit: PositiveInt.pipe(Schema.optional),
  32. }) {}
  33. export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
  34. pattern: Schema.String,
  35. path: RelativePath.pipe(Schema.optional),
  36. limit: PositiveInt.pipe(Schema.optional),
  37. }) {}
  38. export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
  39. pattern: Schema.String,
  40. path: RelativePath.pipe(Schema.optional),
  41. include: Schema.String.pipe(Schema.optional),
  42. limit: PositiveInt.pipe(Schema.optional),
  43. }) {}
  44. export const Event = {
  45. Edited: EventV2.define({
  46. type: "file.edited",
  47. schema: {
  48. file: Schema.String,
  49. },
  50. }),
  51. }
  52. export interface Interface {
  53. readonly read: (input: ReadInput) => Effect.Effect<Content>
  54. readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
  55. readonly find: (input: FindInput) => Effect.Effect<Entry[]>
  56. readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
  57. readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
  58. }
  59. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
  60. const baseLayer = Layer.effect(
  61. Service,
  62. Effect.gen(function* () {
  63. const fs = yield* FSUtil.Service
  64. const location = yield* Location.Service
  65. const search = yield* FileSystemSearch.Service
  66. const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
  67. const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
  68. const absolute = path.resolve(location.directory, input ?? ".")
  69. if (!FSUtil.contains(location.directory, absolute))
  70. return yield* Effect.die(new Error("Path escapes the location"))
  71. const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
  72. if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
  73. return { absolute, real, directory: location.directory, root }
  74. })
  75. return Service.of({
  76. find: search.find,
  77. glob: search.glob,
  78. grep: search.grep,
  79. read: Effect.fn("FileSystem.read")(function* (input) {
  80. const target = yield* resolve(input.path)
  81. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  82. if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
  83. const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
  84. const mime = FSUtil.mimeType(target.real)
  85. if (!bytes.includes(0)) {
  86. const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
  87. Effect.option,
  88. )
  89. if (Option.isSome(content))
  90. return {
  91. uri: pathToFileURL(target.real).href,
  92. name: path.basename(target.real),
  93. content: content.value,
  94. encoding: "utf8" as const,
  95. mime,
  96. }
  97. }
  98. return {
  99. uri: pathToFileURL(target.real).href,
  100. name: path.basename(target.real),
  101. content: Buffer.from(bytes).toString("base64"),
  102. encoding: "base64" as const,
  103. mime,
  104. }
  105. }),
  106. list: Effect.fn("FileSystem.list")(function* (input = {}) {
  107. const target = yield* resolve(input.path)
  108. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  109. if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
  110. return yield* fs.readDirectoryEntries(target.real).pipe(
  111. Effect.orDie,
  112. Effect.map((items) =>
  113. items
  114. .flatMap((item) => {
  115. if (item.type !== "file" && item.type !== "directory") return []
  116. const absolute = path.join(target.absolute, item.name)
  117. const relative = path.relative(target.directory, absolute)
  118. return [
  119. new Entry({
  120. path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
  121. type: item.type,
  122. mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
  123. }),
  124. ]
  125. })
  126. .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
  127. ),
  128. )
  129. }),
  130. })
  131. }),
  132. )
  133. export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
  134. export const locationLayer = layer