304 lines
7.3 KiB
TypeScript

import {
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { mkdirpSync, rmdirSync } from 'fs-extra';
import { logger } from './logger';
import { dirname, join, relative } from 'path';
const chalk = require('chalk');
/**
* Virtual file system tree.
*/
export interface Tree {
/**
* Root of the workspace. All paths are relative to this.
*/
root: string;
/**
* Read the contents of a file.
*/
read(filePath: string): Buffer | null;
/**
* Update the contents of a file or create a new file.
*/
write(filePath: string, content: Buffer | string): void;
/**
* Check if a file exists.
*/
exists(filePath: string): boolean;
/**
* Delete the file.
*/
delete(filePath: string): void;
/**
* Rename the file or the folder.
*/
rename(from: string, to: string): void;
/**
* Check if this is a file or not.
*/
isFile(filePath: string): boolean;
/**
* Returns the list of children of a folder.
*/
children(dirPath: string): string[];
/**
* Returns the list of currently recorded changes.
*/
listChanges(): FileChange[];
}
/**
* Description of a file change in the Nx virtual file system/
*/
export interface FileChange {
/**
* Path relative to the workspace root
*/
path: string;
/**
* Type of change: 'CREATE' | 'DELETE' | 'UPDATE'
*/
type: 'CREATE' | 'DELETE' | 'UPDATE';
/**
* The content of the file or null in case of delete.
*/
content: Buffer | null;
}
export class FsTree implements Tree {
private recordedChanges: {
[path: string]: { content: Buffer | null; isDeleted: boolean };
} = {};
constructor(readonly root: string, private readonly isVerbose: boolean) {}
read(filePath: string): Buffer | null {
filePath = this.normalize(filePath);
try {
if (this.recordedChanges[this.rp(filePath)]) {
return this.recordedChanges[this.rp(filePath)].content;
} else {
return this.fsReadFile(filePath);
}
} catch (e) {
if (this.isVerbose) {
logger.error(e);
}
return null;
}
}
write(filePath: string, content: Buffer | string): void {
filePath = this.normalize(filePath);
try {
this.recordedChanges[this.rp(filePath)] = {
content: Buffer.from(content),
isDeleted: false,
};
} catch (e) {
if (this.isVerbose) {
logger.error(e);
}
}
}
overwrite(filePath: string, content: Buffer | string): void {
filePath = this.normalize(filePath);
this.write(filePath, content);
}
exists(filePath: string): boolean {
filePath = this.normalize(filePath);
try {
if (this.recordedChanges[this.rp(filePath)]) {
return !this.recordedChanges[this.rp(filePath)].isDeleted;
} else if (this.filesForDir(this.rp(filePath)).length > 0) {
return true;
} else {
return this.fsExists(filePath);
}
} catch (err) {
return false;
}
}
delete(filePath: string): void {
filePath = this.normalize(filePath);
if (this.filesForDir(this.rp(filePath)).length > 0) {
this.filesForDir(this.rp(filePath)).forEach(
(f) => (this.recordedChanges[f] = { content: null, isDeleted: true })
);
}
this.recordedChanges[this.rp(filePath)] = {
content: null,
isDeleted: true,
};
}
rename(from: string, to: string): void {
from = this.normalize(from);
to = this.normalize(to);
const content = this.read(this.rp(from));
this.recordedChanges[this.rp(from)] = { content: null, isDeleted: true };
this.recordedChanges[this.rp(to)] = { content: content, isDeleted: false };
}
isFile(filePath: string): boolean {
filePath = this.normalize(filePath);
try {
if (this.recordedChanges[this.rp(filePath)]) {
return !this.recordedChanges[this.rp(filePath)].isDeleted;
} else {
return this.fsIsFile(filePath);
}
} catch (err) {
return false;
}
}
children(dirPath: string): string[] {
dirPath = this.normalize(dirPath);
let res = this.fsReadDir(dirPath);
res = [...res, ...this.directChildrenOfDir(this.rp(dirPath))];
return res.filter((q) => {
const r = this.recordedChanges[join(this.rp(dirPath), q)];
if (r && r.isDeleted) return false;
return true;
});
}
listChanges(): FileChange[] {
const res = [] as FileChange[];
Object.keys(this.recordedChanges).forEach((f) => {
if (this.recordedChanges[f].isDeleted) {
if (this.fsExists(f)) {
res.push({ path: f, type: 'DELETE', content: null });
}
} else {
if (this.fsExists(f)) {
res.push({
path: f,
type: 'UPDATE',
content: this.recordedChanges[f].content,
});
} else {
res.push({
path: f,
type: 'CREATE',
content: this.recordedChanges[f].content,
});
}
}
});
return res;
}
private normalize(path: string) {
return relative(this.root, join(this.root, path));
}
private fsReadDir(dirPath: string) {
if (!this.delegateToFs) return [];
try {
return readdirSync(join(this.root, dirPath));
} catch (e) {
return [];
}
}
private fsIsFile(filePath: string) {
if (!this.delegateToFs) return false;
const stat = statSync(join(this.root, filePath));
return stat.isFile();
}
private fsReadFile(filePath: string) {
if (!this.delegateToFs) return null;
return readFileSync(join(this.root, filePath));
}
private fsExists(filePath: string): boolean {
if (!this.delegateToFs) return false;
try {
const stat = statSync(join(this.root, filePath));
return stat.isFile() || stat.isDirectory();
} catch (e) {
return false;
}
}
private delegateToFs(): boolean {
return this.root !== null;
}
private filesForDir(path: string): string[] {
return Object.keys(this.recordedChanges).filter(
(f) => f.startsWith(path + '/') && !this.recordedChanges[f].isDeleted
);
}
private directChildrenOfDir(path: string): string[] {
const res = {};
Object.keys(this.recordedChanges).forEach((f) => {
if (f.startsWith(path + '/')) {
const [_, file] = f.split(path + '/');
res[file.split('/')[0]] = true;
}
});
return Object.keys(res);
}
private rp(pp: string) {
return pp.startsWith('/') ? pp.substring(1) : pp;
}
}
export function flushChanges(root: string, fileChanges: FileChange[]) {
fileChanges.forEach((f) => {
const fpath = join(root, f.path);
if (f.type === 'CREATE') {
mkdirpSync(dirname(fpath));
writeFileSync(fpath, f.content);
} else if (f.type === 'UPDATE') {
writeFileSync(fpath, f.content);
} else if (f.type === 'DELETE') {
try {
const stat = statSync(fpath);
if (stat.isDirectory()) {
rmdirSync(fpath, { recursive: true });
} else {
unlinkSync(fpath);
}
} catch (e) {}
}
});
}
export function printChanges(fileChanges: FileChange[]) {
fileChanges.forEach((f) => {
if (f.type === 'CREATE') {
console.log(`${chalk.green('CREATE')} ${f.path}`);
} else if (f.type === 'UPDATE') {
console.log(`${chalk.white('UPDATE')} ${f.path}`);
} else if (f.type === 'DELETE') {
console.log(`${chalk.yellow('DELETE')} ${f.path}`);
}
});
}