Skip to content

Commit 8e2db27

Browse files
committed
feat: use ts-morph and quicktype
1 parent 575d9b1 commit 8e2db27

9 files changed

Lines changed: 256 additions & 48 deletions

File tree

packages/cli/src/brownfield/commands/packageAndroid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const packageAndroidCommand = curryOptions(
3232
});
3333

3434
await runBrownieCodegenIfApplicable(projectRoot, 'kotlin');
35-
runNavigationCodegenIfApplicable(projectRoot);
35+
await runNavigationCodegenIfApplicable(projectRoot);
3636

3737
await packageAarAction({
3838
projectRoot,

packages/cli/src/brownfield/commands/packageIos.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const packageIosCommand = curryOptions(
7777
projectRoot,
7878
'swift'
7979
);
80-
runNavigationCodegenIfApplicable(projectRoot);
80+
await runNavigationCodegenIfApplicable(projectRoot);
8181

8282
await packageIosAction(
8383
options,

packages/cli/src/brownfield/commands/publishAndroid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const publishAndroidCommand = curryOptions(
3030
});
3131

3232
await runBrownieCodegenIfApplicable(projectRoot, 'kotlin');
33-
runNavigationCodegenIfApplicable(projectRoot);
33+
await runNavigationCodegenIfApplicable(projectRoot);
3434

3535
await publishLocalAarAction({
3636
projectRoot,

packages/cli/src/navigation/commands/codegen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export async function runNavigationCodegenCommand({
1818
dryRun = false,
1919
}: NavigationCodegenActionOptions): Promise<void> {
2020
intro('Running Brownfield Navigation codegen');
21-
runNavigationCodegen({
21+
await runNavigationCodegen({
2222
specPath,
2323
dryRun,
2424
});
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import {
4+
FetchingJSONSchemaStore,
5+
InputData,
6+
JSONSchemaInput,
7+
quicktype,
8+
} from 'quicktype-core';
9+
import { schemaForTypeScriptSources } from 'quicktype-typescript-input';
10+
11+
import type { MethodSignature } from '../types.js';
12+
13+
export interface NavigationModelsOptions {
14+
specPath: string;
15+
methods: MethodSignature[];
16+
kotlinPackageName: string;
17+
}
18+
19+
export interface GeneratedNavigationModels {
20+
swiftModels?: string;
21+
kotlinModels?: string;
22+
modelTypeNames: string[];
23+
}
24+
25+
const SKIP_TYPE_TOKENS = new Set([
26+
'Array',
27+
'Date',
28+
'Map',
29+
'Object',
30+
'Promise',
31+
'ReadonlyArray',
32+
'Record',
33+
'Set',
34+
'any',
35+
'boolean',
36+
'false',
37+
'null',
38+
'number',
39+
'object',
40+
'string',
41+
'true',
42+
'undefined',
43+
'unknown',
44+
'void',
45+
]);
46+
47+
function collectReferencedTypes(methods: MethodSignature[]): Set<string> {
48+
const referenced = new Set<string>();
49+
50+
for (const method of methods) {
51+
const typeTexts = [
52+
method.returnType,
53+
...method.params.map((param) => param.type),
54+
];
55+
for (const typeText of typeTexts) {
56+
const matches = typeText.match(/\b[A-Za-z_]\w*\b/g);
57+
if (!matches) {
58+
continue;
59+
}
60+
for (const match of matches) {
61+
if (!SKIP_TYPE_TOKENS.has(match)) {
62+
referenced.add(match);
63+
}
64+
}
65+
}
66+
}
67+
68+
return referenced;
69+
}
70+
71+
async function generateModelsForLanguage({
72+
typeNames,
73+
schema,
74+
lang,
75+
kotlinPackageName,
76+
}: {
77+
typeNames: string[];
78+
schema: object;
79+
lang: 'swift' | 'kotlin';
80+
kotlinPackageName: string;
81+
}): Promise<string> {
82+
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
83+
84+
for (const typeName of typeNames) {
85+
const rootSchema = JSON.parse(JSON.stringify(schema)) as {
86+
$ref?: string;
87+
};
88+
rootSchema.$ref = `#/definitions/${typeName}`;
89+
90+
await schemaInput.addSource({
91+
name: typeName,
92+
schema: JSON.stringify(rootSchema),
93+
});
94+
}
95+
96+
const inputData = new InputData();
97+
inputData.addInput(schemaInput);
98+
99+
const rendererOptions =
100+
lang === 'swift'
101+
? {
102+
'access-level': 'public',
103+
'mutable-properties': 'true',
104+
initializers: 'false',
105+
'swift-5-support': 'true',
106+
}
107+
: {
108+
framework: 'just-types',
109+
package: kotlinPackageName,
110+
};
111+
112+
const { lines } = await quicktype({
113+
inputData,
114+
lang,
115+
rendererOptions,
116+
});
117+
118+
return lines.join('\n');
119+
}
120+
121+
export async function generateNavigationModels({
122+
specPath,
123+
methods,
124+
kotlinPackageName,
125+
}: NavigationModelsOptions): Promise<GeneratedNavigationModels> {
126+
const absoluteSpecPath = path.resolve(process.cwd(), specPath);
127+
128+
if (!fs.existsSync(absoluteSpecPath)) {
129+
throw new Error(`Spec file not found: ${absoluteSpecPath}`);
130+
}
131+
132+
const schemaData = schemaForTypeScriptSources([absoluteSpecPath]);
133+
if (!schemaData.schema) {
134+
throw new Error('Failed to generate schema from TypeScript spec');
135+
}
136+
137+
const parsedSchema = JSON.parse(schemaData.schema) as {
138+
definitions?: Record<string, unknown>;
139+
};
140+
const referencedTypes = collectReferencedTypes(methods);
141+
const definitions = parsedSchema.definitions ?? {};
142+
const modelTypeNames = [...referencedTypes].filter((typeName) =>
143+
Object.hasOwn(definitions, typeName)
144+
);
145+
146+
if (modelTypeNames.length === 0) {
147+
return { modelTypeNames: [] };
148+
}
149+
150+
const [swiftModels, kotlinModels] = await Promise.all([
151+
generateModelsForLanguage({
152+
typeNames: modelTypeNames,
153+
schema: parsedSchema,
154+
lang: 'swift',
155+
kotlinPackageName,
156+
}),
157+
generateModelsForLanguage({
158+
typeNames: modelTypeNames,
159+
schema: parsedSchema,
160+
lang: 'kotlin',
161+
kotlinPackageName,
162+
}),
163+
]);
164+
165+
return {
166+
swiftModels,
167+
kotlinModels,
168+
modelTypeNames,
169+
};
170+
}

packages/cli/src/navigation/helpers/runNavigationCodegenIfApplicable.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { isNavigationInstalled } from '../config.js';
22
import { isNavigationSpecPresent } from '../spec-discovery.js';
33
import { runNavigationCodegen } from '../runner.js';
44

5-
export function runNavigationCodegenIfApplicable(
5+
export async function runNavigationCodegenIfApplicable(
66
projectRoot: string,
77
specPath?: string
8-
): { hasNavigation: boolean; hasSpec: boolean } {
8+
): Promise<{ hasNavigation: boolean; hasSpec: boolean }> {
99
const hasNavigation = isNavigationInstalled(projectRoot);
1010
const hasSpec = hasNavigation && isNavigationSpecPresent(specPath, projectRoot);
1111

1212
if (hasSpec) {
13-
runNavigationCodegen({ specPath, projectRoot });
13+
await runNavigationCodegen({ specPath, projectRoot });
1414
}
1515

1616
return { hasNavigation, hasSpec };
Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,43 @@
11
import fs from 'node:fs';
2+
import { Project } from 'ts-morph';
23

34
import type { MethodParam, MethodSignature } from './types.js';
45

56
export function parseNavigationSpec(specPath: string): MethodSignature[] {
6-
const content = fs.readFileSync(specPath, 'utf-8');
7+
if (!fs.existsSync(specPath)) {
8+
throw new Error(`Spec file not found: ${specPath}`);
9+
}
710

8-
const interfaceMatch = content.match(
9-
/export interface (?:BrownfieldNavigationSpec|Spec)\s*\{([^}]+)\}/s
10-
);
11+
const project = new Project({ skipAddingFilesFromTsConfig: true });
12+
const sourceFile = project.addSourceFileAtPath(specPath);
13+
const specInterface =
14+
sourceFile.getInterface('BrownfieldNavigationSpec') ??
15+
sourceFile.getInterface('Spec');
1116

12-
if (!interfaceMatch?.[1]) {
17+
if (!specInterface) {
1318
throw new Error(
1419
'Could not find BrownfieldNavigationSpec or Spec interface in spec file'
1520
);
1621
}
1722

18-
const methods: MethodSignature[] = [];
19-
const methodRegex = /(\w+)\s*\(([^)]*)\)\s*:\s*(Promise<[^>]+>|[^;]+)\s*;/g;
20-
21-
let match: RegExpExecArray | null;
22-
while ((match = methodRegex.exec(interfaceMatch[1])) !== null) {
23-
const name = match[1];
24-
const paramsStr = match[2];
25-
const returnType = match[3];
26-
27-
if (!name || !returnType) {
28-
continue;
29-
}
30-
31-
const params: MethodParam[] = [];
32-
if (paramsStr?.trim()) {
33-
const paramParts = paramsStr.split(',');
34-
for (const param of paramParts) {
35-
const paramMatch = param.trim().match(/(\w+)(\?)?:\s*(.+)/);
36-
if (paramMatch?.[1] && paramMatch[3]) {
37-
params.push({
38-
name: paramMatch[1],
39-
type: paramMatch[3].trim(),
40-
optional: Boolean(paramMatch[2]),
41-
});
42-
}
43-
}
44-
}
23+
return specInterface.getMethods().map((method): MethodSignature => {
24+
const name = method.getName();
25+
const params: MethodParam[] = method.getParameters().map((param) => {
26+
const typeNode = param.getTypeNode();
27+
return {
28+
name: param.getName(),
29+
type: typeNode?.getText() ?? 'unknown',
30+
optional: param.isOptional(),
31+
};
32+
});
33+
const returnTypeNode = method.getReturnTypeNode();
34+
const returnType = returnTypeNode?.getText() ?? 'void';
4535

46-
methods.push({
36+
return {
4737
name,
4838
params,
49-
returnType: returnType.trim(),
50-
isAsync: returnType.trim().startsWith('Promise<'),
51-
});
52-
}
53-
54-
return methods;
39+
returnType,
40+
isAsync: returnType.startsWith('Promise<'),
41+
};
42+
});
5543
}

0 commit comments

Comments
 (0)