filesystem.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. export * as FileSystem from "./filesystem"
  2. import { makeLocationNode } from "./effect/app-node"
  3. import path from "path"
  4. import { Context, Effect, Layer, Schema } from "effect"
  5. import { FSUtil } from "./fs-util"
  6. import { Location } from "./location"
  7. import { PositiveInt, RelativePath } from "./schema"
  8. import { FileSystemSearch } from "./filesystem/search"
  9. import { Entry, FileSystem, FindInput, Match } from "@kirincode-ai/schema/filesystem"
  10. export { Entry, Match, Submatch } from "@kirincode-ai/schema/filesystem"
  11. export const ReadInput = Schema.Struct({
  12. path: RelativePath,
  13. })
  14. export type ReadInput = typeof ReadInput.Type
  15. export const Content = Schema.Struct({
  16. uri: Schema.String,
  17. name: Schema.String.pipe(Schema.optional),
  18. content: Schema.String,
  19. encoding: Schema.Literals(["utf8", "base64"]),
  20. mime: Schema.String,
  21. }).annotate({ identifier: "FileSystem.Content" })
  22. export type Content = typeof Content.Type
  23. export const ListInput = Schema.Struct({
  24. path: RelativePath.pipe(Schema.optional),
  25. })
  26. export type ListInput = typeof ListInput.Type
  27. export { FindInput }
  28. export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
  29. pattern: Schema.String,
  30. path: RelativePath.pipe(Schema.optional),
  31. limit: PositiveInt.pipe(Schema.optional),
  32. }) {}
  33. export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
  34. pattern: Schema.String,
  35. path: RelativePath.pipe(Schema.optional),
  36. include: Schema.String.pipe(Schema.optional),
  37. limit: PositiveInt.pipe(Schema.optional),
  38. }) {}
  39. export const Event = FileSystem.Event
  40. export interface Interface {
  41. readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
  42. readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
  43. readonly find: (input: FindInput) => Effect.Effect<Entry[]>
  44. readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
  45. readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
  46. }
  47. export class Service extends Context.Service<Service, Interface>()("@kirincode/v2/FileSystem") {}
  48. const baseLayer = Layer.effect(
  49. Service,
  50. Effect.gen(function* () {
  51. const fs = yield* FSUtil.Service
  52. const location = yield* Location.Service
  53. const search = yield* FileSystemSearch.Service
  54. const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
  55. const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
  56. const absolute = path.resolve(location.directory, input ?? ".")
  57. if (!FSUtil.contains(location.directory, absolute))
  58. return yield* Effect.die(new Error("Path escapes the location"))
  59. const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
  60. if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
  61. return { absolute, real, directory: location.directory, root }
  62. })
  63. return Service.of({
  64. find: search.find,
  65. glob: search.glob,
  66. grep: search.grep,
  67. read: Effect.fn("FileSystem.read")(function* (input) {
  68. const target = yield* resolve(input.path)
  69. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  70. if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
  71. return {
  72. content: yield* fs.readFile(target.real).pipe(Effect.orDie),
  73. mime: FSUtil.mimeType(target.real),
  74. }
  75. }),
  76. list: Effect.fn("FileSystem.list")(function* (input = {}) {
  77. const target = yield* resolve(input.path)
  78. const info = yield* fs.stat(target.real).pipe(Effect.orDie)
  79. if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
  80. return yield* fs.readDirectoryEntries(target.real).pipe(
  81. Effect.orDie,
  82. Effect.map((items) =>
  83. items
  84. .flatMap((item) => {
  85. if (item.type !== "file" && item.type !== "directory") return []
  86. const absolute = path.join(target.absolute, item.name)
  87. const relative = path.relative(target.directory, absolute)
  88. return [
  89. Entry.make({
  90. path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
  91. type: item.type,
  92. }),
  93. ]
  94. })
  95. .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
  96. ),
  97. )
  98. }),
  99. })
  100. }),
  101. )
  102. export const node = makeLocationNode({
  103. service: Service,
  104. layer: baseLayer,
  105. deps: [FSUtil.node, Location.node, FileSystemSearch.node],
  106. })