Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Extracted from `systemfsoftware/systemfsoftware` (core); package history came wi

| Package | Version | Distributed |
| ------------------------------- | ------- | ----------- |
| `@systemfsoftware/effect-memfs` | 6.0.3 | yes |
| `@systemfsoftware/effect-memfs` | 6.0.4 | yes |

## Dependencies on core

Expand Down
2 changes: 1 addition & 1 deletion packages/effect-memfs/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@systemfsoftware/effect-memfs",
"license": "Apache-2.0",
"version": "6.0.3",
"version": "6.0.4",
"author": "Ryan Lee <drdgvhbh@gmail.com>",
"repository": {
"type": "git",
Expand Down
52 changes: 28 additions & 24 deletions packages/effect-memfs/src/memory-file-system.handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,26 +200,13 @@ const writeFileArgsOf = (
return withWriteFileMode(writeFileBaseArgs(options.flag), options.mode)
}

const withGlobExclude = (
args: { readonly cwd: string },
exclude: ReadonlyArray<string> | undefined,
): { readonly cwd: string; readonly exclude?: Array<string> } => {
if (exclude === undefined) {
return args
}
return { ...args, exclude: [...exclude] }
}

const globRootOf = (cwd: string, root: string | undefined): string => root ?? cwd

const globArgsOf = (
cwd: string,
options?: GlobOptions,
): { readonly cwd: string; readonly exclude?: Array<string> } => {
const globOptionsOf = (cwd: string, options?: GlobOptions): GlobOptions => {
if (options === undefined) {
return { cwd }
return { root: cwd }
}
return withGlobExclude({ cwd: globRootOf(cwd, options.root) }, options.exclude)
return { ...options, root: globRootOf(cwd, options.root) }
}

const truncateLengthOf = (length?: number): number => length ?? 0
Expand Down Expand Up @@ -399,12 +386,6 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {
const copyFile: FileSystem.FileSystem['copyFile'] = (fromPath, toPath) =>
mutated('copyFile', () => nfs.copyFileSync(fromPath, toPath))

const glob: FileSystem.FileSystem['glob'] = (pattern, options) =>
Effect.tryPromise({
try: () => Array.fromAsync(nfs.promises.glob(pattern, globArgsOf(self.cwd, options))),
catch: failureOf('glob'),
}).pipe(Effect.map((matches) => matches.map(entryPathOf)))

const link: FileSystem.FileSystem['link'] = (existingPath, newPath) =>
mutated('link', () => nfs.linkSync(existingPath, newPath))

Expand Down Expand Up @@ -457,6 +438,11 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {
Effect.flatMap(infoFrom('stat record')),
)

const lstat: FileSystem.FileSystem['lstat'] = (path) =>
Effect.tryPromise({ try: () => nfs.promises.lstat(path), catch: failureOf('lstat') }).pipe(
Effect.flatMap(infoFrom('stat record')),
)

const open: FileSystem.FileSystem['open'] = (path, options) =>
Effect.acquireRelease(
Effect.tryPromise({
Expand Down Expand Up @@ -490,6 +476,18 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {
catch: failureOf('readDirectory'),
})

const entryOf = <D = unknown>(value: D): Effect.Effect<FileSystem.DirectoryEntry, Error.PlatformError> =>
Effect.fromResult(OpenFile.direntOf(value)).pipe(
Effect.mapError(shapeFailure('directory entry')),
Effect.map((dirent) => ({ name: entryPathOf(dirent.name), type: OpenFile.kindOf(dirent) })),
)

const readDirectoryEntries: FileSystem.FileSystem['readDirectoryEntries'] = (path) =>
Effect.tryPromise({
try: () => nfs.promises.readdir(path, { withFileTypes: true }),
catch: failureOf('readDirectoryEntries'),
}).pipe(Effect.flatMap((entries) => Effect.forEach(entries, entryOf)))

const realPath: FileSystem.FileSystem['realPath'] = (path) =>
Effect.tryPromise({ try: () => nfs.promises.realpath(path), catch: failureOf('realPath') }).pipe(
Effect.map(entryPathOf),
Expand All @@ -512,14 +510,14 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {

const watch: FileSystem.FileSystem['watch'] = (path, options) => Stream.unwrap(startWatch(self, path, options))

return FileSystem.make({
const base = FileSystem.make({
access,
chmod,
chown,
copy,
copyFile,
glob,
link,
lstat,
makeDirectory,
makeTempDirectory,
makeTempDirectoryScoped,
Expand All @@ -528,6 +526,7 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {
open,
readFile,
readDirectory,
readDirectoryEntries,
readLink,
realPath,
remove,
Expand All @@ -539,4 +538,9 @@ export const fileSystem = (self: MemoryFileSystem): FileSystem.FileSystem => {
watch,
writeFile,
})

// The store has its own working directory; the derived glob would otherwise root relative patterns at the process's.
const glob: FileSystem.FileSystem['glob'] = (pattern, options) => base.glob(pattern, globOptionsOf(self.cwd, options))

return { ...base, glob }
}
41 changes: 40 additions & 1 deletion packages/effect-memfs/src/open-file.handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ export const statOf = <S = unknown>(value: S): Result.Result<Stat, ShapeRefusal>
return Result.fail(new ShapeRefusal({ method: 'stat', cause: value }))
}

export interface Dirent extends
Pick<
Stat,
'isFile' | 'isDirectory' | 'isSymbolicLink' | 'isBlockDevice' | 'isCharacterDevice' | 'isFIFO' | 'isSocket'
>
{
readonly name: string
}

const hasNameProperty = (value: unknown): value is { readonly name: string } =>
Predicate.hasProperty(value, 'name') && typeof value.name === 'string'

const isDirent = (value: unknown): value is Dirent => isStat(value) && hasNameProperty(value)

export const direntOf = <D = unknown>(value: D): Result.Result<Dirent, ShapeRefusal> => {
if (isDirent(value)) {
return Result.succeed(value)
}
return Result.fail(new ShapeRefusal({ method: 'readDirectoryEntries', cause: value }))
}

export const driverOf = <H = unknown>(value: H): Result.Result<Driver, ShapeRefusal> => {
if (isDriver(value)) {
return Result.succeed(value)
Expand Down Expand Up @@ -145,7 +166,7 @@ const kindAssociations = (stat: StatKind): ReadonlyArray<readonly [boolean, File
[stat.isSocket(), 'Socket'],
]

const kindOf = (stat: StatKind): FileSystem.File.Type =>
export const kindOf = (stat: StatKind): FileSystem.File.Type =>
Option.match(Arr.findFirst(kindAssociations(stat), ([matches]) => matches), {
onNone: () => 'Unknown',
onSome: ([, type]) => type,
Expand Down Expand Up @@ -647,6 +668,24 @@ if (import.meta.vitest !== void 0) {
},
)

const direntSubject = (drawn: ReadonlyArray<string>, nameless: boolean) => {
const record = recordAnswering(drawn)
return direntOf(nameless ? record : { ...record, name: 'entry' })
}

const answersDirent = (answers: ReadonlyArray<string>, nameless: boolean): boolean =>
!nameless && answersStat(answers)

it.prop(
'∀a_DirentAdmitted_≡AnswersFileAndDirectoryAndIsNamed',
{ of: [Schema.Array(StatQuestion), Schema.Boolean], subject: direntSubject },
(subject, [drawn, nameless]) =>
Result.match(subject(drawn, nameless), {
onFailure: (refusal) => !answersDirent(drawn, nameless) && refusalNames('readDirectoryEntries')(refusal),
onSuccess: () => answersDirent(drawn, nameless),
}),
)

it.prop(
'∀a_KindUnknown_≡NoKindAnswered',
{ of: [Schema.Array(KindQuestion), Schema.Boolean], subject: kindSubject },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec'
import { MemoryFileSystem } from '@systemfsoftware/effect-memfs'
import { Effect } from 'effect'
import * as FileSystem from 'effect/FileSystem'

const Feature = makeFeature({ it })

const withShortcut = MemoryFileSystem.make({ '/notes/hello.txt': 'hello', '/notes/more/deep.txt': 'deep' })
const filesystem = Effect.service(FileSystem.FileSystem)

const shortcut = '/notes/shortcut.txt'
const dangling = '/notes/dangling.txt'

Feature('Telling a shortcut apart from the file it points at')
.withScenarioLayer(withShortcut.layer)
.body(({ scenario, scenarioOutline }) => {
scenario(
'Looking at a shortcut without following it reports a shortcut',
Gherkin.Do.pipe(
Given('a filesystem holding a shortcut to a note')('fs', () => filesystem),
When('the shortcut is looked at without following it')('info', (s) =>
Effect.flatMap(
s.fs.symlink('/notes/hello.txt', shortcut),
() => s.fs.lstat(shortcut),
)),
Then('it is reported as a shortcut')((s, expect) => expect(s.info.type).toBe('SymbolicLink')),
),
)

scenario(
'Looking at a shortcut and following it reports the note',
Gherkin.Do.pipe(
Given('a filesystem holding a shortcut to a note')('fs', () => filesystem),
When('the shortcut is looked at after following it')('info', (s) =>
Effect.flatMap(
s.fs.symlink('/notes/hello.txt', shortcut),
() => s.fs.stat(shortcut),
)),
Then('it is reported as the file behind it')((s, expect) => expect(s.info.type).toBe('File')),
),
)

scenario(
'A shortcut whose note is missing can still be looked at without following it',
Gherkin.Do.pipe(
Given('a filesystem holding a shortcut to a note nobody saved')('fs', () => filesystem),
When('the shortcut is looked at without following it')('info', (s) =>
Effect.flatMap(
s.fs.symlink('/notes/absent.txt', dangling),
() => s.fs.lstat(dangling),
)),
Then('it is reported as a shortcut')((s, expect) => expect(s.info.type).toBe('SymbolicLink')),
),
)

scenarioOutline(
'Looking at a <thing> without following shortcuts reports a <type>',
[
{ thing: 'note', path: '/notes/hello.txt', type: 'File' },
{ thing: 'folder', path: '/notes/more', type: 'Directory' },
],
(row) =>
Gherkin.Do.pipe(
Given('a filesystem holding a notes folder')('fs', () => filesystem),
When('it is looked at without following shortcuts')('info', (s) => s.fs.lstat(row.path)),
Then('it is reported for what it is')((s, expect) => expect(s.info.type).toBe(row.type)),
),
)

scenario(
'Looking at a note nobody saved is turned down, and which attempt failed',
Gherkin.Do.pipe(
Given('a filesystem holding a notes folder')('fs', () => filesystem),
When('a note that was never saved is looked at without following shortcuts')(
'outcome',
(s) => Effect.flip(s.fs.lstat('/notes/absent.txt')),
),
Then('the caller is told the note is missing')((s, expect) =>
expect({ _tag: s.outcome.reason._tag, method: s.outcome.reason.method }).toEqual({
_tag: 'NotFound',
method: 'lstat',
})
),
),
)

scenario(
'Listing a folder with kinds tells a shortcut from a note and a folder',
Gherkin.Do.pipe(
Given('a filesystem holding a shortcut to a note')('fs', () => filesystem),
When('the notes folder is listed with kinds')('entries', (s) =>
Effect.flatMap(
s.fs.symlink('/notes/hello.txt', shortcut),
() => s.fs.readDirectoryEntries('/notes'),
)),
Then('each entry carries its own kind, the shortcut is not followed')((s, expect) =>
expect(s.entries.toSorted((left, right) => left.name.localeCompare(right.name))).toEqual([
{ name: 'hello.txt', type: 'File' },
{ name: 'more', type: 'Directory' },
{ name: 'shortcut.txt', type: 'SymbolicLink' },
])
),
),
)

scenario(
'Searching the notes by pattern finds the matching notes beneath the root',
Gherkin.Do.pipe(
Given('a filesystem holding a notes folder')('fs', () => filesystem),
When('every text note beneath the notes folder is searched for')(
'matches',
(s) => s.fs.glob('**/*.txt', { root: '/notes' }),
),
Then('both notes are found')((s, expect) =>
expect(s.matches.toSorted()).toEqual(['hello.txt', 'more/deep.txt'])
),
),
)
})
Loading
Loading