1import fs from 'fs-extra'; 2import inquirer from 'inquirer'; 3import minimatch from 'minimatch'; 4import path from 'path'; 5 6import { printDiff } from './Diff'; 7import { 8 CopyFileOptions, 9 CopyFileResult, 10 FileTransform, 11 RawTransform, 12 ReplaceTransform, 13 StringTransform, 14} from './Transforms.types'; 15import { arrayize } from './Utils'; 16 17export * from './Transforms.types'; 18 19function isRawTransform(transform: any): transform is RawTransform { 20 return transform.transform; 21} 22 23function isReplaceTransform(transform: any): transform is ReplaceTransform { 24 return transform.find !== undefined && transform.replaceWith !== undefined; 25} 26 27/** 28 * Transforms input string according to the given transform rules. 29 */ 30export function transformString( 31 input: string, 32 transforms: StringTransform[] | null | undefined 33): string { 34 if (!transforms) { 35 return input; 36 } 37 return transforms.reduce((acc, transform) => { 38 return applySingleTransform(acc, transform); 39 }, input); 40} 41 42async function applyTransformsOnFileContentAsync( 43 filePath: string, 44 transforms: FileTransform[] 45): Promise<{ 46 content: string; 47 transformsUsed: Set<FileTransform>; 48}> { 49 // Transform source content. 50 let result = await fs.readFile(filePath, 'utf8'); 51 const transformsUsed = new Set<FileTransform>(); 52 for (const transform of transforms) { 53 const beforeTransformation = result; 54 result = applySingleTransform(result, transform); 55 await maybePrintDebugInfoAsync(beforeTransformation, result, filePath, transform); 56 if (result !== beforeTransformation) { 57 transformsUsed.add(transform); 58 } 59 } 60 return { content: result, transformsUsed }; 61} 62 63/** 64 * Filters file contents transformations to only these that match path patterns. 65 * `filePath` param should be a relative path. 66 */ 67function getFilteredContentTransforms(transforms: FileTransform[], filePath: string) { 68 return transforms.filter( 69 ({ paths }) => 70 !paths || arrayize(paths).some((pattern) => minimatch(filePath, pattern, { matchBase: true })) 71 ); 72} 73 74async function maybePrintDebugInfoAsync( 75 contentBefore: string, 76 contentAfter: string, 77 filePath: string, 78 transform: FileTransform 79): Promise<void> { 80 if (!transform.debug || contentAfter === contentBefore) { 81 return; 82 } 83 const transformName = 84 typeof transform.debug === 'string' ? transform.debug : JSON.stringify(transform, null, 2); 85 86 printDiff(contentBefore, contentAfter); 87 88 const { isCorrect } = await inquirer.prompt<{ isCorrect: boolean }>([ 89 { 90 type: 'confirm', 91 name: 'isCorrect', 92 message: `Changes in file ${filePath} introduced by transform ${transformName}`, 93 default: true, 94 }, 95 ]); 96 if (!isCorrect) { 97 throw new Error('ABORTING'); 98 } 99} 100 101function applySingleTransform(input: string, transform: StringTransform): string { 102 if (isRawTransform(transform)) { 103 return transform.transform(input); 104 } else if (isReplaceTransform(transform)) { 105 const { find, replaceWith } = transform; 106 // @ts-ignore @tsapeta: TS gets crazy on `replaceWith` being a function. 107 return input.replace(find, replaceWith); 108 } 109 throw new Error(`Unknown transform type`); 110} 111 112/** 113 * Transforms file's content in-place. 114 */ 115export async function transformFileAsync( 116 filePath: string, 117 transforms: StringTransform[] | null | undefined 118): Promise<void> { 119 const content = await fs.readFile(filePath, 'utf8'); 120 await fs.outputFile(filePath, transformString(content, transforms)); 121} 122 123/** 124 * Transforms multiple files' content in-place. 125 */ 126export async function transformFilesAsync( 127 files: string[], 128 transforms: FileTransform[] 129): Promise<Set<FileTransform>> { 130 const transformsUsed = new Set<FileTransform>(); 131 for (const file of files) { 132 const filteredContentTransforms = getFilteredContentTransforms(transforms ?? [], file); 133 134 // Transform source content. 135 const transformedFile = await applyTransformsOnFileContentAsync( 136 file, 137 filteredContentTransforms 138 ); 139 transformedFile.transformsUsed.forEach((transform) => transformsUsed.add(transform)); 140 141 // Save transformed content 142 await fs.outputFile(file, transformedFile.content); 143 } 144 return transformsUsed; 145} 146 147/** 148 * Copies a file from source directory to target directory with transformed relative path and content. 149 */ 150export async function copyFileWithTransformsAsync( 151 options: CopyFileOptions 152): Promise<CopyFileResult> { 153 const { sourceFile, sourceDirectory, targetDirectory, transforms } = options; 154 const sourcePath = path.join(sourceDirectory, sourceFile); 155 156 // Transform the target path according to rename rules. 157 const targetFile = transformString(sourceFile, transforms.path); 158 const targetPath = path.join(targetDirectory, targetFile); 159 160 const filteredContentTransforms = getFilteredContentTransforms( 161 transforms.content ?? [], 162 sourceFile 163 ); 164 165 // Transform source content. 166 const { content, transformsUsed } = await applyTransformsOnFileContentAsync( 167 sourcePath, 168 filteredContentTransforms 169 ); 170 171 if (filteredContentTransforms.length > 0) { 172 // Save transformed source file at renamed target path. 173 await fs.outputFile(targetPath, content); 174 } else { 175 // When there are no transforms, it's safer to just copy the file. 176 // Only then binary files will be copied properly since the `content` is utf8-encoded. 177 await fs.copy(sourcePath, targetPath, { overwrite: true }); 178 } 179 180 // Keep original file mode if needed. 181 if (options.keepFileMode) { 182 const { mode } = await fs.stat(sourcePath); 183 await fs.chmod(targetPath, mode); 184 } 185 186 return { 187 content, 188 transformsUsed, 189 targetFile, 190 }; 191} 192