filesystem.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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 { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
  9. import { Search } from "./filesystem/search"
  10. export const ReadInput = Schema.Struct({
  11. path: RelativePath,
  12. })
  13. export type ReadInput = typeof ReadInput.Type
  14. export const Content = Schema.Struct({
  15. uri: Schema.String,
  16. name: Schema.String.pipe(Schema.optional),
  17. content: Schema.String,
  18. encoding: Schema.Literals(["utf8", "base64"]),
  19. mime: Schema.String,
  20. }).annotate({ identifier: "FileSystem.Content" })
  21. export type Content = typeof Content.Type
  22. export const ListInput = Schema.Struct({
  23. path: RelativePath.pipe(Schema.optional),
  24. })
  25. export type ListInput = typeof ListInput.Type
  26. export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
  27. path: RelativePath,
  28. uri: Schema.String,
  29. type: Schema.Literals(["file", "directory"]),
  30. mime: Schema.String,
  31. }) {}
  32. export const FindInput = Schema.Struct({
  33. query: Schema.String,
  34. type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
  35. limit: PositiveInt.pipe(Schema.optional),
  36. })
  37. export type FindInput = typeof FindInput.Type
  38. export const GrepInput = Schema.Struct({
  39. pattern: Schema.String,
  40. include: Schema.String.pipe(Schema.optional),
  41. limit: PositiveInt.pipe(Schema.optional),
  42. })
  43. export type GrepInput = typeof GrepInput.Type
  44. export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
  45. path: RelativePath,
  46. lines: Schema.String,
  47. line: PositiveInt,
  48. offset: NonNegativeInt,
  49. submatches: Schema.Array(
  50. Schema.Struct({
  51. text: Schema.String,
  52. start: NonNegativeInt,
  53. end: NonNegativeInt,
  54. }),
  55. ),
  56. }) {}
  57. export const Event = {
  58. Edited: EventV2.define({
  59. type: "file.edited",
  60. schema: {
  61. file: Schema.String,
  62. },
  63. }),
  64. }
  65. export interface Interface {
  66. readonly read: (input: ReadInput) => Effect.Effect<Content>
  67. readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
  68. readonly find: (input: FindInput) => Effect.Effect<Entry[]>
  69. readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
  70. }
  71. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
  72. export const layer = Layer.effect(
  73. Service,
  74. Effect.gen(function* () {
  75. const fs = yield* FSUtil.Service
  76. const location = yield* Location.Service
  77. const search = yield* Search.Service
  78. const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
  79. const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
  80. const absolute = path.resolve(location.directory, input ?? ".")
  81. if (!FSUtil.contains(location.directory, absolute))
  82. return yield* Effect.die(new Error("Path escapes the location"))
  83. const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
  84. if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
  85. return { absolute, real, directory: location.directory, root }
  86. })
  87. const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
  88. const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
  89. if (!real) return
  90. if (!FSUtil.contains(selected.root, real)) return
  91. const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
  92. const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
  93. if (!type) return
  94. return new Entry({
  95. path: RelativePath.make(path.relative(selected.directory, absolute)),
  96. uri: pathToFileURL(real).href,
  97. type,
  98. mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
  99. })
  100. })
  101. return Service.of({
  102. read: Effect.fn("FileSystem.read")(function* (input) {
  103. const target = yield* resolve(input.path)
  104. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  105. if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
  106. const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
  107. const mime = FSUtil.mimeType(target.real)
  108. if (!bytes.includes(0)) {
  109. const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
  110. Effect.option,
  111. )
  112. if (Option.isSome(content))
  113. return {
  114. uri: pathToFileURL(target.real).href,
  115. name: path.basename(target.real),
  116. content: content.value,
  117. encoding: "utf8" as const,
  118. mime,
  119. }
  120. }
  121. return {
  122. uri: pathToFileURL(target.real).href,
  123. name: path.basename(target.real),
  124. content: Buffer.from(bytes).toString("base64"),
  125. encoding: "base64" as const,
  126. mime,
  127. }
  128. }),
  129. list: Effect.fn("FileSystem.list")(function* (input = {}) {
  130. const target = yield* resolve(input.path)
  131. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  132. if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
  133. return yield* fs.readDirectoryEntries(target.real).pipe(
  134. Effect.orDie,
  135. Effect.flatMap((items) =>
  136. Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
  137. concurrency: "unbounded",
  138. }),
  139. ),
  140. Effect.map((items) =>
  141. items
  142. .filter((item): item is Entry => item !== undefined)
  143. .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
  144. ),
  145. )
  146. }),
  147. find: Effect.fn("FileSystem.find")(function* (input) {
  148. const found = yield* search
  149. .file({
  150. cwd: location.directory,
  151. query: input.query,
  152. limit: input.limit,
  153. kind: input.type ?? "all",
  154. })
  155. .pipe(Effect.orDie)
  156. return found.map(
  157. (item) =>
  158. new Entry({
  159. path: RelativePath.make(item.path),
  160. uri: pathToFileURL(path.join(location.directory, item.path)).href,
  161. type: item.type,
  162. mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
  163. }),
  164. )
  165. }),
  166. grep: Effect.fn("FileSystem.grep")(function* (input) {
  167. return (yield* search
  168. .search({
  169. cwd: location.directory,
  170. pattern: input.pattern,
  171. glob: input.include ? [input.include] : undefined,
  172. limit: input.limit,
  173. })
  174. .pipe(Effect.orDie)).items.map(
  175. (item) =>
  176. new GrepMatch({
  177. path: RelativePath.make(item.path.text),
  178. lines: item.lines.text,
  179. line: item.line_number,
  180. offset: item.absolute_offset,
  181. submatches: item.submatches.map((submatch) => ({
  182. text: submatch.match.text,
  183. start: submatch.start,
  184. end: submatch.end,
  185. })),
  186. }),
  187. )
  188. }),
  189. })
  190. }),
  191. )
  192. export const locationLayer = layer