304 lines
7.3 KiB
TypeScript
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}`);
|
|
}
|
|
});
|
|
}
|