codz-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Flutter adapter, part 2: writing Dart (models, repository methods, imports,
|
|
2
|
+
// class members, GetX registrations).
|
|
3
|
+
import { commentText } from "../core/safe.js";
|
|
4
|
+
import { ToolError } from "../core/types.js";
|
|
5
|
+
import { DART_RESERVED, identifier } from "../input/naming.js";
|
|
6
|
+
import { referencedModels } from "./models.js";
|
|
7
|
+
import { matchBrace } from "./source.js";
|
|
8
|
+
import { dartPathLiteral, dartType, dq, fieldName, fromJsonExpr, toJsonExpr } from "./dartGen.js";
|
|
9
|
+
import { renderFreezedModel } from "./flutterStyles.js";
|
|
10
|
+
import { relDir } from "./flutterScan.js";
|
|
11
|
+
export function renderModel(m, g, fileBase) {
|
|
12
|
+
if (g.c.serialization === 'freezed')
|
|
13
|
+
return renderFreezedModel(m, g, fileBase);
|
|
14
|
+
const cls = g.cls(m.name);
|
|
15
|
+
const fields = m.fields.map((f) => ({ f, name: fieldName(f), type: dartType(f.type, g) + (f.nullable && f.type.kind !== 'unknown' ? '?' : '') }));
|
|
16
|
+
const L = [];
|
|
17
|
+
const json = g.c.serialization === 'json_serializable';
|
|
18
|
+
if (json) {
|
|
19
|
+
L.push(`import 'package:json_annotation/json_annotation.dart';`, '', `part '${fileBase}.g.dart';`, '');
|
|
20
|
+
const nested = m.fields.some((f) => referencedModels(f.type).size > 0);
|
|
21
|
+
let ann = g.c.jsonAnnotation || '@JsonSerializable()';
|
|
22
|
+
if (nested && !/explicitToJson/.test(ann))
|
|
23
|
+
ann = ann === '@JsonSerializable()' ? '@JsonSerializable(explicitToJson: true)' : ann.replace(/\)$/, ', explicitToJson: true)');
|
|
24
|
+
L.push(ann);
|
|
25
|
+
}
|
|
26
|
+
L.push(`class ${cls} {`);
|
|
27
|
+
const required = fields.map((x) => (x.f.nullable || x.f.type.kind === 'unknown' ? ` this.${x.name},` : ` required this.${x.name},`));
|
|
28
|
+
L.push(` const ${cls}({`, ...required, ' });', '');
|
|
29
|
+
if (json) {
|
|
30
|
+
L.push(` factory ${cls}.fromJson(Map<String, dynamic> json) => _$${cls}FromJson(json);`, '');
|
|
31
|
+
for (const x of fields) {
|
|
32
|
+
if (x.f.type.kind === 'unknown' || (x.f.type.kind === 'array' && x.f.type.item.kind === 'unknown'))
|
|
33
|
+
L.push(' // codz: type unknown from the samples; set it by hand.');
|
|
34
|
+
if (x.name !== x.f.jsonName)
|
|
35
|
+
L.push(` @JsonKey(name: ${dq(x.f.jsonName)})`);
|
|
36
|
+
L.push(` final ${x.type} ${x.name};`);
|
|
37
|
+
}
|
|
38
|
+
L.push('', ` Map<String, dynamic> toJson() => _$${cls}ToJson(this);`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
L.push(` factory ${cls}.fromJson(Map<String, dynamic> json) => ${cls}(`);
|
|
42
|
+
for (const x of fields)
|
|
43
|
+
L.push(` ${x.name}: ${fromJsonExpr(x.f.type, `json[${dq(x.f.jsonName)}]`, x.f.nullable, g)},`);
|
|
44
|
+
L.push(' );', '');
|
|
45
|
+
for (const x of fields) {
|
|
46
|
+
if (x.f.type.kind === 'unknown' || (x.f.type.kind === 'array' && x.f.type.item.kind === 'unknown'))
|
|
47
|
+
L.push(' // codz: type unknown from the samples; set it by hand.');
|
|
48
|
+
L.push(` final ${x.type} ${x.name};`);
|
|
49
|
+
}
|
|
50
|
+
L.push('', ' Map<String, dynamic> toJson() => {');
|
|
51
|
+
for (const x of fields)
|
|
52
|
+
L.push(` ${dq(x.f.jsonName)}: ${toJsonExpr(x.f.type, x.name, x.f.nullable)},`);
|
|
53
|
+
L.push(' };');
|
|
54
|
+
}
|
|
55
|
+
L.push('}');
|
|
56
|
+
return L.join('\n');
|
|
57
|
+
}
|
|
58
|
+
export function indentStyled(code, g) {
|
|
59
|
+
if (g.indent === ' ')
|
|
60
|
+
return code;
|
|
61
|
+
return code
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((l) => {
|
|
64
|
+
const m = l.match(/^( +)/);
|
|
65
|
+
return m ? g.indent.repeat(Math.floor(m[1].length / 2)) + ' '.repeat(m[1].length % 2) + l.slice(m[1].length) : l;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
69
|
+
/** Import URI for `toFile` as seen from `fromFile`. */
|
|
70
|
+
export function importUri(fromFile, toFile, c) {
|
|
71
|
+
if (c.importStyle === 'package' && c.packageName && toFile.startsWith('lib/'))
|
|
72
|
+
return `package:${c.packageName}/${toFile.slice(4)}`;
|
|
73
|
+
const from = relDir(fromFile).split('/');
|
|
74
|
+
const to = toFile.split('/');
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < from.length && i < to.length - 1 && from[i] === to[i])
|
|
77
|
+
i++;
|
|
78
|
+
const up = from.length - i;
|
|
79
|
+
return [...Array(up).fill('..'), ...to.slice(i)].join('/');
|
|
80
|
+
}
|
|
81
|
+
/** Adds `import '<uri>';` keeping Dart's grouping (dart:, package:, relative) and alphabetical order. */
|
|
82
|
+
export function addDartImport(text, uri) {
|
|
83
|
+
if (new RegExp(`^import\\s+['"]${uri.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"]`, 'm').test(text))
|
|
84
|
+
return text;
|
|
85
|
+
const line = `import '${uri}';`;
|
|
86
|
+
const kind = (u) => (u.startsWith('dart:') ? 0 : u.startsWith('package:') ? 1 : 2);
|
|
87
|
+
const lines = text.split('\n');
|
|
88
|
+
const importIdx = lines.map((l, i) => ({ l, i })).filter((x) => /^import\s+['"]/.test(x.l));
|
|
89
|
+
if (!importIdx.length) {
|
|
90
|
+
// After a library/part directive if present, else at the top.
|
|
91
|
+
const lib = lines.findIndex((l) => /^(library|part of)\b/.test(l));
|
|
92
|
+
lines.splice(lib + 1, 0, ...(lib >= 0 ? [''] : []), line, '');
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
const same = importIdx.filter((x) => kind(x.l.match(/['"]([^'"]+)['"]/)[1]) === kind(uri));
|
|
96
|
+
if (same.length) {
|
|
97
|
+
const uris = same.map((x) => x.l.match(/['"]([^'"]+)['"]/)[1]);
|
|
98
|
+
const sorted = uris.every((u, i) => i === 0 || uris[i - 1] <= u);
|
|
99
|
+
// Keep alphabetical order when the file already uses it; otherwise append to the group.
|
|
100
|
+
const after = sorted ? same.find((x) => x.l.match(/['"]([^'"]+)['"]/)[1] > uri) : undefined;
|
|
101
|
+
if (after)
|
|
102
|
+
lines.splice(after.i, 0, line);
|
|
103
|
+
else
|
|
104
|
+
lines.splice(same[same.length - 1].i + 1, 0, line);
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
// New group: place after the last import of a lower kind, separated by a blank line.
|
|
108
|
+
const lower = importIdx.filter((x) => kind(x.l.match(/['"]([^'"]+)['"]/)[1]) < kind(uri));
|
|
109
|
+
if (lower.length) {
|
|
110
|
+
lines.splice(lower[lower.length - 1].i + 1, 0, '', line);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
lines.splice(importIdx[0].i, 0, line, '');
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
/** Inserts `member` before the closing brace of `class name`. */
|
|
118
|
+
export function addToClass(text, className, member) {
|
|
119
|
+
const m = new RegExp(`\\bclass\\s+${className}\\b[^{]*\\{`).exec(text);
|
|
120
|
+
if (!m)
|
|
121
|
+
throw new ToolError('class_not_found', `Could not find class ${className}.`);
|
|
122
|
+
const close = matchBrace(text, m.index + m[0].length - 1, 'dart');
|
|
123
|
+
if (close === -1)
|
|
124
|
+
throw new ToolError('class_not_found', `Could not find the end of class ${className}.`);
|
|
125
|
+
const before = text.slice(0, close).replace(/\s*$/, '');
|
|
126
|
+
return `${before}\n\n${member}\n${text.slice(close)}`;
|
|
127
|
+
}
|
|
128
|
+
export function renderMethod(spec, g, path, field) {
|
|
129
|
+
const params = spec.pathParams.map((p) => `${p.type === 'int' ? 'int' : 'String'} ${p.name}`);
|
|
130
|
+
if (spec.request)
|
|
131
|
+
params.push(`${dartType(spec.request, g)} body`);
|
|
132
|
+
const named = spec.queryParams.map((q) => ({ key: q.name, name: identifier(q.name, DART_RESERVED), type: dartType(q.type, g) }));
|
|
133
|
+
const sig = named.length ? `${params.join(', ')}${params.length ? ', ' : ''}{${named.map((n) => `${n.type}? ${n.name}`).join(', ')}}` : params.join(', ');
|
|
134
|
+
const verb = spec.method.toLowerCase();
|
|
135
|
+
const r = spec.response;
|
|
136
|
+
const noBody = spec.method === 'DELETE' || spec.method === 'HEAD' || !r;
|
|
137
|
+
const resultType = noBody ? (spec.method === 'DELETE' || spec.method === 'HEAD' ? 'void' : 'dynamic') : dartType(r, g);
|
|
138
|
+
const isList = r?.kind === 'array';
|
|
139
|
+
const typeArg = noBody ? (resultType === 'void' ? 'void' : 'dynamic') : spec.unwrapData ? 'Map<String, dynamic>' : isList ? 'List<dynamic>' : r.kind === 'object' ? 'Map<String, dynamic>' : 'dynamic';
|
|
140
|
+
const args = [dartPathLiteral(path)];
|
|
141
|
+
if (spec.request)
|
|
142
|
+
args.push(`data: ${toJsonExpr(spec.request, 'body', false)}`);
|
|
143
|
+
if (named.length)
|
|
144
|
+
args.push(`queryParameters: {${named.map((n) => `if (${n.name} != null) ${dq(n.key)}: ${n.name}`).join(', ')}}`);
|
|
145
|
+
const L = [];
|
|
146
|
+
L.push(` /// ${spec.method} ${commentText(spec.path)}`);
|
|
147
|
+
L.push(` Future<${resultType}> ${spec.name}(${sig}) async {`);
|
|
148
|
+
const call = `${field}.${verb}<${typeArg}>(${args.length > 1 ? `\n ${args.join(',\n ')},\n ` : args[0]})`;
|
|
149
|
+
if (resultType === 'void') {
|
|
150
|
+
L.push(` await ${call};`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
L.push(` final response = await ${call};`);
|
|
154
|
+
const raw = spec.unwrapData ? `response.data!['data']` : 'response.data';
|
|
155
|
+
if (noBody || r.kind === 'unknown')
|
|
156
|
+
L.push(` return ${raw};`);
|
|
157
|
+
else if (r.kind === 'object')
|
|
158
|
+
L.push(` return ${g.cls(r.model)}.fromJson(${spec.unwrapData ? `${raw} as Map<String, dynamic>` : `${raw}!`});`);
|
|
159
|
+
else if (isList) {
|
|
160
|
+
const list = spec.unwrapData ? `(${raw} as List<dynamic>)` : `${raw}!`;
|
|
161
|
+
L.push(` return ${list}.map((e) => ${fromJsonExpr(r.kind === 'array' ? r.item : r, 'e', false, g)}).toList();`);
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
L.push(` return ${fromJsonExpr(r, spec.unwrapData ? raw : `${raw}`, false, g)};`);
|
|
165
|
+
}
|
|
166
|
+
L.push(' }');
|
|
167
|
+
return L.join('\n');
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Adds a registration line after the last Get.put / Get.lazyPut statement, or
|
|
171
|
+
* at the end of `dependencies()` when there is none. Returns null if neither exists.
|
|
172
|
+
*/
|
|
173
|
+
export function insertGetxRegistration(text, line) {
|
|
174
|
+
const calls = [...text.matchAll(/\bGet\.(?:lazyPut|put|putAsync|create)\s*(?:<[^>(]*>)?\s*\(/g)];
|
|
175
|
+
if (calls.length) {
|
|
176
|
+
const last = calls[calls.length - 1];
|
|
177
|
+
const open = last.index + last[0].length - 1;
|
|
178
|
+
const close = matchBrace(text, open, 'dart');
|
|
179
|
+
if (close !== -1) {
|
|
180
|
+
const semi = text.indexOf(';', close);
|
|
181
|
+
if (semi !== -1) {
|
|
182
|
+
const lineStart = text.lastIndexOf('\n', last.index) + 1;
|
|
183
|
+
const indent = text.slice(lineStart, last.index).match(/^[ \t]*/)[0];
|
|
184
|
+
return `${text.slice(0, semi + 1)}\n${indent}${line}${text.slice(semi + 1)}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const dep = /void\s+dependencies\s*\(\s*\)\s*\{/.exec(text);
|
|
189
|
+
if (!dep)
|
|
190
|
+
return null;
|
|
191
|
+
const close = matchBrace(text, dep.index + dep[0].length - 1, 'dart');
|
|
192
|
+
if (close === -1)
|
|
193
|
+
return null;
|
|
194
|
+
const before = text.slice(0, close).replace(/\s*$/, '');
|
|
195
|
+
return `${before}\n ${line}\n ${text.slice(close)}`;
|
|
196
|
+
}
|
|
197
|
+
export function resourceOf(path) {
|
|
198
|
+
return path.split('/').filter((s) => s && !s.startsWith('{') && !/^(api|v\d+(\.\d+)?|rest)$/i.test(s))[0] ?? 'root';
|
|
199
|
+
}
|
|
200
|
+
export function r0(spec, g) {
|
|
201
|
+
if (spec.method === 'DELETE' || spec.method === 'HEAD')
|
|
202
|
+
return 'void';
|
|
203
|
+
return spec.response ? dartType(spec.response, g) : 'dynamic';
|
|
204
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Flutter adapter, part 1: reading a project (pubspec, conventions, models,
|
|
2
|
+
// endpoints, routes, locales) into a ProjectMap.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDir, readText, walk } from "../core/fsutil.js";
|
|
5
|
+
import {} from "../core/types.js";
|
|
6
|
+
import { snake } from "../input/naming.js";
|
|
7
|
+
import { emptyMap, fileStats, scanLocales } from "./common.js";
|
|
8
|
+
import { detectIndent, matchBrace, skipString } from "./source.js";
|
|
9
|
+
import { parseFreezedFields, scanRetrofit } from "./flutterStyles.js";
|
|
10
|
+
import { VERSION } from "./flutter.js";
|
|
11
|
+
const PLATFORM_DIRS = ['android', 'ios', 'macos', 'windows', 'linux', 'web', 'test', 'integration_test'];
|
|
12
|
+
export const relDir = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '.');
|
|
13
|
+
export const base = (f) => f.slice(f.lastIndexOf('/') + 1).replace(/\.dart$/, '');
|
|
14
|
+
export function mostCommon(items, fallback) {
|
|
15
|
+
const counts = new Map();
|
|
16
|
+
for (const i of items)
|
|
17
|
+
counts.set(i, (counts.get(i) ?? 0) + 1);
|
|
18
|
+
let best = fallback;
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (const [k, v] of counts)
|
|
21
|
+
if (v > n)
|
|
22
|
+
[best, n] = [k, v];
|
|
23
|
+
return best;
|
|
24
|
+
}
|
|
25
|
+
export function pubspecDeps(text) {
|
|
26
|
+
const deps = new Set();
|
|
27
|
+
let inDeps = false;
|
|
28
|
+
for (const line of text.split(/\r?\n/)) {
|
|
29
|
+
if (/^(dependencies|dev_dependencies):\s*$/.test(line)) {
|
|
30
|
+
inDeps = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (/^\s*(#|$)/.test(line))
|
|
34
|
+
continue; // comments and blank lines do not end a section
|
|
35
|
+
if (/^\S/.test(line))
|
|
36
|
+
inDeps = false;
|
|
37
|
+
const m = line.match(/^ {2}([a-z0-9_]+):/);
|
|
38
|
+
if (inDeps && m)
|
|
39
|
+
deps.add(m[1]);
|
|
40
|
+
}
|
|
41
|
+
return deps;
|
|
42
|
+
}
|
|
43
|
+
/** Converts a Dart string literal body with $x / ${x} into a path with {x}. */
|
|
44
|
+
export function dartPath(literal) {
|
|
45
|
+
return literal
|
|
46
|
+
.replace(/\$\{([^}]*)\}/g, (_s, e) => `{${(e.match(/[A-Za-z_]\w*/g) ?? ['param']).pop()}}`)
|
|
47
|
+
.replace(/\$([A-Za-z_]\w*)/g, '{$1}')
|
|
48
|
+
.split('?')[0];
|
|
49
|
+
}
|
|
50
|
+
/** A class-level field declaration: `final String? name;`, `int? id;`, `late List<Item> items;`. */
|
|
51
|
+
const FIELD_LINE = /^\s*(?:late\s+)?(?:final\s+)?((?:[A-Z][\w]*|int|double|num|bool|dynamic)(?:<[\w<>, ?]*>)?\??)\s+(\w+)\s*;\s*(?:\/\/.*)?$/;
|
|
52
|
+
/** A Dio field in a class, in any of the common styles. */
|
|
53
|
+
export const DIO_FIELD = /\b(?:static\s+)?(?:late\s+)?(?:final\s+)?Dio\??\s+(\w+)\s*(?:=[^;]*)?;/;
|
|
54
|
+
export function parseDartModels(text, file) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const re = /\bclass\s+(\w+)(?:<[^>]*>)?[^{]*\{/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(text))) {
|
|
59
|
+
const open = m.index + m[0].length - 1;
|
|
60
|
+
const close = matchBrace(text, open, 'dart');
|
|
61
|
+
if (close === -1)
|
|
62
|
+
continue;
|
|
63
|
+
const body = text.slice(open + 1, close);
|
|
64
|
+
if (!/fromJson/.test(body)) {
|
|
65
|
+
re.lastIndex = close;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const freezed = parseFreezedFields(body, m[1]);
|
|
69
|
+
if (freezed) {
|
|
70
|
+
out.push({ name: m[1], file, fields: freezed });
|
|
71
|
+
re.lastIndex = close;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const fields = [];
|
|
75
|
+
// Class-level fields only (depth 0 inside the class), final or not.
|
|
76
|
+
// @JsonKey(name: ...) on the same or the previous line keeps the JSON name.
|
|
77
|
+
let depth = 0;
|
|
78
|
+
let jsonKey;
|
|
79
|
+
for (const line of body.split('\n')) {
|
|
80
|
+
if (depth === 0) {
|
|
81
|
+
const key = line.match(/@JsonKey\(\s*name:\s*['"]([^'"]+)['"]/);
|
|
82
|
+
if (key)
|
|
83
|
+
jsonKey = key[1];
|
|
84
|
+
const f = line.replace(/@JsonKey\([^)]*\)\s*/, '').match(FIELD_LINE);
|
|
85
|
+
if (f) {
|
|
86
|
+
const type = f[1].trim();
|
|
87
|
+
fields.push({ name: jsonKey ?? snake(f[2]), type: type.replace(/\?$/, ''), nullable: type.endsWith('?') });
|
|
88
|
+
jsonKey = undefined;
|
|
89
|
+
}
|
|
90
|
+
else if (!key && line.trim() !== '') {
|
|
91
|
+
jsonKey = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
depth += (line.match(/[{(\[]/g) ?? []).length - (line.match(/[})\]]/g) ?? []).length;
|
|
95
|
+
}
|
|
96
|
+
out.push({ name: m[1], file, fields });
|
|
97
|
+
re.lastIndex = close;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export async function scan(root, configConventions) {
|
|
102
|
+
const map = emptyMap('flutter', `flutter@${VERSION}`);
|
|
103
|
+
const files = walk(root, '.', { extraIgnored: PLATFORM_DIRS });
|
|
104
|
+
map.stats = fileStats(files);
|
|
105
|
+
const dart = files.filter((f) => f.startsWith('lib/') && f.endsWith('.dart') && !/\.(g|freezed|gr)\.dart$/.test(f));
|
|
106
|
+
const texts = new Map();
|
|
107
|
+
const text = (f) => {
|
|
108
|
+
if (!texts.has(f))
|
|
109
|
+
texts.set(f, readText(join(root, f)) ?? '');
|
|
110
|
+
return texts.get(f);
|
|
111
|
+
};
|
|
112
|
+
const pubspec = readText(join(root, 'pubspec.yaml')) ?? '';
|
|
113
|
+
const deps = pubspecDeps(pubspec);
|
|
114
|
+
const c = {};
|
|
115
|
+
c.packageName = pubspec.match(/^name:\s*([\w]+)/m)?.[1] ?? '';
|
|
116
|
+
c.http = deps.has('dio') ? 'dio' : deps.has('http') ? 'http' : 'dio';
|
|
117
|
+
c.serialization = deps.has('freezed') && dart.some((f) => /@freezed/.test(text(f))) ? 'freezed' : deps.has('json_serializable') ? 'json_serializable' : 'manual';
|
|
118
|
+
c.stateManagement = deps.has('flutter_riverpod') || deps.has('hooks_riverpod') || deps.has('riverpod') ? 'riverpod' : deps.has('flutter_bloc') ? 'bloc' : deps.has('get') ? 'getx' : deps.has('provider') ? 'provider' : 'none';
|
|
119
|
+
if (c.serialization === 'freezed') {
|
|
120
|
+
const styles = dart.flatMap((f) => [...text(f).matchAll(/@freezed\s+(abstract\s+|sealed\s+)?class\b/g)].map((x) => (x[1] ? 'abstract' : 'plain')));
|
|
121
|
+
const major = Number(pubspec.match(/^\s+freezed:\s*\^?(\d+)/m)?.[1] ?? '3');
|
|
122
|
+
c.freezedClass = styles.length ? mostCommon(styles, 'abstract') : major >= 3 ? 'abstract' : 'plain';
|
|
123
|
+
}
|
|
124
|
+
const restApiFiles = deps.has('retrofit') ? dart.filter((f) => /@RestApi\s*\(/.test(text(f))) : [];
|
|
125
|
+
if (deps.has('retrofit') && (restApiFiles.length > 0 || !dart.some((f) => DIO_FIELD.test(text(f)))))
|
|
126
|
+
c.apiStyle = 'retrofit';
|
|
127
|
+
c.router = deps.has('go_router') ? 'go_router' : deps.has('auto_route') ? 'auto_route' : 'navigator';
|
|
128
|
+
// Models.
|
|
129
|
+
const modelFiles = dart.filter((f) => /\b\w+\.fromJson\s*\(\s*(?:Map|dynamic)/.test(text(f)) && /\bclass\s+\w+/.test(text(f)));
|
|
130
|
+
// No models yet: an existing (empty) models folder says where they go, as starters and layered templates set up.
|
|
131
|
+
const emptyModels = ['lib/data/models', 'lib/models', 'lib/domain/models', 'lib/core/models'].find((d) => isDir(join(root, d)));
|
|
132
|
+
c.modelsFolder = mostCommon(modelFiles.map(relDir), emptyModels ?? 'lib/models');
|
|
133
|
+
{
|
|
134
|
+
// Feature-first projects keep models in many folders; no single one is "the" models folder.
|
|
135
|
+
const dirs = new Set(modelFiles.map(relDir));
|
|
136
|
+
const inBest = modelFiles.filter((f) => relDir(f) === c.modelsFolder).length;
|
|
137
|
+
if (dirs.size >= 3 && inBest * 2 < modelFiles.length) {
|
|
138
|
+
map.notes.push(`Models live in ${dirs.size} different folders, so new ones go to lib/models. Use --into <folder> or set modelsFolder in codz.config.yaml to choose.`);
|
|
139
|
+
c.modelsFolder = 'lib/models';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const modelBases = modelFiles.filter((f) => relDir(f) === c.modelsFolder).map(base);
|
|
143
|
+
c.modelFileSuffix = modelBases.length && modelBases.filter((b) => b.endsWith('_model')).length * 2 >= modelBases.length ? '_model' : '';
|
|
144
|
+
for (const f of modelFiles)
|
|
145
|
+
map.models.push(...parseDartModels(text(f), f));
|
|
146
|
+
const classNames = map.models.map((m) => m.name);
|
|
147
|
+
c.modelClassSuffix = classNames.length && classNames.filter((n) => n.endsWith('Model')).length * 2 >= classNames.length ? 'Model' : '';
|
|
148
|
+
const annotations = modelFiles.flatMap((f) => text(f).match(/@JsonSerializable\([^)]*\)/g) ?? []);
|
|
149
|
+
c.jsonAnnotation = c.serialization === 'json_serializable' ? mostCommon(annotations, '@JsonSerializable()') : '';
|
|
150
|
+
// Repositories / services: classes holding a Dio.
|
|
151
|
+
const repoFiles = dart.filter((f) => DIO_FIELD.test(text(f)) && /\bclass\s+\w+/.test(text(f)));
|
|
152
|
+
c.repoFolder = c.apiStyle === 'retrofit' ? mostCommon(restApiFiles.map(relDir), 'lib/data/api') : mostCommon(repoFiles.map(relDir), 'lib/data/repositories');
|
|
153
|
+
const repoClasses = repoFiles.flatMap((f) => [...text(f).matchAll(/\bclass\s+(\w+)/g)].map((m) => m[1]));
|
|
154
|
+
c.repoKind = mostCommon(repoClasses.map((n) => (/Repository$/.test(n) ? 'repository' : /Service$/.test(n) ? 'service' : /Api$/.test(n) ? 'api' : '')).filter(Boolean), 'repository');
|
|
155
|
+
c.dioField = mostCommon(repoFiles.map((f) => text(f).match(DIO_FIELD)?.[1] ?? ''), '_dio');
|
|
156
|
+
// Registration.
|
|
157
|
+
const dioProv = dart.map((f) => ({ f, m: text(f).match(/final\s+(\w+)\s*=\s*Provider<Dio>/) })).find((x) => x.m);
|
|
158
|
+
const getItFile = dart.find((f) => /\.registerLazySingleton|\.registerSingleton|\.registerFactory/.test(text(f)));
|
|
159
|
+
// GetX: a Bindings class that registers with Get.put / Get.lazyPut. Prefer the app-wide initial binding.
|
|
160
|
+
// A binding that only registers its module's controller is not where app-wide services go.
|
|
161
|
+
const registersService = (t) => [...t.matchAll(/\bGet\.(?:lazyPut|put|putAsync)\s*(?:<\s*(\w+)\s*>)?\s*\(\s*(?:\(\)\s*=>\s*)?(\w+)?/g)].some((m) => {
|
|
162
|
+
const type = m[1] ?? m[2] ?? '';
|
|
163
|
+
return type !== '' && !/Controller$/.test(type);
|
|
164
|
+
});
|
|
165
|
+
const bindings = dart.filter((f) => /\b(?:extends|implements)\s+Bindings\b/.test(text(f)) && registersService(text(f)));
|
|
166
|
+
const getxFile = bindings.find((f) => /initial|app_binding|global/i.test(f)) ??
|
|
167
|
+
[...bindings].sort((a, b) => (text(b).match(/\bGet\.(?:lazyPut|put)/g) ?? []).length - (text(a).match(/\bGet\.(?:lazyPut|put)/g) ?? []).length)[0];
|
|
168
|
+
if (dioProv && c.stateManagement === 'riverpod') {
|
|
169
|
+
c.di = 'riverpod';
|
|
170
|
+
c.dioProvider = dioProv.m[1];
|
|
171
|
+
c.dioProviderFile = dioProv.f;
|
|
172
|
+
}
|
|
173
|
+
else if (getxFile) {
|
|
174
|
+
c.di = 'getx';
|
|
175
|
+
c.diFile = getxFile;
|
|
176
|
+
}
|
|
177
|
+
else if (getItFile) {
|
|
178
|
+
c.di = 'get_it';
|
|
179
|
+
c.diFile = getItFile;
|
|
180
|
+
c.diInstance = text(getItFile).match(/((?:GetIt\.(?:I|instance))|\w+)\.register(?:LazySingleton|Singleton|Factory)/)?.[1] ?? 'getIt';
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
c.di = 'none';
|
|
184
|
+
}
|
|
185
|
+
// Base URL.
|
|
186
|
+
const baseUrls = dart.flatMap((f) => [...text(f).matchAll(/baseUrl:\s*['"](https?:\/\/[^'"]+)['"]/g), ...(/baseUrl:\s*\w+/.test(text(f)) ? text(f).matchAll(/String\.fromEnvironment\(\s*'[A-Z_]+'\s*,\s*defaultValue:\s*['"](https?:\/\/[^'"]+)['"]/g) : [])].map((m) => ({ f, v: m[1] })));
|
|
187
|
+
c.baseUrl = baseUrls[0]?.v ?? '';
|
|
188
|
+
map.baseUrls = baseUrls.map((b) => ({ name: 'Dio baseUrl', value: b.v, file: b.f }));
|
|
189
|
+
// Dio with a base URL codz cannot read (baseUrl: Env.apiUrl, dio.options.baseUrl = ...): calls still go relative to it.
|
|
190
|
+
map.clientBaseUrl = baseUrls.length > 0 || dart.some((f) => /\b(?:BaseOptions|Dio)\s*\([^;]*?\bbaseUrl\s*:|\.options\.baseUrl\s*=|@RestApi\(\s*baseUrl\s*:/s.test(text(f)));
|
|
191
|
+
// Import and code style.
|
|
192
|
+
const imports = dart.flatMap((f) => text(f).match(/^import\s+'([^']+)'/gm) ?? []);
|
|
193
|
+
const pkgImports = imports.filter((i) => c.packageName && i.includes(`package:${c.packageName}/`)).length;
|
|
194
|
+
const relImports = imports.filter((i) => !i.includes('package:') && !i.includes('dart:')).length;
|
|
195
|
+
c.importStyle = pkgImports > relImports ? 'package' : 'relative';
|
|
196
|
+
const ind = detectIndent(dart.slice(0, 30).map(text).join('\n'));
|
|
197
|
+
c.indent = ind === '\t' ? 'tab' : String(ind.length);
|
|
198
|
+
map.conventions = { ...c, ...configConventions };
|
|
199
|
+
const conv = map.conventions;
|
|
200
|
+
if (conv.http !== 'dio')
|
|
201
|
+
map.notes.push(`This project uses the ${conv.http} package. The supported setup is Dio, so \`api add\` is not available yet.`);
|
|
202
|
+
// Retrofit clients: annotated abstract methods.
|
|
203
|
+
for (const f of restApiFiles)
|
|
204
|
+
for (const e of scanRetrofit(text(f)))
|
|
205
|
+
map.endpoints.push({ name: e.name, method: e.method, path: e.path, response: e.response, file: f, calledFrom: [] });
|
|
206
|
+
// Endpoints: Future-returning methods that call the Dio field.
|
|
207
|
+
for (const f of repoFiles) {
|
|
208
|
+
const t = text(f);
|
|
209
|
+
const re = /Future<(.+?)>\s+(\w+)\s*\(([^)]*)\)\s*async\s*\{/g;
|
|
210
|
+
let m;
|
|
211
|
+
while ((m = re.exec(t))) {
|
|
212
|
+
const open = m.index + m[0].length - 1;
|
|
213
|
+
const close = matchBrace(t, open, 'dart');
|
|
214
|
+
if (close === -1)
|
|
215
|
+
continue;
|
|
216
|
+
const body = t.slice(open, close + 1);
|
|
217
|
+
const call = body.match(/\.(get|post|put|patch|delete|head)\s*(?:<[^(]*>)?\s*\(\s*/);
|
|
218
|
+
if (!call)
|
|
219
|
+
continue;
|
|
220
|
+
const at = call.index + call[0].length;
|
|
221
|
+
const q = body[at];
|
|
222
|
+
if (q !== "'" && q !== '"')
|
|
223
|
+
continue;
|
|
224
|
+
const end = skipString(body, at, 'dart');
|
|
225
|
+
const path = dartPath(body.slice(at + 1, end - 1));
|
|
226
|
+
if (!path.startsWith('/') && !/^https?:/.test(path))
|
|
227
|
+
continue;
|
|
228
|
+
const response = m[1].trim();
|
|
229
|
+
map.endpoints.push({ name: m[2], method: call[1].toUpperCase(), path, response, file: f, calledFrom: [] });
|
|
230
|
+
re.lastIndex = close;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Call sites: `.methodName(` in files that import the repository file.
|
|
234
|
+
for (const f of dart) {
|
|
235
|
+
const t = text(f);
|
|
236
|
+
for (const ep of map.endpoints) {
|
|
237
|
+
if (ep.file === f)
|
|
238
|
+
continue;
|
|
239
|
+
const imported = t.includes(base(ep.file) + '.dart');
|
|
240
|
+
if (imported && new RegExp(`\\.${ep.name}\\s*\\(`).test(t))
|
|
241
|
+
ep.calledFrom.push(f);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Routes.
|
|
245
|
+
for (const f of dart) {
|
|
246
|
+
const t = text(f);
|
|
247
|
+
for (const m of t.matchAll(/GoRoute\(/g)) {
|
|
248
|
+
const open = m.index + m[0].length - 1;
|
|
249
|
+
const close = matchBrace(t, open, 'dart');
|
|
250
|
+
const block = t.slice(open, close === -1 ? open + 400 : close);
|
|
251
|
+
// Only the route's own arguments, not nested child routes.
|
|
252
|
+
const own = block.split(/routes:\s*\[/)[0];
|
|
253
|
+
const path = own.match(/path:\s*['"]([^'"]+)['"]/)?.[1];
|
|
254
|
+
if (!path)
|
|
255
|
+
continue;
|
|
256
|
+
const name = own.match(/name:\s*['"]([^'"]+)['"]/)?.[1] ?? (path.replace(/[/:]/g, ' ').trim().split(/\s+/)[0] || 'home');
|
|
257
|
+
const params = [...path.matchAll(/:(\w+)/g)].map((p) => p[1]);
|
|
258
|
+
map.routes.push({ name, path: path.replace(/:(\w+)/g, '{$1}'), params, file: f });
|
|
259
|
+
}
|
|
260
|
+
for (const m of t.matchAll(/AutoRoute\(([^)]*)\)/g)) {
|
|
261
|
+
const path = m[1].match(/path:\s*['"]([^'"]+)['"]/)?.[1];
|
|
262
|
+
const page = m[1].match(/page:\s*(\w+)\.page/)?.[1];
|
|
263
|
+
if (path || page)
|
|
264
|
+
map.routes.push({ name: page ?? path, path: (path ?? '').replace(/:(\w+)/g, '{$1}'), params: [...(path ?? '').matchAll(/:(\w+)/g)].map((p) => p[1]), file: f });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// GetX pages: GetPage(name: _Paths.home / Routes.home / '/home', ...) with paths from app_routes.dart.
|
|
268
|
+
const pathConsts = new Map();
|
|
269
|
+
for (const f of dart)
|
|
270
|
+
for (const m of text(f).matchAll(/static\s+const\s+(\w+)\s*=\s*['"](\/[^'"]*)['"]/g))
|
|
271
|
+
pathConsts.set(m[1], m[2]);
|
|
272
|
+
for (const f of dart) {
|
|
273
|
+
for (const m of text(f).matchAll(/GetPage\s*\(/g)) {
|
|
274
|
+
const t = text(f);
|
|
275
|
+
const close = matchBrace(t, m.index + m[0].length - 1, 'dart');
|
|
276
|
+
const block = t.slice(m.index, close === -1 ? m.index + 300 : close);
|
|
277
|
+
const own = block.split(/children:\s*\[/)[0];
|
|
278
|
+
const name = own.match(/name:\s*(?:(?:_Paths|Routes)\.(\w+)|['"]([^'"]+)['"])/);
|
|
279
|
+
if (!name)
|
|
280
|
+
continue;
|
|
281
|
+
const path = name[2] ?? pathConsts.get(name[1]) ?? `/${name[1]}`;
|
|
282
|
+
const page = own.match(/page:\s*\(\)\s*=>\s*(?:const\s+)?(\w+)/)?.[1];
|
|
283
|
+
map.routes.push({ name: page ?? name[1] ?? path, path: path.replace(/:(\w+)/g, '{$1}'), params: [...path.matchAll(/:(\w+)/g)].map((x) => x[1]), file: f });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (dart.some((f) => /GetPage\s*\(/.test(text(f))))
|
|
287
|
+
conv.router = configConventions.router ?? 'getx';
|
|
288
|
+
if (!conv.networkFile)
|
|
289
|
+
conv.networkFile = dart.find((f) => /\bBaseOptions\s*\(/.test(text(f)) && /\bDio\s*\(/.test(text(f))) ?? '';
|
|
290
|
+
if (!conv.modulesFolder)
|
|
291
|
+
conv.modulesFolder = dart.some((f) => f.startsWith('lib/app/modules/')) || conv.di === 'getx' ? 'lib/app/modules' : '';
|
|
292
|
+
if (!conv.routesFile)
|
|
293
|
+
conv.routesFile = dart.find((f) => /(^|\/)app_pages\.dart$/.test(f)) ?? (conv.di === 'getx' ? 'lib/app/routes/app_pages.dart' : '');
|
|
294
|
+
map.routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
295
|
+
map.locales = scanLocales(root, files);
|
|
296
|
+
if (!map.locales.files.length) {
|
|
297
|
+
// GetX keeps texts in a Translations class: 'en_US': {'key': 'text'}.
|
|
298
|
+
const tf = dart.find((f) => /\bextends\s+Translations\b/.test(text(f)));
|
|
299
|
+
if (tf) {
|
|
300
|
+
const langs = [...text(tf).matchAll(/['"]([a-z]{2,3})(?:[_-][A-Z]{2})?['"]\s*:\s*(?:\{|[A-Za-z_])/g)].map((m) => m[1]);
|
|
301
|
+
const first = text(tf).match(/['"][a-z]{2,3}(?:[_-][A-Z]{2})?['"]\s*:\s*\{([^}]*)\}/)?.[1] ?? '';
|
|
302
|
+
map.locales = {
|
|
303
|
+
languages: [...new Set(langs)],
|
|
304
|
+
default: langs.includes('en') ? 'en' : langs[0],
|
|
305
|
+
files: [tf],
|
|
306
|
+
keyCount: [...first.matchAll(/['"]\s*:\s*['"]/g)].length,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return map;
|
|
311
|
+
}
|
|
312
|
+
// ---------------- Generation ----------------
|