Skip to content
11 changes: 11 additions & 0 deletions docs/node/relative-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,14 @@ is one folder that exists in all `memfs` volumes:
```js
process.chdir('/');
```

# Experimental support

PR [1124](https://github.com/streamich/memfs/pull/1224) adds partial support for
relative paths. Tests are passing on the following methods:

- `fs.promises.readFile`
- `fs.readFileSync`
- `vol.readdirSync`
- `vol.opendir`
- `vol.statSync`
27 changes: 18 additions & 9 deletions packages/fs-core/src/Superblock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ export class Superblock {
this.root = root;
}

protected _cwd: string = '/';
get cwd(): string {
return this._cwd;
}
set cwd(value: string) {
this._cwd = value;
}

createLink(): Link;
createLink(parent: Link, name: string, isDirectory?: boolean, mode?: number): Link;
createLink(parent?: Link, name?: string, isDirectory: boolean = false, mode?: number): Link {
Expand Down Expand Up @@ -202,7 +210,7 @@ export class Superblock {
steps = stepsOrFilenameOrLink.steps;
filename = pathSep + steps.join(pathSep);
} else if (typeof stepsOrFilenameOrLink === 'string') {
steps = filenameToSteps(stepsOrFilenameOrLink);
steps = filenameToSteps(stepsOrFilenameOrLink, this.cwd);
filename = stepsOrFilenameOrLink;
} else {
steps = stepsOrFilenameOrLink;
Expand Down Expand Up @@ -243,7 +251,7 @@ export class Superblock {
if (node.isSymlink() && (resolveSymlinks || i < steps.length - 1)) {
const resolvedPath = isAbsolute(node.symlink) ? node.symlink : pathJoin(dirname(curr.getPath()), node.symlink); // Relative to symlink's parent

steps = filenameToSteps(resolvedPath).concat(steps.slice(i + 1));
steps = filenameToSteps(resolvedPath, this.cwd).concat(steps.slice(i + 1));
curr = this.root;
i = 0;
continue;
Expand Down Expand Up @@ -331,7 +339,7 @@ export class Superblock {

getLinkParentAsDirOrThrow(filenameOrSteps: string | string[], funcName?: string): Link {
const steps: string[] = (
filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps)
filenameOrSteps instanceof Array ? filenameOrSteps : filenameToSteps(filenameOrSteps, this.cwd)
).slice(0, -1);
const filename: string = pathSep + steps.join(pathSep);
const link = this.getLinkOrThrow(filename, funcName);
Expand Down Expand Up @@ -412,10 +420,11 @@ export class Superblock {
return json;
}

fromJSON(json: DirectoryJSON, cwd: string = this.process.cwd()) {
fromJSON(json: DirectoryJSON, cwd: string = "/") {
this.cwd = cwd;
for (let filename in json) {
const data = json[filename];
filename = resolve(filename, cwd);
filename = resolve(filename, this.cwd);
if (typeof data === 'string' || data instanceof Buffer) {
const dir = dirname(filename);
this.mkdirp(dir, MODE.DIR);
Expand Down Expand Up @@ -500,7 +509,7 @@ export class Superblock {
modeNum: number | undefined,
resolveSymlinks: boolean = true,
): File {
const steps = filenameToSteps(filename);
const steps = filenameToSteps(filename, this.cwd);
let link: Link | null;
try {
link = resolveSymlinks ? this.getResolvedLinkOrThrow(filename, 'open') : this.getLinkOrThrow(filename, 'open');
Expand Down Expand Up @@ -639,7 +648,7 @@ export class Superblock {
};

public readonly symlink = (targetFilename: string, pathFilename: string): Link => {
const pathSteps = filenameToSteps(pathFilename);
const pathSteps = filenameToSteps(pathFilename, this.cwd);
// Check if directory exists, where we about to create a symlink.
let dirLink;
try {
Expand Down Expand Up @@ -715,7 +724,7 @@ export class Superblock {
};

public readonly mkdir = (filename: string, modeNum: number): void => {
const steps = filenameToSteps(filename);
const steps = filenameToSteps(filename, this.cwd);
// This will throw if user tries to create root dir `fs.mkdirSync('/')`.
if (!steps.length) throw createError(ERROR_CODE.EEXIST, 'mkdir', filename);
const dir = this.getLinkParentAsDirOrThrow(filename, 'mkdir');
Expand All @@ -732,7 +741,7 @@ export class Superblock {
*/
public readonly mkdirp = (filename: string, modeNum: number): string | undefined => {
let created = false;
const steps = filenameToSteps(filename);
const steps = filenameToSteps(filename, this.cwd);
let curr: Link | null = null;
let i = steps.length;
// Find the longest subpath of filename that still exists:
Expand Down
1 change: 1 addition & 0 deletions packages/fs-core/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function pathToFilename(path: misc.PathLike): string {
path = getPathFromURLPosix(path);
}
const pathString = String(path);
if (pathString === '.') return './';
nullCheck(pathString);
return pathString;
}
Expand Down
10 changes: 5 additions & 5 deletions packages/fs-node/src/__tests__/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ export const multitest = (_done: (err?: Error) => void, times: number) => {
};
};

export const create = (json: { [s: string]: string } = { '/foo': 'bar' }) => {
const vol = Volume.fromJSON(json);
export const create = (json: { [s: string]: string } = { '/foo': 'bar' }, cwd?: string) => {
const vol = Volume.fromJSON(json, cwd);
return vol;
};

export const createFs = (json?) => {
const vol = create(json);
export const createFs = (json?: any, cwd?: string) => {
const vol = create(json, cwd);
return vol;
};

export const memfs = (json: NestedDirectoryJSON = {}, cwd: string = '/') => {
export const memfs = (json: NestedDirectoryJSON = {}, cwd?: string) => {
const vol = Volume.fromNestedJSON(json, cwd);
return { fs: vol, vol };
};
Expand Down
2 changes: 1 addition & 1 deletion packages/fs-node/src/__tests__/volume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ describe('volume', () => {
expect(fd).toBeGreaterThan(0);
done();
});
}, 100);
});
it('Error on file not found', done => {
vol.open('/non-existing-file.txt', 'r', (err, fd) => {
expect(err).toHaveProperty('code', 'ENOENT');
Expand Down
34 changes: 34 additions & 0 deletions packages/fs-node/src/__tests__/volume/cwd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { create } from '../util';

describe('Volume.cwd', () => {
it('Supports absolute path changes', () => {
const vol = create(
{
'/file': 'root',
'/a/file': 'a',
'/a/b/file': 'b',
},
'/',
);
expect(vol.readFileSync('./file', 'utf8')).toEqual('root');
vol.cwd = '/a';
expect(vol.readFileSync('./file', 'utf8')).toEqual('a');
vol.cwd = '/a/b';
expect(vol.readFileSync('./file', 'utf8')).toEqual('b');
});
it('Supports relative path changes', () => {
const vol = create(
{
file: 'root',
'a/file': 'a',
'a/b/file': 'b',
},
process.cwd(),
);
expect(vol.readFileSync('./file', 'utf8')).toEqual('root');
vol.cwd = 'a';
expect(vol.readFileSync('./file', 'utf8')).toEqual('a');
vol.cwd = 'a/b';
expect(vol.readFileSync('./file', 'utf8')).toEqual('b');
});
});
29 changes: 29 additions & 0 deletions packages/fs-node/src/__tests__/volume/opendir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ describe('opendir', () => {
});
});

it('should provide relative fs.opendirSync (synchronous)', () => {
const vol = Volume.fromJSON(
{
'/test/file1.txt': 'content1',
'/test/file2.txt': 'content2',
},
'/test',
);

const dir = vol.opendirSync('.');
expect(dir).toBeDefined();
expect(typeof dir.read).toBe('function');
expect(typeof dir.close).toBe('function');
expect(typeof dir.readSync).toBe('function');
expect(typeof dir.closeSync).toBe('function');

const entries: string[] = [];
let entry;
while ((entry = dir.readSync()) !== null) {
entries.push(entry.name);
}

expect(entries).toContain('file1.txt');
expect(entries).toContain('file2.txt');
expect(entries.length).toBe(2);

dir.closeSync();
});

it('should provide fs.opendirSync (synchronous)', () => {
const vol = new Volume();
vol.mkdirSync('/test');
Expand Down
14 changes: 14 additions & 0 deletions packages/fs-node/src/__tests__/volume/readFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import { of } from 'thingies';
import { memfs } from '../util';

describe('.readFile()', () => {
it('can read a file in cwd', async () => {
const { fs } = memfs({ 'test.txt': '01234567' }, '/dir');
await expect(fs.promises.readFile('/dir/test.txt', { encoding: 'utf8' })).resolves.toBe('01234567');
await expect(fs.readFileSync('/dir/test.txt', { encoding: 'utf8' })).toBe('01234567');
});

it('can read a relative file in cwd', async () => {
const { fs } = memfs({ 'test.txt': '01234567' }, '/dir');
await expect(fs.promises.readFile('test.txt', { encoding: 'utf8' })).resolves.toBe('01234567');
await expect(fs.promises.readFile('./test.txt', { encoding: 'utf8' })).resolves.toBe('01234567');
await expect(fs.readFileSync('test.txt', { encoding: 'utf8' })).toBe('01234567');
await expect(fs.readFileSync('./test.txt', { encoding: 'utf8' })).toBe('01234567');
});

it('can read a file', async () => {
const { fs } = memfs({ '/dir/test.txt': '01234567' });
const data = await fs.promises.readFile('/dir/test.txt', { encoding: 'utf8' });
Expand Down
13 changes: 13 additions & 0 deletions packages/fs-node/src/__tests__/volume/readdirSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ describe('readdirSync()', () => {
expect(dirs).toEqual([]);
});

it('reads relative dir', () => {
const vol = create(
{
'/foo/bar/file': 'content',
'/foo/bar/file2': 'content2',
},
'/foo',
);
const files = vol.readdirSync('bar');

expect(files).toEqual(['file', 'file2']);
});

it('respects symlinks', () => {
const vol = create({
'/a/a': 'a',
Expand Down
4 changes: 2 additions & 2 deletions packages/fs-node/src/__tests__/volume/statSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ describe('.statSync(...)', () => {
vol.writeFileSync('/c/index.js', 'alert(123);');
vol.symlinkSync('/c', '/a/b');

const stats = vol.statSync('/a/b/index.js');
expect(stats.size).toBe(11);
expect(vol.statSync('/a/b/index.js').size).toBe(11);
expect(vol.statSync('a/b/index.js').size).toBe(11);
});

it('returns rdev', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/fs-node/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function pathToFilename(path: misc.PathLike): string {
}

const pathString = String(path);
if (pathString === '.') return './';
nullCheck(pathString);
// return slash(pathString);
return pathString;
Expand Down
8 changes: 7 additions & 1 deletion packages/fs-node/src/volume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ export class Volume implements FsCallbackApi, FsSynchronousApi {
this.realpathSync.native = realpathSyncImpl as any;
}

get cwd(): string {
return this._core.cwd;
}
set cwd(value: string) {
this._core.cwd = value;
}

private wrapAsync<Args extends any[]>(method: (...args: Args) => void, args: Args, callback: misc.TCallback<any>) {
validateCallback(callback);
Promise.resolve().then(() => {
Expand Down Expand Up @@ -988,7 +995,6 @@ export class Volume implements FsCallbackApi, FsSynchronousApi {
};

private readonly _readdir = (filename: string, options: opts.IReaddirOptions): TDataOut[] | Dirent[] => {
const steps = filenameToSteps(filename);
const link: Link = this._core.getResolvedLinkOrThrow(filename, 'scandir');
const node = link.getNode();
if (!node.isDirectory()) throw createError(ERROR_CODE.ENOTDIR, 'scandir', filename);
Expand Down