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