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.
Files changed (117) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +523 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +358 -0
  77. package/dist/skills/doctor.js +304 -0
  78. package/dist/skills/doctorFix.js +214 -0
  79. package/dist/skills/explain.js +61 -0
  80. package/dist/skills/feature.js +418 -0
  81. package/dist/skills/featureEdit.js +225 -0
  82. package/dist/skills/featureLinks.js +179 -0
  83. package/dist/skills/init.js +66 -0
  84. package/dist/skills/iosUi.js +468 -0
  85. package/dist/skills/locale.js +335 -0
  86. package/dist/skills/mcp.js +175 -0
  87. package/dist/skills/mock.js +482 -0
  88. package/dist/skills/module.js +23 -0
  89. package/dist/skills/plans.js +66 -0
  90. package/dist/skills/prefill.js +84 -0
  91. package/dist/skills/reactNativeUi.js +388 -0
  92. package/dist/skills/reactUi.js +352 -0
  93. package/dist/skills/report.js +75 -0
  94. package/dist/skills/route.js +230 -0
  95. package/dist/skills/screen.js +597 -0
  96. package/dist/skills/screenForm.js +410 -0
  97. package/dist/skills/setup.js +28 -0
  98. package/dist/skills/setupDoctor.js +226 -0
  99. package/dist/skills/templatesCmd.js +32 -0
  100. package/dist/skills/testgen.js +331 -0
  101. package/dist/skills/theme.js +388 -0
  102. package/dist/starters/auth.js +144 -0
  103. package/dist/starters/commands.js +283 -0
  104. package/dist/starters/engine.js +315 -0
  105. package/dist/starters/home.js +42 -0
  106. package/dist/starters/http.js +93 -0
  107. package/dist/starters/manifest.js +285 -0
  108. package/dist/starters/paths.js +70 -0
  109. package/dist/starters/publish.js +150 -0
  110. package/dist/starters/registry.js +258 -0
  111. package/dist/starters/sign.js +72 -0
  112. package/dist/starters/source.js +98 -0
  113. package/dist/starters/strictJson.js +148 -0
  114. package/dist/starters/tar.js +180 -0
  115. package/docs/COMMANDS.md +527 -0
  116. package/examples/openapi.yaml +319 -0
  117. package/package.json +57 -4
@@ -0,0 +1,468 @@
1
+ // iOS (SwiftUI) screens: route add, screen add (data) and screen add --form.
2
+ //
3
+ // <Name>View.swift a SwiftUI view in the screens folder
4
+ // <Name>ViewModel.swift data and form screens: a @MainActor ObservableObject
5
+ // that calls APIClient; route parameters (:id) become
6
+ // init parameters typed like the API method's
7
+ // LoadState.swift loading / loaded / failed, written once
8
+ //
9
+ // SwiftUI has no route table: screens are opened with NavigationLink, so a new
10
+ // screen is not registered anywhere, except --initial, which makes it the
11
+ // first screen of the app inside a NavigationStack. A data screen shows a
12
+ // ProgressView, the error with Retry, then a List (lists) or the fields, and
13
+ // reloads when it appears again and on pull to refresh. A form screen has one
14
+ // input per request field and closes after saving.
15
+ import { join } from 'node:path';
16
+ import { swiftClose, swiftName, swiftText } from "../adapters/ios.js";
17
+ import { buildChangeSet } from "../core/changeset.js";
18
+ import { readText } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ import { camel, kebab, pascal, words } from "../input/naming.js";
21
+ import { prefillFor } from "./prefill.js";
22
+ function names(name) {
23
+ const base = pascal(name.replace(/[-_ ]?(view|screen|page)$/i, ''));
24
+ return { base, view: `${base}View`, vm: `${base}ViewModel`, title: words(base).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') };
25
+ }
26
+ const routeParams = (path) => [...(path ?? '').matchAll(/[:{]([\w-]+)\}?/g)].map((m) => camel(m[1]));
27
+ /** func getOrder(orderId: Int, page: Int? = nil) async throws -> Order */
28
+ export function swiftMethod(text, name) {
29
+ const m = new RegExp(`\\bfunc\\s+${name}\\s*\\(([^)]*)\\)\\s*async\\s+throws(?:\\s*->\\s*([^{]+?))?\\s*\\{`).exec(text);
30
+ if (!m)
31
+ return null;
32
+ const params = m[1]
33
+ .split(',')
34
+ .map((p) => /^\s*`?(\w+)`?\s*:\s*([^=]+?)\s*(=.*)?$/.exec(p))
35
+ .filter((p) => !!p)
36
+ .map((p) => ({ label: p[1], type: p[2].trim(), optional: !!p[3] }));
37
+ return { params, returns: m[2]?.trim() ?? 'Void' };
38
+ }
39
+ function context(root, map) {
40
+ const c = map.conventions;
41
+ if (c.ui !== 'swiftui')
42
+ throw new ToolError('not_supported', 'codz writes SwiftUI screens, and this project has no SwiftUI App (@main struct ...: App).', 'Screens for UIKit apps are not supported yet; api add works.');
43
+ const files = new Map();
44
+ return { root, map, I: ' '.repeat(Number(c.indent) || 4), dir: c.screensFolder || `${c.sourceDir || '.'}/Views`, files, summary: [], warnings: [], read: (p) => files.get(p) ?? readText(join(root, p)) };
45
+ }
46
+ function ensureLoadState(x) {
47
+ const file = `${x.dir}/LoadState.swift`;
48
+ if (x.read(file) !== null)
49
+ return;
50
+ x.files.set(file, ['/// What a screen shows while it loads, when it has the data, and when loading failed.', 'enum LoadState<Value> {', `${x.I}case loading`, `${x.I}case loaded(Value)`, `${x.I}case failed(String)`, '}', ''].join('\n'));
51
+ x.summary.push(`Create ${file}: LoadState (loading, loaded, failed)`);
52
+ }
53
+ /** --initial: the app opens on this screen, inside a NavigationStack. */
54
+ function makeInitial(x, view, args) {
55
+ const appFile = x.map.conventions.appFile;
56
+ const text = appFile ? x.read(appFile) : null;
57
+ const m = text ? /\bWindowGroup\s*\{/.exec(text) : null;
58
+ if (!appFile || !text || !m)
59
+ return void x.warnings.push(`Could not find WindowGroup { } in the App file; show ${view}(${args}) in a NavigationStack yourself.`);
60
+ const open = m.index + m[0].length - 1;
61
+ const close = swiftClose(text, open);
62
+ const indent = /\n([ \t]*)[^\n]*$/.exec(text.slice(0, close))?.[1] ?? ' ';
63
+ const I = x.I;
64
+ x.files.set(appFile, `${text.slice(0, open + 1)}\n${indent}${I}NavigationStack {\n${indent}${I}${I}${view}(${args})\n${indent}${I}}\n${indent}${text.slice(close)}`);
65
+ x.summary.push(`Update ${appFile}: the app opens on ${view} in a NavigationStack`);
66
+ }
67
+ function finish(x, skill, view, params, opts) {
68
+ if (opts.initial)
69
+ makeInitial(x, view, params.map((p) => `${p.label}: ${p.type === 'Int' ? '0' : '""'}`).join(', '));
70
+ else
71
+ x.summary.push(`Open it with: NavigationLink { ${view}(${params.map((p) => `${p.label}: ...`).join(', ')}) } label: { Text("...") }`);
72
+ const created = [...x.files.keys()].filter((f) => readText(join(x.root, f)) === null);
73
+ if (x.map.conventions.fileSync === 'groups' && created.length)
74
+ x.warnings.push(`Add ${created.join(', ')} to the ${x.map.conventions.appName || 'app'} target in Xcode (this project does not use synchronized folders).`);
75
+ return buildChangeSet(x.root, skill, { files: x.files, summary: x.summary, warnings: x.warnings, generated: [] });
76
+ }
77
+ export function planIosRoute(root, map, opts) {
78
+ const x = context(root, map);
79
+ const n = names(opts.name);
80
+ const file = `${x.dir}/${n.view}.swift`;
81
+ if (x.read(file) !== null)
82
+ throw new ToolError('route_exists', `${file} already exists.`);
83
+ const params = routeParams(opts.path).map((p) => ({ label: p, type: 'String' }));
84
+ const I = x.I;
85
+ x.files.set(file, [
86
+ 'import SwiftUI',
87
+ '',
88
+ `struct ${n.view}: View {`,
89
+ ...params.map((p) => `${I}let ${swiftName(p.label)}: String`),
90
+ ...(params.length ? [''] : []),
91
+ `${I}var body: some View {`,
92
+ `${I}${I}Text("${n.title}${params.length ? ` \\(${swiftName(params[0].label)})` : ''}")`,
93
+ `${I}${I}${I}.navigationTitle("${n.title}")`,
94
+ `${I}}`,
95
+ '}',
96
+ '',
97
+ ].join('\n'));
98
+ x.summary.push(`Create ${file}: ${n.view}`);
99
+ return finish(x, 'route add', n.view, params, opts);
100
+ }
101
+ function findEp(map, name) {
102
+ const ep = map.endpoints.find((e) => e.name === name) ?? map.endpoints.find((e) => e.name.toLowerCase() === name.toLowerCase());
103
+ if (!ep)
104
+ throw new ToolError('no_endpoint', `No endpoint called ${name} was found.`, map.endpoints.length ? `Endpoints: ${map.endpoints.map((e) => e.name).join(', ')}` : 'Add one first with: codz api add');
105
+ return ep;
106
+ }
107
+ /** "[Product]" → list of Product; "Product" → one. */
108
+ function shapeOf(type, map) {
109
+ const list = /^\[.+\]$/.test(type.trim());
110
+ const inner = type.trim().replace(/^\[(.+)\]$/, '$1').replace(/\?$/, '');
111
+ return { list, model: map.models.find((m) => m.name === inner) };
112
+ }
113
+ function titleOf(model) {
114
+ if (!model?.fields.length)
115
+ return undefined;
116
+ const ns = model.fields.map((f) => f.name);
117
+ const pick = ['name', 'title', 'label', 'display_name', 'displayName', 'full_name', 'fullName', 'email', 'username', 'sku', 'id'].find((x) => ns.includes(x)) ?? ns[0];
118
+ return model.fields.find((f) => f.name === pick);
119
+ }
120
+ const show = (v, f) => (f.nullable ? `${v}.${swiftName(f.name)}.map { String(describing: $0) } ?? ""` : `String(describing: ${v}.${swiftName(f.name)})`);
121
+ function callFor(x, ep, opts, skip = () => false) {
122
+ const sig = swiftMethod(x.read(ep.file) ?? '', ep.name);
123
+ if (!sig)
124
+ throw new ToolError('no_endpoint', `Could not read ${ep.name} in ${ep.file}.`);
125
+ const route = routeParams(opts.path);
126
+ const required = sig.params.filter((p) => !p.optional && !skip(p.label));
127
+ const missing = required.filter((p) => !route.includes(p.label));
128
+ if (missing.length)
129
+ throw new ToolError('screen_args', `${ep.name}() needs ${missing.map((p) => p.label).join(', ')}, which the screen does not have.`, `Give the route a matching parameter, for example: --path /${kebab(names(opts.name).base)}/:${missing[0].label}`);
130
+ const inits = required.map((p) => ({ label: p.label, type: p.type }));
131
+ return { sig, inits, args: inits.map((p) => `${p.label}: ${swiftName(p.label)}`).join(', ') };
132
+ }
133
+ function viewModelHead(x, vm, inits) {
134
+ const I = x.I;
135
+ return [
136
+ ...inits.map((p) => `${I}private let ${swiftName(p.label)}: ${p.type}`),
137
+ `${I}private let api: APIClient`,
138
+ '',
139
+ `${I}init(${[...inits.map((p) => `${swiftName(p.label)}: ${p.type}`), 'api: APIClient = .shared'].join(', ')}) {`,
140
+ ...inits.map((p) => `${I}${I}self.${swiftName(p.label)} = ${swiftName(p.label)}`),
141
+ `${I}${I}self.api = api`,
142
+ `${I}}`,
143
+ ];
144
+ }
145
+ function viewInit(x, vm, inits) {
146
+ const I = x.I;
147
+ if (!inits.length)
148
+ return [`${I}@StateObject private var model = ${vm}()`];
149
+ return [`${I}@StateObject private var model: ${vm}`, '', `${I}init(${inits.map((p) => `${swiftName(p.label)}: ${p.type}`).join(', ')}) {`, `${I}${I}_model = StateObject(wrappedValue: ${vm}(${inits.map((p) => `${p.label}: ${swiftName(p.label)}`).join(', ')}))`, `${I}}`];
150
+ }
151
+ const sample = (type) => (/^Int$/.test(type) ? '1' : /^Double$/.test(type) ? '1.0' : '"1"');
152
+ export function planIosScreen(root, map, opts) {
153
+ if (!opts.api)
154
+ return planIosRoute(root, map, opts);
155
+ const x = context(root, map);
156
+ const ep = findEp(map, opts.api);
157
+ if (opts.form)
158
+ return planIosForm(x, ep, opts);
159
+ if (ep.method !== 'GET')
160
+ throw new ToolError('screen_needs_get', `${ep.name} is a ${ep.method} endpoint; a screen loads data with a GET.`, 'For a form that sends it, add --form.');
161
+ const n = names(opts.name);
162
+ const viewFile = `${x.dir}/${n.view}.swift`;
163
+ const vmFile = `${x.dir}/${n.vm}.swift`;
164
+ for (const f of [viewFile, vmFile])
165
+ if (x.read(f) !== null)
166
+ throw new ToolError('route_exists', `${f} already exists.`);
167
+ const call = callFor(x, ep, opts);
168
+ const T = call.sig.returns;
169
+ if (T === 'Void')
170
+ throw new ToolError('screen_needs_data', `${ep.name}() returns nothing, so there is no data to show.`);
171
+ const shape = shapeOf(T, map);
172
+ const I = x.I;
173
+ ensureLoadState(x);
174
+ x.files.set(vmFile, [
175
+ 'import Foundation',
176
+ '',
177
+ '@MainActor',
178
+ `final class ${n.vm}: ObservableObject {`,
179
+ `${I}@Published private(set) var state: LoadState<${T}> = .loading`,
180
+ ...viewModelHead(x, n.vm, call.inits),
181
+ '',
182
+ `${I}func load() async {`,
183
+ `${I}${I}do {`,
184
+ `${I}${I}${I}state = .loaded(try await api.${ep.name}(${call.args}))`,
185
+ `${I}${I}} catch {`,
186
+ `${I}${I}${I}state = .failed(error.localizedDescription)`,
187
+ `${I}${I}}`,
188
+ `${I}}`,
189
+ '}',
190
+ '',
191
+ ].join('\n'));
192
+ x.summary.push(`Create ${vmFile}: ${n.vm} loads APIClient.${ep.name}()${call.inits.length ? ` with ${call.inits.map((p) => p.label).join(', ')}` : ''}`);
193
+ const model = shape.model;
194
+ const tf = titleOf(model);
195
+ const identifiable = !!model?.fields.some((f) => f.name === 'id' && !f.nullable);
196
+ const L = (depth, s) => `${I.repeat(depth)}${s}`;
197
+ const loaded = shape.list
198
+ ? [
199
+ L(2, 'case .loaded(let items):'),
200
+ L(3, 'if items.isEmpty {'),
201
+ L(4, 'Text("Nothing here yet.")'),
202
+ L(5, '.foregroundStyle(.secondary)'),
203
+ L(3, '} else {'),
204
+ ...(identifiable
205
+ ? [L(4, 'List(items) { item in'), L(5, `Text(${tf ? show('item', tf) : 'String(describing: item)'})`), L(4, '}')]
206
+ : [L(4, 'List(Array(items.enumerated()), id: \\.offset) { _, item in'), L(5, `Text(${tf ? show('item', tf) : 'String(describing: item)'})`), L(4, '}')]),
207
+ L(3, '}'),
208
+ ]
209
+ : [
210
+ L(2, 'case .loaded(let data):'),
211
+ L(3, 'List {'),
212
+ ...(model?.fields.length ? model.fields.map((f) => L(4, `LabeledContent("${swiftText(f.name)}", value: ${show('data', f)})`)) : [L(4, 'Text(String(describing: data))')]),
213
+ L(3, '}'),
214
+ ];
215
+ x.files.set(viewFile, [
216
+ 'import SwiftUI',
217
+ '',
218
+ `struct ${n.view}: View {`,
219
+ ...viewInit(x, n.vm, call.inits),
220
+ '',
221
+ `${I}var body: some View {`,
222
+ `${I}${I}content`,
223
+ `${I}${I}${I}.navigationTitle("${n.title}")`,
224
+ `${I}${I}${I}.task { await model.load() }`,
225
+ `${I}${I}${I}.refreshable { await model.load() }`,
226
+ `${I}}`,
227
+ '',
228
+ `${I}@ViewBuilder`,
229
+ `${I}private var content: some View {`,
230
+ `${I}${I}switch model.state {`,
231
+ `${I}${I}case .loading:`,
232
+ `${I}${I}${I}ProgressView()`,
233
+ `${I}${I}case .failed(let message):`,
234
+ `${I}${I}${I}VStack(spacing: 12) {`,
235
+ `${I}${I}${I}${I}Text(message)`,
236
+ `${I}${I}${I}${I}${I}.multilineTextAlignment(.center)`,
237
+ `${I}${I}${I}${I}Button("Retry") {`,
238
+ `${I}${I}${I}${I}${I}Task { await model.load() }`,
239
+ `${I}${I}${I}${I}}`,
240
+ `${I}${I}${I}}`,
241
+ `${I}${I}${I}.padding()`,
242
+ ...loaded,
243
+ `${I}${I}}`,
244
+ `${I}}`,
245
+ '}',
246
+ '',
247
+ '#Preview {',
248
+ `${I}NavigationStack {`,
249
+ `${I}${I}${n.view}(${call.inits.map((p) => `${swiftName(p.label)}: ${sample(p.type)}`).join(', ')})`,
250
+ `${I}}`,
251
+ '}',
252
+ '',
253
+ ].join('\n'));
254
+ x.summary.push(`Create ${viewFile}: ${n.view} (loading, error with Retry, ${shape.list ? 'List' : 'details'}; reloads on appear and pull to refresh)`);
255
+ return finish(x, 'screen add', n.view, call.inits, opts);
256
+ }
257
+ function planIosForm(x, ep, opts) {
258
+ if (!['POST', 'PUT', 'PATCH'].includes(ep.method))
259
+ throw new ToolError('form_needs_body', `${ep.name} is a ${ep.method} endpoint; a form sends a request body (POST, PUT or PATCH).`);
260
+ const n = names(opts.name);
261
+ const viewFile = `${x.dir}/${n.view}.swift`;
262
+ const vmFile = `${x.dir}/${n.vm}.swift`;
263
+ for (const f of [viewFile, vmFile])
264
+ if (x.read(f) !== null)
265
+ throw new ToolError('route_exists', `${f} already exists.`);
266
+ const call = callFor(x, ep, opts, (l) => l === 'body');
267
+ const bodyParam = call.sig.params.find((p) => p.label === 'body');
268
+ if (!bodyParam)
269
+ throw new ToolError('form_needs_body', `${ep.name}() takes no body, so there is nothing for a form to send.`);
270
+ const model = x.map.models.find((m) => m.name === bodyParam.type);
271
+ if (!model)
272
+ throw new ToolError('form_fields', `The request type ${bodyParam.type} is not a model codz knows, so it cannot build the inputs.`);
273
+ const kindOf = (t) => (t === 'String' ? 'text' : t === 'Int' ? 'int' : t === 'Double' ? 'double' : t === 'Bool' ? 'bool' : t === '[String]' ? 'strings' : null);
274
+ const reserved = new Set(['body', 'model', 'dismiss', 'canSave', 'save']);
275
+ const fields = model.fields.map((f) => {
276
+ const prop = swiftName(f.name);
277
+ const bare = prop.replace(/`/g, '');
278
+ return { f, prop, state: reserved.has(bare) ? `${bare}Value` : bare, kind: kindOf(f.type) };
279
+ });
280
+ const cannot = fields.filter((x) => !x.kind && !x.f.nullable);
281
+ if (cannot.length)
282
+ throw new ToolError('form_fields', `${model.name} has required fields a form cannot fill: ${cannot.map((c) => `${c.f.name} (${c.f.type})`).join(', ')}.`, `Write the form for ${ep.name} by hand, or make those fields optional.`);
283
+ const usable = fields.filter((x) => x.kind);
284
+ if (fields.length > usable.length)
285
+ x.warnings.push(`The form has no input for ${fields.filter((f) => !f.kind).map((f) => `${f.f.name} (${f.f.type})`).join(', ')}; they are sent as nil.`);
286
+ const I = x.I;
287
+ const trim = (s) => `${s}.trimmingCharacters(in: .whitespaces)`;
288
+ const value = (y) => {
289
+ const s = y.state;
290
+ switch (y.kind) {
291
+ case 'text':
292
+ return y.f.nullable ? `${s}.isEmpty ? nil : ${trim(s)}` : trim(s);
293
+ case 'int':
294
+ return y.f.nullable ? `Int(${trim(s)})` : `Int(${trim(s)}) ?? 0`;
295
+ case 'double':
296
+ return y.f.nullable ? `Double(${trim(s)})` : `Double(${trim(s)}) ?? 0`;
297
+ case 'bool':
298
+ return s;
299
+ case 'strings': {
300
+ const list = `${s}.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }`;
301
+ return y.f.nullable ? `${s}.isEmpty ? nil : ${list}` : list;
302
+ }
303
+ default:
304
+ return 'nil';
305
+ }
306
+ };
307
+ const checks = usable
308
+ .filter((y) => !y.f.nullable && y.kind !== 'bool')
309
+ .map((y) => (y.kind === 'int' ? `Int(${trim(y.state)}) != nil` : y.kind === 'double' ? `Double(${trim(y.state)}) != nil` : `!${trim(y.state)}.isEmpty`));
310
+ const returnsVoid = call.sig.returns === 'Void';
311
+ // Edit forms: load the detail endpoint first and start with its values.
312
+ const pre = (() => {
313
+ const p = prefillFor(x.root, x.map, opts.load, usable.map((y) => y.f.name), routeParams(opts.path), 'swift');
314
+ if (!p)
315
+ return null;
316
+ const sig = swiftMethod(x.read(p.ep.file) ?? '', p.ep.name);
317
+ if (!sig)
318
+ return null;
319
+ const req = sig.params.filter((q) => !q.optional);
320
+ if (!req.every((q) => call.inits.some((i) => i.label === q.label && i.type === q.type)))
321
+ return null;
322
+ const set = usable.filter((y) => p.shared.has(y.f.name) && p.field(y.f.name).type.replace(/\?$/, '') === y.f.type.replace(/\?$/, ''));
323
+ if (!set.length)
324
+ return null;
325
+ return { p, args: req.map((q) => `${q.label}: ${swiftName(q.label)}`).join(', '), set };
326
+ })();
327
+ const startValue = (y) => {
328
+ const nullable = pre.p.field(y.f.name).nullable;
329
+ const a = `d.${y.prop}`;
330
+ switch (y.kind) {
331
+ case 'bool':
332
+ return nullable ? `${a} ?? false` : a;
333
+ case 'int':
334
+ case 'double':
335
+ return nullable ? `${a}.map { String($0) } ?? ""` : `String(${a})`;
336
+ case 'strings':
337
+ return nullable ? `(${a} ?? []).joined(separator: ", ")` : `${a}.joined(separator: ", ")`;
338
+ default:
339
+ return nullable ? `${a} ?? ""` : a;
340
+ }
341
+ };
342
+ x.files.set(vmFile, [
343
+ 'import Foundation',
344
+ '',
345
+ '@MainActor',
346
+ `final class ${n.vm}: ObservableObject {`,
347
+ `${I}@Published private(set) var saving = false`,
348
+ `${I}@Published private(set) var error: String?`,
349
+ ...viewModelHead(x, n.vm, call.inits),
350
+ ...(pre
351
+ ? [
352
+ '',
353
+ `${I}/// The current values to start the form with (nil when they could not be loaded).`,
354
+ `${I}func loadInitial() async -> ${pre.p.model.name}? {`,
355
+ `${I}${I}try? await api.${pre.p.ep.name}(${pre.args})`,
356
+ `${I}}`,
357
+ ]
358
+ : []),
359
+ '',
360
+ `${I}/// Sends the form; true when it was saved.`,
361
+ `${I}func submit(_ body: ${model.name}) async -> Bool {`,
362
+ `${I}${I}saving = true`,
363
+ `${I}${I}error = nil`,
364
+ `${I}${I}defer { saving = false }`,
365
+ `${I}${I}do {`,
366
+ `${I}${I}${I}${returnsVoid ? '' : '_ = '}try await api.${ep.name}(${[call.args, 'body: body'].filter(Boolean).join(', ')})`,
367
+ `${I}${I}${I}return true`,
368
+ `${I}${I}} catch {`,
369
+ `${I}${I}${I}self.error = error.localizedDescription`,
370
+ `${I}${I}${I}return false`,
371
+ `${I}${I}}`,
372
+ `${I}}`,
373
+ '}',
374
+ '',
375
+ ].join('\n'));
376
+ x.summary.push(`Create ${vmFile}: ${n.vm}.submit() sends APIClient.${ep.name}()${pre ? `; the form starts with APIClient.${pre.p.ep.name}()` : ''}`);
377
+ const input = (y) => {
378
+ const label = `${swiftText(y.f.name)}${y.f.nullable ? '' : ' *'}`;
379
+ if (y.kind === 'bool')
380
+ return [`${I}${I}${I}${I}Toggle("${label}", isOn: $${y.state})`];
381
+ const extra = y.kind === 'int' ? ['.keyboardType(.numberPad)'] : y.kind === 'double' ? ['.keyboardType(.decimalPad)'] : /email/i.test(y.f.name) ? ['.keyboardType(.emailAddress)', '.textInputAutocapitalization(.never)'] : [];
382
+ return [`${I}${I}${I}${I}TextField("${label}${y.kind === 'strings' ? ' (comma separated)' : ''}", text: $${y.state})`, ...extra.map((e) => `${I}${I}${I}${I}${I}${e}`)];
383
+ };
384
+ x.files.set(viewFile, [
385
+ 'import SwiftUI',
386
+ '',
387
+ `struct ${n.view}: View {`,
388
+ ...viewInit(x, n.vm, call.inits).slice(0, 1),
389
+ `${I}@Environment(\\.dismiss) private var dismiss`,
390
+ ...usable.map((y) => `${I}@State private var ${y.state}${y.kind === 'bool' ? ' = false' : ' = ""'}`),
391
+ ...(pre ? [`${I}@State private var loaded = false`] : []),
392
+ ...viewInit(x, n.vm, call.inits).slice(1),
393
+ '',
394
+ `${I}var body: some View {`,
395
+ `${I}${I}Form {`,
396
+ `${I}${I}${I}Section {`,
397
+ ...usable.flatMap(input),
398
+ `${I}${I}${I}}`,
399
+ `${I}${I}${I}if let error = model.error {`,
400
+ `${I}${I}${I}${I}Text(error)`,
401
+ `${I}${I}${I}${I}${I}.foregroundStyle(.red)`,
402
+ `${I}${I}${I}}`,
403
+ `${I}${I}${I}Button(model.saving ? "Saving…" : "Save") {`,
404
+ `${I}${I}${I}${I}Task { await save() }`,
405
+ `${I}${I}${I}}`,
406
+ `${I}${I}${I}.disabled(model.saving || !canSave)`,
407
+ `${I}${I}}`,
408
+ `${I}${I}.navigationTitle("${n.title}")`,
409
+ ...(pre
410
+ ? [
411
+ `${I}${I}.task {`,
412
+ `${I}${I}${I}guard !loaded, let d = await model.loadInitial() else { return }`,
413
+ `${I}${I}${I}loaded = true`,
414
+ ...pre.set.map((y) => `${I}${I}${I}${y.state} = ${startValue(y)}`),
415
+ `${I}${I}}`,
416
+ ]
417
+ : []),
418
+ `${I}}`,
419
+ '',
420
+ `${I}private var canSave: Bool {`,
421
+ `${I}${I}${checks.length ? checks.join(' && ') : 'true'}`,
422
+ `${I}}`,
423
+ '',
424
+ `${I}private func save() async {`,
425
+ `${I}${I}let body = ${model.name}(`,
426
+ ...fields.map((y, i) => `${I}${I}${I}${y.prop.replace(/`/g, '')}: ${value(y)}${i < fields.length - 1 ? ',' : ''}`),
427
+ `${I}${I})`,
428
+ `${I}${I}if await model.submit(body) { dismiss() }`,
429
+ `${I}}`,
430
+ '}',
431
+ '',
432
+ '#Preview {',
433
+ `${I}NavigationStack {`,
434
+ `${I}${I}${n.view}(${call.inits.map((p) => `${swiftName(p.label)}: ${sample(p.type)}`).join(', ')})`,
435
+ `${I}}`,
436
+ '}',
437
+ '',
438
+ ].join('\n'));
439
+ x.summary.push(`Create ${viewFile}: ${n.view}, a form with ${usable.length} field${usable.length === 1 ? '' : 's'} for ${model.name}; it closes after saving`);
440
+ return finish(x, 'screen add', n.view, call.inits, opts);
441
+ }
442
+ /** feature add: rows open the detail view, a + button opens the create form; the list reloads when it appears again. */
443
+ export function linkIosList(text, t) {
444
+ let out = text;
445
+ let changed = false;
446
+ const I = t.I;
447
+ if (t.detail && t.id) {
448
+ const re = new RegExp(`(\\{ (?:_, )?item in\\n)([ \\t]*)(Text\\([^\\n]*\\))\\n`);
449
+ const m = re.exec(out);
450
+ if (m) {
451
+ const idExpr = `item.${swiftName(t.id.name)}`;
452
+ const arg = t.id.type === t.detail.type ? idExpr : t.detail.type === 'String' ? `String(${idExpr})` : t.detail.type === 'Int' ? `Int(${idExpr}) ?? 0` : idExpr;
453
+ const ind = m[2];
454
+ out = out.replace(re, `${m[1]}${ind}NavigationLink {\n${ind}${I}${t.detail.view}(${t.detail.param}: ${arg})\n${ind}} label: {\n${ind}${I}${m[3]}\n${ind}}\n`);
455
+ changed = true;
456
+ }
457
+ }
458
+ if (t.create) {
459
+ const re = /\n([ \t]*)(\.navigationTitle\("[^"\n]*"\))\n/;
460
+ const m = re.exec(out);
461
+ if (m) {
462
+ const ind = m[1];
463
+ out = out.replace(re, `\n${ind}${m[2]}\n${ind}.toolbar {\n${ind}${I}NavigationLink {\n${ind}${I}${I}${t.create.view}()\n${ind}${I}} label: {\n${ind}${I}${I}Image(systemName: "plus")\n${ind}${I}}\n${ind}}\n`);
464
+ changed = true;
465
+ }
466
+ }
467
+ return changed ? out : null;
468
+ }