xref: /expo/tools/src/Transforms.ts (revision 98f365fa)
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 getTransformedFileContentAsync(
43  filePath: string,
44  transforms: FileTransform[]
45): Promise<string> {
46  // Transform source content.
47  let result = await fs.readFile(filePath, 'utf8');
48  for (const transform of transforms) {
49    const beforeTransformation = result;
50    result = applySingleTransform(result, transform);
51    await maybePrintDebugInfoAsync(beforeTransformation, result, filePath, transform);
52  }
53  return result;
54}
55
56/**
57 * Filters file contents transformations to only these that match path patterns.
58 * `filePath` param should be a relative path.
59 */
60function getFilteredContentTransforms(transforms: FileTransform[], filePath: string) {
61  return transforms.filter(
62    ({ paths }) =>
63      !paths || arrayize(paths).some((pattern) => minimatch(filePath, pattern, { matchBase: true }))
64  );
65}
66
67async function maybePrintDebugInfoAsync(
68  contentBefore: string,
69  contentAfter: string,
70  filePath: string,
71  transform: FileTransform
72): Promise<void> {
73  if (!transform.debug || contentAfter === contentBefore) {
74    return;
75  }
76  const transformName =
77    typeof transform.debug === 'string' ? transform.debug : JSON.stringify(transform, null, 2);
78
79  printDiff(contentBefore, contentAfter);
80
81  const { isCorrect } = await inquirer.prompt<{ isCorrect: boolean }>([
82    {
83      type: 'confirm',
84      name: 'isCorrect',
85      message: `Changes in file ${filePath} introduced by transform ${transformName}`,
86      default: true,
87    },
88  ]);
89  if (!isCorrect) {
90    throw new Error('ABORTING');
91  }
92}
93
94function applySingleTransform(input: string, transform: StringTransform): string {
95  if (isRawTransform(transform)) {
96    return transform.transform(input);
97  } else if (isReplaceTransform(transform)) {
98    const { find, replaceWith } = transform;
99    // @ts-ignore @tsapeta: TS gets crazy on `replaceWith` being a function.
100    return input.replace(find, replaceWith);
101  }
102  throw new Error(`Unknown transform type`);
103}
104
105/**
106 * Transforms file's content in-place.
107 */
108export async function transformFileAsync(
109  filePath: string,
110  transforms: StringTransform[] | null | undefined
111): Promise<void> {
112  const content = await fs.readFile(filePath, 'utf8');
113  await fs.outputFile(filePath, transformString(content, transforms));
114}
115
116/**
117 * Transforms multiple files' content in-place.
118 */
119export async function transformFilesAsync(files: string[], transforms: FileTransform[]) {
120  for (const file of files) {
121    const filteredContentTransforms = getFilteredContentTransforms(transforms ?? [], file);
122
123    // Transform source content.
124    const content = await getTransformedFileContentAsync(file, filteredContentTransforms);
125
126    // Save transformed content
127    await fs.outputFile(file, content);
128  }
129}
130
131/**
132 * Copies a file from source directory to target directory with transformed relative path and content.
133 */
134export async function copyFileWithTransformsAsync(
135  options: CopyFileOptions
136): Promise<CopyFileResult> {
137  const { sourceFile, sourceDirectory, targetDirectory, transforms } = options;
138  const sourcePath = path.join(sourceDirectory, sourceFile);
139
140  // Transform the target path according to rename rules.
141  const targetFile = transformString(sourceFile, transforms.path);
142  const targetPath = path.join(targetDirectory, targetFile);
143
144  const filteredContentTransforms = getFilteredContentTransforms(
145    transforms.content ?? [],
146    sourceFile
147  );
148
149  // Transform source content.
150  const content = await getTransformedFileContentAsync(sourcePath, filteredContentTransforms);
151
152  if (filteredContentTransforms.length > 0) {
153    // Save transformed source file at renamed target path.
154    await fs.outputFile(targetPath, content);
155  } else {
156    // When there are no transforms, it's safer to just copy the file.
157    // Only then binary files will be copied properly since the `content` is utf8-encoded.
158    await fs.copy(sourcePath, targetPath, { overwrite: true });
159  }
160
161  // Keep original file mode if needed.
162  if (options.keepFileMode) {
163    const { mode } = await fs.stat(sourcePath);
164    await fs.chmod(targetPath, mode);
165  }
166
167  return {
168    content,
169    targetFile,
170  };
171}
172