Skip to content

Commit cad65a5

Browse files
committed
feat: introduce standalone module functions (transform script)
1 parent 8e7801a commit cad65a5

7 files changed

Lines changed: 432 additions & 1 deletion

File tree

scripts/shared/paths.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const FILE_PATH_DOCS_LOCALES = resolve(FILE_PATH_DOCS, 'locales');
2525
/**
2626
* The path to the src directory.
2727
*/
28-
const FILE_PATH_SRC = resolve(FILE_PATH_PROJECT, 'src');
28+
export const FILE_PATH_SRC = resolve(FILE_PATH_PROJECT, 'src');
2929
/**
3030
* The path to the locale source files.
3131
*/

scripts/tranform-once.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { writeFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import type { ClassDeclaration, MethodDeclaration, Project } from 'ts-morph';
4+
import { SyntaxKind } from 'ts-morph';
5+
import { newProcessingError } from './apidocs/processing/error';
6+
import type { SignatureLikeDeclaration } from './apidocs/processing/signature';
7+
import { getProject } from './apidocs/project';
8+
import { required } from './apidocs/utils/value-checks';
9+
import { formatTypescript } from './shared/format';
10+
import { FILE_PATH_SRC } from './shared/paths';
11+
12+
const coreName = 'fakerCore';
13+
14+
await generate();
15+
16+
async function generate(): Promise<void> {
17+
console.log('Reading project');
18+
const project = getProject();
19+
console.log('Processing modules');
20+
await processModuleClasses(project);
21+
}
22+
23+
// Modules
24+
25+
export async function processModuleClasses(project: Project): Promise<void> {
26+
await processModules(
27+
Object.values(
28+
getAllClasses(
29+
project,
30+
(module: string): boolean =>
31+
module.endsWith('Module') && !module.startsWith('Simple')
32+
)
33+
).toSorted((a, b) => a.getNameOrThrow().localeCompare(b.getNameOrThrow()))
34+
);
35+
}
36+
37+
function getAllClasses(
38+
project: Project,
39+
filter: (name: string) => boolean = () => true
40+
): Record<string, ClassDeclaration> {
41+
return Object.fromEntries(
42+
project
43+
.getSourceFiles()
44+
.flatMap((file) => file.getClasses())
45+
.map((clazz) => [clazz.getNameOrThrow(), clazz] as const)
46+
.filter(([name]) => filter(name))
47+
);
48+
}
49+
50+
async function processModules(modules: ClassDeclaration[]): Promise<void> {
51+
for (const module of modules) {
52+
try {
53+
await processModule(module);
54+
} catch (error: unknown) {
55+
throw newProcessingError({
56+
type: 'module',
57+
name: getModuleName(module),
58+
source: module,
59+
cause: error,
60+
});
61+
}
62+
}
63+
}
64+
65+
async function processModule(module: ClassDeclaration): Promise<void> {
66+
const moduleName = getModuleName(module);
67+
console.log(`Processing module: ${moduleName}`);
68+
await processClassMethods(module, moduleName, getImports(module));
69+
}
70+
71+
function getModuleName(module: ClassDeclaration): string {
72+
return required(module.getName(), 'module name').replace(/Module$/, '');
73+
}
74+
75+
function getImports(module: ClassDeclaration): string {
76+
return module
77+
.getSourceFile()
78+
.getImportDeclarations()
79+
.map((importDecl) => importDecl.getText())
80+
.join('\n');
81+
}
82+
83+
export async function processClassMethods(
84+
clazz: ClassDeclaration,
85+
moduleName: string,
86+
imports: string
87+
): Promise<void> {
88+
await processMethods(getAllMethods(clazz), moduleName, imports);
89+
}
90+
91+
function getAllMethods(clazz: ClassDeclaration): MethodDeclaration[] {
92+
const parents: ClassDeclaration[] = [clazz];
93+
let parent: ClassDeclaration | undefined = clazz;
94+
while ((parent = parent.getBaseClass()) != null) {
95+
parents.unshift(parent);
96+
}
97+
98+
const methods: Record<string, MethodDeclaration> = {};
99+
100+
for (const parent of parents) {
101+
for (const method of parent.getMethods()) {
102+
methods[method.getName()] = method;
103+
}
104+
}
105+
106+
return Object.values(methods).toSorted((a, b) =>
107+
a.getName().localeCompare(b.getName())
108+
);
109+
}
110+
111+
async function processMethods(
112+
methods: MethodDeclaration[],
113+
moduleName: string,
114+
imports: string
115+
): Promise<void> {
116+
for (const method of methods.filter(
117+
(method) => !method.hasModifier(SyntaxKind.PrivateKeyword)
118+
)) {
119+
const name = method.getName();
120+
try {
121+
await processMethod(moduleName, name, method, imports);
122+
} catch (error) {
123+
throw newProcessingError({
124+
type: 'method',
125+
name,
126+
source: method,
127+
cause: error,
128+
});
129+
}
130+
}
131+
}
132+
133+
function toKebabCase(str: string): string {
134+
return str.replaceAll(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
135+
}
136+
137+
function toCamelCase(value: string, ...more: string[]): string {
138+
return (
139+
value.substring(0, 1).toLowerCase() +
140+
value.substring(1) +
141+
more.map(toPascalCase).join('')
142+
);
143+
}
144+
145+
function toPascalCase(value: string): string {
146+
return value.substring(0, 1).toUpperCase() + value.substring(1);
147+
}
148+
149+
async function processMethod(
150+
moduleName: string,
151+
name: string,
152+
method: MethodDeclaration,
153+
imports: string
154+
): Promise<void> {
155+
console.log(` - ${name}`);
156+
157+
// Get all signatures (overloads) and implementation
158+
const overloads = method.getOverloads();
159+
const signatureDeclarations: SignatureLikeDeclaration[] =
160+
overloads.length > 0 ? [...overloads, method] : [method];
161+
162+
const importsByFile: Record<string, Set<string>> = {};
163+
164+
let fileBody = '';
165+
166+
const moduleDocsReplacer: (substring: string, ...args: string[]) => string = (
167+
_: string,
168+
module: string,
169+
method: string,
170+
closer: string = ', '
171+
) => {
172+
if (module === toCamelCase(moduleName)) {
173+
return `${method}(${coreName}${closer}`;
174+
}
175+
176+
return `${toCamelCase(module, method)}(${coreName}${closer}`;
177+
};
178+
179+
for (const signature of signatureDeclarations) {
180+
let jsdocs = signature.getJsDocs()[0]?.getText().trim() ?? '';
181+
let code = signature.getText().trim();
182+
183+
jsdocs = jsdocs
184+
.replace(
185+
/(\* @(?!template)|\*\/)/,
186+
`* @param ${coreName} The FakerCore to use.\n $1`
187+
)
188+
.replace(/(@param .*)\n *\* (@see|@example)/, `$1\n *\n * $2`)
189+
// Replace calls to faker.defaultRefDate() in jsdocs (mostly defaults)
190+
.replaceAll(
191+
/\bfaker\.defaultRefDate\(\)/g,
192+
'getDefaultRefDate(fakerCore)'
193+
)
194+
// Calls to modules in jsdocs (mostly examples)
195+
.replaceAll(/\bfaker\.(\w+)\.(\w+)\((\))?/g, moduleDocsReplacer);
196+
197+
const moduleCallReplacer: (substring: string, ...args: string[]) => string =
198+
// eslint-disable-next-line unicorn/consistent-function-scoping
199+
(_: string, module: string, method: string, closer: string = ', ') => {
200+
if (module === 'this') {
201+
module = moduleName;
202+
}
203+
204+
if (module === moduleName && method === name) {
205+
return `${name}(${coreName}${closer}`;
206+
}
207+
208+
const asName = code.includes(`${method} =`)
209+
? toCamelCase(module, method)
210+
: method;
211+
212+
(importsByFile[`../${toCamelCase(module)}/${toKebabCase(method)}`] ??=
213+
new Set()).add(asName === method ? asName : `${method} as ${asName}`);
214+
return `${asName}(${coreName}${closer}`;
215+
};
216+
217+
// Add core parameter and export keyword
218+
code = code
219+
.replaceAll(
220+
new RegExp(`^${name}(<.*>)?\\(`, 'gm'),
221+
`export function ${name}$1(${coreName}: FakerCore, `
222+
)
223+
.replaceAll(', ):', '):');
224+
225+
// Calls to other modules
226+
code = code.replaceAll(
227+
/this\.faker\.(\w+)\s*\.(\w+)\((\))?/g,
228+
moduleCallReplacer
229+
);
230+
231+
// Calls to own module
232+
code = code.replaceAll(/\b(this)\.(\w+)\((\))?/g, moduleCallReplacer);
233+
234+
// Replace locale data access
235+
code = code.replaceAll(
236+
/\bthis\.faker\.definitions\.(\w+)\.(\w+)\b/g,
237+
"resolveLocaleData(fakerCore, '$1', '$2')"
238+
);
239+
240+
// Replace default reference date access
241+
code = code.replaceAll(
242+
/\bthis\.faker\.defaultRefDate\(\)/g,
243+
'getDefaultRefDate(fakerCore)'
244+
);
245+
246+
// Replace any remaining this.faker with parameter
247+
code = code.replaceAll(/\bthis\.faker\b/g, coreName);
248+
249+
fileBody += `${jsdocs}\n${code}\n`;
250+
}
251+
252+
const fileImports = [
253+
"import type { FakerCore } from '../../faker-core'",
254+
"import { resolveLocaleData } from '../../utils/resolve-locale-data'",
255+
"import { getDefaultRefDate } from '../../utils/get-default-ref-date'",
256+
imports,
257+
...Object.entries(importsByFile).map(([file, imports]) => {
258+
return `import { ${[...imports].join(', ')} } from '${file}';`;
259+
}),
260+
].join('\n');
261+
262+
let fileContent = `${fileImports}\n\n${fileBody}`;
263+
264+
fileContent = fileContent.replaceAll(
265+
'@default faker.defaultRefDate()',
266+
'@default getDefaultRefDate(fakerCore)'
267+
);
268+
269+
// Format the file content
270+
try {
271+
fileContent = await formatTypescript(fileContent);
272+
} catch {
273+
// ignore
274+
}
275+
276+
const outputPath = resolve(
277+
FILE_PATH_SRC,
278+
'modules',
279+
moduleName,
280+
`${toKebabCase(name)}.ts`
281+
);
282+
283+
writeFileSync(outputPath, fileContent, 'utf8');
284+
}

src/faker-config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Central location for all configuration options of Faker.
3+
*
4+
* When accessing any of the configuration options, always assume that the option may be unset and provide a default value.
5+
*/
6+
export interface FakerConfig {
7+
/**
8+
* Provides the default reference date, mainly used for relative dates.
9+
*
10+
* @since 10.4.0
11+
*/
12+
defaultRefDate?: () => Date;
13+
}

src/faker-core.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { LocaleDefinition } from './definitions';
2+
import type { FakerConfig } from './faker-config';
3+
import type { Randomizer } from './randomizer';
4+
5+
/**
6+
* Container that is passed to all methods. It contains the locale definitions, the randomizer and the configuration.
7+
*/
8+
export interface FakerCore {
9+
/**
10+
* The locale definitions to use.
11+
*/
12+
readonly definitions: LocaleDefinition;
13+
/**
14+
* The randomizer used to generate random values.
15+
*/
16+
readonly randomizer: Randomizer;
17+
/**
18+
* The configuration options for all methods.
19+
*/
20+
readonly config: FakerConfig;
21+
}

src/utils/get-default-ref-date.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { FakerCore } from '../faker-core';
2+
3+
/**
4+
* Gets a new reference date used to generate relative dates.
5+
*
6+
* If `fakerCore.config.defaultRefDate` is defined, it will be used to get the default reference date. Otherwise, the current date will be used.
7+
*
8+
* @param fakerCore The FakerCore instance to get it from.
9+
*
10+
* @returns The newly created default reference date.
11+
*
12+
* @since 10.4.0
13+
*/
14+
export function getDefaultRefDate(fakerCore: FakerCore): Date {
15+
return fakerCore.config.defaultRefDate?.() ?? new Date();
16+
}

src/utils/resolve-locale-data.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { LocaleDefinition } from '../definitions';
2+
import type { FakerCore } from '../faker-core';
3+
import { assertLocaleData } from '../internal/locale-proxy';
4+
5+
/**
6+
* A proxy for LocaleDefinition that marks all properties as required and throws an error when an entry is accessed that is not defined.
7+
*/
8+
export type LocaleProxy = Readonly<{
9+
[key in keyof LocaleDefinition]-?: LocaleProxyCategory<LocaleDefinition[key]>;
10+
}>;
11+
12+
type LocaleProxyCategory<T> = Readonly<{
13+
[key in keyof T]-?: LocaleProxyEntry<T[key]>;
14+
}>;
15+
16+
type LocaleProxyEntry<T> = unknown extends T ? T : Readonly<NonNullable<T>>;
17+
18+
/**
19+
* Resolves the locale data for the given category and entry.
20+
*
21+
* @template TCategory The category of the locale data to resolve.
22+
* @template TEntry The entry of the locale data to resolve.
23+
*
24+
* @param fakerCore The FakerCore instance to get the locale data from.
25+
* @param category The category of the locale data to resolve.
26+
* @param entry The entry of the locale data to resolve.
27+
*
28+
* @returns The resolved locale data for the given category and entry.
29+
*
30+
* @throws {FakerError} If the category or entry is not defined in the locale data.
31+
*
32+
* @example
33+
* arrayElements(fakerCore, resolveLocaleData(fakerCore, 'date', 'weekday')); // 'Sunday'
34+
*
35+
* @since 10.4.0
36+
*/
37+
export function resolveLocaleData<
38+
const TCategory extends keyof LocaleProxy,
39+
const TEntry extends keyof LocaleProxy[TCategory] & string,
40+
>(
41+
fakerCore: FakerCore,
42+
category: TCategory,
43+
entry: TEntry
44+
): LocaleProxy[TCategory][TEntry] {
45+
const categoryData = fakerCore.definitions[category];
46+
assertLocaleData(categoryData, category);
47+
48+
const entryData = categoryData[entry];
49+
assertLocaleData(entryData, category, entry);
50+
return entryData;
51+
}

0 commit comments

Comments
 (0)