@neodx/vfs
Version:
Simple virtual file system - working dir context, lazy changes, different modes, integrations and moreover
393 lines (385 loc) • 12.5 kB
JavaScript
import { colors } from '@neodx/colors';
import {
entries,
uniq,
isTypeOfString,
compact,
invariant,
isTypeOfFunction,
uniqBy,
prop,
concurrently,
isTruthy
} from '@neodx/std';
import { redefineName } from '@neodx/std/shared';
import { join, dirname, sep, relative } from 'pathe';
/**
* In-memory VFS backend.
* Useful for testing, emulating file system, etc.
*/ function createInMemoryBackend(root = '/', initializer = {}) {
// generate new implementation based on old one
const store = new Map(
entries(createInMemoryFilesRecord(initializer, root)).map(([path, content]) => [
path,
Buffer.from(content)
])
);
const deleted = new Set();
const isFile = path => store.has(path);
const isDir = path => !isFile(path) && Array.from(store.keys()).some(pathStartsBy(path));
const deletePath = path => {
store.delete(path);
deleted.add(path);
};
return {
read(path) {
return store.get(path) ?? null;
},
isFile,
isDir,
exists(path) {
return isFile(path) || isDir(path);
},
readDir(path) {
return uniq(
Array.from(store.keys())
.filter(pathStartsBy(path))
.map(name => name.split(withTrailingSlash(path))[1].split('/')[0])
).map(name => createInMemoryDirent(name, isFile(join(path, name))));
},
write(path, content) {
store.set(path, Buffer.from(content));
while (path !== '/') {
deleted.delete(path);
path = dirname(path);
}
},
delete(path) {
deletePath(path);
for (const name of store.keys()) {
if (pathStartsWith(name, path)) {
deletePath(name);
}
}
},
__: {
kind: 'in-memory',
getStore: () => new Map(store),
getDeleted: () => new Set(deleted)
}
};
}
const createInMemoryDirent = (name, file, symlink = false) => ({
isFile: () => file,
isDirectory: () => !file,
isSymbolicLink: () => symlink,
name
});
const withTrailingSlash = path => (path.endsWith('/') ? path : `${path}/`);
const pathStartsWith = (fullPath, basePath) => fullPath.startsWith(withTrailingSlash(basePath));
const pathStartsBy = basePath => fullPath => pathStartsWith(fullPath, basePath);
/**
* @param initializer Virtual files tree
* @param base Root path
* @example
* ```ts
* createInMemoryFilesRecord({
* "package.json": "{...}",
* "src": {
* "foo": {
* "bar.ts": "export const a = 1"
* "baz.ts": "export const b = 2"
* },
* "index.ts": "export const c = 3"
* }
* });
* // {
* // "package.json": "{...}",
* // "src/foo/bar.ts": "export const a = 1",
* // "src/foo/baz.ts": "export const b = 2",
* // "src/index.ts": "export const c = 3"
* // }
* ```
*/ function createInMemoryFilesRecord(initializer, base = '') {
const result = {};
for (const [name, value] of entries(initializer)) {
const path = join(base, name);
if (isTypeOfString(value)) result[path] = value;
else Object.assign(result, createInMemoryFilesRecord(value, path));
}
return result;
}
const linkError = (error, cause) =>
isTypeOfString(error)
? new Error(error, {
cause
})
: error ?? cause;
const timeDisplay = () => {
const start = Date.now();
return () => formatTimeMs(Date.now() - start);
};
const formatTimeMs = time => {
const showSeconds = time >= 100;
const timeValue = showSeconds ? time / 1000 : time;
return timeValue
.toLocaleString('en', {
style: 'unit',
unit: showSeconds ? 'second' : 'millisecond',
unitDisplay: 'narrow'
})
.padEnd(showSeconds ? 6 : 4);
};
const formatList = (list, max = 4) => {
const visible = compact(list);
const extra = visible.slice(max).length;
return visible.length > 0
? listFormatter.format(extra ? visible.slice(0, max - 1).concat(`${extra} more`) : visible)
: 'none';
};
const listFormatter = new Intl.ListFormat('en', {
style: 'short',
type: 'unit'
});
function createTaskRunner({ log } = {}) {
return {
task: (name, handler, { mapError, invariant: invariantMessage, mapSuccessMessage } = {}) => {
async function task(...args) {
const printAllTime = timeDisplay();
try {
const result = await handler(...args);
if (mapSuccessMessage) {
log?.debug(
`${colors.gray(`[${name}] ${printAllTime()}`)} ${mapSuccessMessage(result, ...args)}`
);
}
if (invariantMessage) {
invariant(
result,
isTypeOfFunction(invariantMessage)
? invariantMessage(result, ...args)
: invariantMessage
);
}
return result;
} catch (error) {
// eslint-disable-next-line no-ex-assign
error = linkError(mapError?.(error, ...args), error);
log?.error(error);
throw error;
}
}
redefineName(task, name);
return Object.assign(
task,
createTaskRunner({
log
})
);
}
};
}
const rules = new Intl.PluralRules();
const similarPlurals = {
two: 'few',
few: 'many',
many: 'other',
zero: 'other'
};
const getPluralForm = (current, forms) =>
forms[current] ?? getPluralForm(similarPlurals[current], forms);
const plural = (n, forms) => getPluralForm(rules.select(n), forms).replace('%d', n.toString());
const sortingCollator = new Intl.Collator('en');
const compare = {
by: (fn, compareFn) => (a, b) => compareFn(fn(a), fn(b)),
locale: sortingCollator.compare.bind(sortingCollator)
};
async function existsVfsPath(ctx, path = '.') {
const meta = ctx.get(path);
ctx.log.debug('Check exists %s', displayPath(ctx, path));
if (isKnownDeletedPath(ctx, path)) return false;
// If we know any non-deleted descendants of this path, then it's exists
if (meta || getVfsNonDeletedDescendants(ctx, path).length > 0) return true;
return await ctx.backend.exists(path);
}
async function isVfsFile(ctx, path = '.') {
const meta = ctx.get(path);
return meta ? !meta.deleted : await ctx.backend.isFile(path);
}
async function isVfsDir(ctx, path = '.') {
return (
ctx.getRelativeChanges(path).some(meta => !meta.deleted) || (await ctx.backend.isDir(path))
);
}
const isKnownAsDir = (ctx, path) => ctx.getRelativeChanges(path).some(meta => !meta.deleted);
/**
* Returns actual children of a directory.
*/ async function readVfsDir(ctx, path = '.') {
const originalDirChildren = await ctx.backend.readDir(path);
const relativeChanges = ctx.getRelativeChanges(path);
const isNotDeleted = name => !isKnownDeletedPath(ctx, name);
const basePath = ctx.resolve(path);
const getDirentName = path => relative(basePath, ctx.resolve(path)).split(sep)[0];
const childrenFromChanges = relativeChanges
.filter(it => isNotDeleted(it.path))
.map(it => createInMemoryDirent(getDirentName(it.path), !isKnownAsDir(ctx, it.path)))
.filter(it => Boolean(it.name.replaceAll('.', '')));
const result = uniqBy(
[
...originalDirChildren
.filter(it => isNotDeleted(ctx.resolve(path, it.name)))
.filter(it => !childrenFromChanges.some(dirent => dirent.name === it.name)),
...childrenFromChanges
],
entry => entry.name
).sort(compare.by(prop('name'), compare.locale));
ctx.log.debug(
'Read %s - %s (%s)',
displayPath(ctx, path),
plural(result.length, {
one: '%d member',
other: '%d members'
}),
formatList(result.map(prop('name')), 3)
);
return result;
}
async function tryReadVfsFile(ctx, path, encoding) {
ctx.log.debug('Read %s', displayPath(ctx, path));
if (!(await isVfsFile(ctx, path))) return null;
const content = ctx.get(path)?.content ?? (await ctx.backend.read(path));
return encoding ? content.toString(encoding) : content;
}
async function readVfsFile(ctx, path, encoding) {
const content = await tryReadVfsFile(ctx, path, encoding);
if (content === null) {
throw new Error(`"${path}" is not file (full path: ${ctx.resolve(path)})`);
}
return content;
}
async function writeVfsFile(ctx, path, content) {
ctx.log.debug('Write %s', displayPath(ctx, path));
const pathIsDir = await isVfsDir(ctx, path);
const actualContent = pathIsDir ? null : await tryReadVfsBackendFile(ctx, path);
ensureVfsPath(ctx, ctx.resolve(path));
if (actualContent && Buffer.from(content).equals(actualContent)) {
// If content is not changed, then we can just forget about this file
ctx.unregister(path);
} else {
ctx.registerPath(path, content, isKnownDeletedPath(ctx, path), pathIsDir);
}
}
async function deleteVfsPath(ctx, path) {
ctx.log.debug('Delete %s', displayPath(ctx, path));
ctx.deletePath(path);
for (const meta of ctx.getRelativeChanges(path)) {
ctx.unregister(meta.path);
}
const parentDirName = dirname(ctx.resolve(path));
if (ctx.relative(path).startsWith('..')) {
ctx.log.warn(
"You're trying to delete a file outside of the root directory, we don't support it fully"
);
} else if (!ctx.relative(parentDirName).startsWith('..'));
}
async function renameVfs(ctx, from, ...to) {
ctx.log.debug(
'Rename %s to %s',
displayPath(ctx, from),
to.map(path => displayPath(ctx, path)).join(', ')
);
if (!(await existsVfsPath(ctx, from))) {
ctx.log.debug('Path %s not exists, rename skipped', displayPath(ctx, from));
return;
}
if (await isVfsDir(ctx, from)) {
throw new Error('Renaming a directory is not supported');
}
const content = await readVfsFile(ctx, from);
await deleteVfsPath(ctx, from);
await concurrently(to, path => writeVfsFile(ctx, path, content), 5);
}
async function getVfsActions(ctx, types) {
const changes = await concurrently(
ctx.getAllDirectChanges(),
async ({ path, deleted, content, ...meta }) => {
// ctx.log.debug('Resolving required action for %s', displayPath(ctx, path));
const exists = await ctx.backend.exists(path);
if (deleted && !exists) {
return null;
}
if (deleted) {
return {
...meta,
path,
type: 'delete'
};
}
if (!content) return null;
return {
...meta,
path,
type: exists ? 'update' : 'create',
content
};
}
);
// ctx.log.debug('Found %d changes under "%s" working directory', changes.length, ctx.path);
return types
? changes.filter(action => isTruthy(action) && types.includes(action.type))
: compact(changes);
}
/** Guarantees all ancestor directories not deleted */ function ensureVfsPath(ctx, path) {
const parent = dirname(path);
if (ctx.relative(parent).startsWith('..') || parent === path) return;
if (isKnownDeletedPath(ctx, parent)) ctx.registerPath(parent, null, true);
else if (!ctx.get(parent)?.updatedAfterDelete) ctx.unregister(parent);
ensureVfsPath(ctx, parent);
}
function getVfsNonDeletedDescendants(ctx, path) {
return ctx.getRelativeChanges(path).filter(meta => !meta.deleted);
}
async function tryReadVfsBackendFile(ctx, path) {
const resolved = ctx.resolve(path);
return (await ctx.backend.isFile(resolved)) ? await ctx.backend.read(resolved) : null;
}
const prefixSize = 28;
const displayPath = (ctx, path) => {
const prefix =
ctx.path.length > prefixSize
? `${ctx.path.slice(0, prefixSize / 4)}...${ctx.path.slice((-prefixSize * 3) / 4)}`
: ctx.path;
return `${colors.gray(prefix + sep)}${ctx.relative(path)}`;
};
const isDirectDeletedPath = (ctx, path) => ctx.get(path)?.deleted;
/** The directory itself or any of its ancestors is deleted */ const isKnownDeletedPath = (
ctx,
path
) => isDirectDeletedPath(ctx, path) || Boolean(hasDeletedAncestor(ctx, path) && !ctx.get(path));
const hasDeletedAncestor = (ctx, path) => {
while (!isOuterPath(ctx, (path = parentPath(ctx, path)))) {
if (isDirectDeletedPath(ctx, path) || ctx.get(path)?.updatedAfterDelete) return true;
}
return false;
};
const parentPath = (ctx, path) => ctx.resolve(join(path, '..'));
const isOuterPath = (ctx, path) => ctx.relative(path).startsWith('..');
export {
isVfsFile as a,
readVfsFile as b,
renameVfs as c,
displayPath as d,
existsVfsPath as e,
deleteVfsPath as f,
getVfsActions as g,
createTaskRunner as h,
isVfsDir as i,
createInMemoryBackend as j,
createInMemoryFilesRecord as k,
formatList as l,
pathStartsWith as p,
readVfsDir as r,
tryReadVfsFile as t,
writeVfsFile as w
};
//# sourceMappingURL=operations-BqwMWtvn.mjs.map