UNPKG

@neodx/vfs

Version:

Simple virtual file system - working dir context, lazy changes, different modes, integrations and moreover

385 lines (376 loc) 12.8 kB
'use strict'; var colors = require('@neodx/colors'); var std = require('@neodx/std'); var shared = require('@neodx/std/shared'); var pathe = require('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( std .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 std .uniq( Array.from(store.keys()) .filter(pathStartsBy(path)) .map(name => name.split(withTrailingSlash(path))[1].split('/')[0]) ) .map(name => createInMemoryDirent(name, isFile(pathe.join(path, name)))); }, write(path, content) { store.set(path, Buffer.from(content)); while (path !== '/') { deleted.delete(path); path = pathe.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 std.entries(initializer)) { const path = pathe.join(base, name); if (std.isTypeOfString(value)) result[path] = value; else Object.assign(result, createInMemoryFilesRecord(value, path)); } return result; } const linkError = (error, cause) => std.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 = std.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.colors.gray(`[${name}] ${printAllTime()}`)} ${mapSuccessMessage(result, ...args)}` ); } if (invariantMessage) { std.invariant( result, std.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; } } shared.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 => pathe.relative(basePath, ctx.resolve(path)).split(pathe.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 = std .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(std.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(std.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 = pathe.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 std.concurrently(to, path => writeVfsFile(ctx, path, content), 5); } async function getVfsActions(ctx, types) { const changes = await std.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 => std.isTruthy(action) && types.includes(action.type)) : std.compact(changes); } /** Guarantees all ancestor directories not deleted */ function ensureVfsPath(ctx, path) { const parent = pathe.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.colors.gray(prefix + pathe.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(pathe.join(path, '..')); const isOuterPath = (ctx, path) => ctx.relative(path).startsWith('..'); exports.createInMemoryBackend = createInMemoryBackend; exports.createInMemoryFilesRecord = createInMemoryFilesRecord; exports.createTaskRunner = createTaskRunner; exports.deleteVfsPath = deleteVfsPath; exports.displayPath = displayPath; exports.existsVfsPath = existsVfsPath; exports.formatList = formatList; exports.getVfsActions = getVfsActions; exports.isVfsDir = isVfsDir; exports.isVfsFile = isVfsFile; exports.pathStartsWith = pathStartsWith; exports.readVfsDir = readVfsDir; exports.readVfsFile = readVfsFile; exports.renameVfs = renameVfs; exports.tryReadVfsFile = tryReadVfsFile; exports.writeVfsFile = writeVfsFile; //# sourceMappingURL=operations-C-RkTDIe.cjs.map