codz-cli 0.1.3 → 0.1.8

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.
@@ -0,0 +1,419 @@
1
+ // codz field add / field remove: one field in an existing model, everywhere
2
+ // the model spells it (TypeScript interfaces, Dart classes in plain,
3
+ // json_serializable or freezed style, Kotlin data classes, Swift Codable
4
+ // structs). Only the field's own lines change; everything else in the file,
5
+ // including code you wrote by hand, stays as it is. Then it lists the files
6
+ // that use the model, so you can show the new field (or see what breaks).
7
+ import { join } from 'node:path';
8
+ import { dartType, fieldName } from "../adapters/dartGen.js";
9
+ import { renderModel } from "../adapters/flutterGen.js";
10
+ import { renderFreezedModel } from "../adapters/flutterStyles.js";
11
+ import { swiftName, swiftText, swiftType } from "../adapters/ios.js";
12
+ import { addKotlinImports, kotlinName, renderDataClass } from "../adapters/kotlinGen.js";
13
+ import { findTsBlock, renderInterface } from "../adapters/nextjsGen.js";
14
+ import { matchBrace, maskCode } from "../adapters/source.js";
15
+ import { loadMap } from "../core/cache.js";
16
+ import { buildChangeSet } from "../core/changeset.js";
17
+ import { loadConfig } from "../core/config.js";
18
+ import { readText, walk } from "../core/fsutil.js";
19
+ import { ToolError } from "../core/types.js";
20
+ export const fieldLangOf = (adapter) => (adapter.id === 'flutter' ? 'dart' : adapter.id === 'android' ? 'kt' : adapter.id === 'ios' ? 'swift' : 'ts');
21
+ const TYPE_WORDS = {
22
+ string: { kind: 'string' }, text: { kind: 'string' }, str: { kind: 'string' },
23
+ int: { kind: 'int' }, integer: { kind: 'int' }, long: { kind: 'int' },
24
+ double: { kind: 'double' }, float: { kind: 'double' }, number: { kind: 'double' }, num: { kind: 'double' }, decimal: { kind: 'double' },
25
+ bool: { kind: 'bool' }, boolean: { kind: 'bool' },
26
+ date: { kind: 'date' }, datetime: { kind: 'date' }, timestamp: { kind: 'date' },
27
+ any: { kind: 'unknown' }, unknown: { kind: 'unknown' }, json: { kind: 'unknown' }, dynamic: { kind: 'unknown' },
28
+ };
29
+ export const FIELD_TYPES = 'string, int, double, bool, date, any, a model name, or a list: string[] / list<Model>';
30
+ /** Finds a model by name: exact, any case, or without the project's class suffix (Category → CategoryModel). */
31
+ export function findModel(map, name) {
32
+ const low = name.toLowerCase();
33
+ const suffix = (map.conventions.modelClassSuffix ?? '').toLowerCase();
34
+ const m = map.models.find((x) => x.name === name) ??
35
+ map.models.find((x) => x.name.toLowerCase() === low) ??
36
+ (suffix ? map.models.find((x) => x.name.toLowerCase() === low + suffix) : undefined);
37
+ if (m)
38
+ return m;
39
+ const names = map.models.map((x) => x.name).sort();
40
+ throw new ToolError('no_model', `No model called ${name}.`, names.length ? `Models: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''}` : 'This project has no models codz can read yet.');
41
+ }
42
+ function parseType(raw, map) {
43
+ const t = raw.trim();
44
+ const list = /^(?:list|array)<(.+)>$/i.exec(t) ?? /^\[(.+)\]$/.exec(t) ?? /^(.+)\[\]$/.exec(t);
45
+ if (list)
46
+ return { kind: 'array', item: parseType(list[1], map) };
47
+ const word = TYPE_WORDS[t.toLowerCase()];
48
+ if (word)
49
+ return word;
50
+ if (/^[A-Za-z_]\w*$/.test(t)) {
51
+ try {
52
+ return { kind: 'object', model: findModel(map, t).name };
53
+ }
54
+ catch {
55
+ throw new ToolError('bad_args', `Unknown type "${t}": it is not a basic type or a model in this project.`, `Types: ${FIELD_TYPES}. Add the model first (api add, or by hand).`);
56
+ }
57
+ }
58
+ throw new ToolError('bad_args', `Unknown type "${t}".`, `Types: ${FIELD_TYPES}.`);
59
+ }
60
+ /** "rating:double?", "rating?:double", "tags:string[]", "note" (a string). The name is the JSON key. */
61
+ export function parseFieldArg(arg, map) {
62
+ const m = /^([A-Za-z_$][\w$-]*)(\?)?(?::(.+?))?(\?)?$/.exec(arg.trim());
63
+ if (!m)
64
+ throw new ToolError('bad_args', `Cannot read the field "${arg}".`, 'Write name:type, with ? for an optional field: rating:double? tags:string[] category:Category');
65
+ return { jsonName: m[1], type: m[3] ? parseType(m[3], map) : { kind: 'string' }, nullable: Boolean(m[2] || m[4]) };
66
+ }
67
+ // ---------- small text helpers ----------
68
+ const lineStart = (text, i) => text.lastIndexOf('\n', i - 1) + 1;
69
+ const indentAt = (text, i) => /^[ \t]*/.exec(text.slice(lineStart(text, i)))[0];
70
+ /** Inserts lines just before the line holding `close`, re-indented like the items above, with a comma after the previous item when `comma`. */
71
+ function insertBefore(text, close, lines, opts) {
72
+ const ls = lineStart(text, close);
73
+ if (text.slice(ls, close).trim())
74
+ throw new ToolError('not_supported', `The ${opts.what} is written on one line, so codz cannot add to it safely.`, 'Put each item on its own line, then run this again.');
75
+ // The previous item: last non-blank, non-comment line before this one.
76
+ let prevEnd = ls - 1;
77
+ while (prevEnd > 0) {
78
+ const s = lineStart(text, prevEnd);
79
+ const line = text.slice(s, prevEnd).trim();
80
+ if (line && !line.startsWith('//'))
81
+ break;
82
+ prevEnd = s - 1;
83
+ }
84
+ const prevLine = text.slice(lineStart(text, prevEnd), prevEnd);
85
+ const opener = /[({[]\s*$/.test(prevLine);
86
+ const ind = opener ? indentAt(text, close) + opts.unit : /^[ \t]*/.exec(prevLine)[0];
87
+ const strip = Math.min(...lines.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)[0].length));
88
+ const block = lines.map((l) => (l.trim() ? ind + l.slice(strip) : '')).join('\n') + '\n';
89
+ let out = text.slice(0, ls) + block + text.slice(ls);
90
+ if (opts.comma && !opener && !/,\s*(\/\/.*)?$/.test(prevLine)) {
91
+ const code = prevLine.replace(/\s*\/\/.*$/, '');
92
+ const at = lineStart(text, prevEnd) + code.length;
93
+ out = out.slice(0, at) + ',' + out.slice(at);
94
+ }
95
+ return out;
96
+ }
97
+ /** The lines strictly between the first line matching `open` and the next line matching `close`. */
98
+ function between(rendered, open, close) {
99
+ const L = rendered.split('\n');
100
+ const a = L.findIndex((l) => open.test(l));
101
+ if (a < 0)
102
+ return [];
103
+ const b = L.findIndex((l, i) => i > a && close.test(l));
104
+ return L.slice(a + 1, b < 0 ? undefined : b);
105
+ }
106
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
+ // ---------- add, per language ----------
108
+ function addTs(text, model, f, c) {
109
+ const block = findTsBlock(text, model);
110
+ if (!block)
111
+ throw new ToolError('not_supported', `Could not find "export interface ${model}" or "export type ${model} = {".`);
112
+ const q = c.quote === 'double' ? '"' : "'";
113
+ const lines = between(renderInterface({ name: model, fields: [f] }, { c, q, style: { quote: q, semi: true, indent: ' ' } }), /\{$/, /^\}$/);
114
+ // Match the separator the interface uses (; , or none).
115
+ const inside = text.slice(block.start, block.end);
116
+ const sep = /,\s*\n\s*\}/.test(inside) ? ',' : /;\s*\n/.test(inside) || !/\n[^\n]*\w[^\n]*\n/.test(inside) ? ';' : '';
117
+ const fixed = lines.map((l) => (/^\s*(\/\/|\/\*\*)/.test(l) ? l : l.replace(/;$/, sep)));
118
+ const close = text.lastIndexOf('}', block.end - 1);
119
+ return insertBefore(text, close, fixed, { comma: false, unit: c.indent === 'tab' ? '\t' : ' '.repeat(Number(c.indent) || 2), what: `${model} interface` });
120
+ }
121
+ function ktSerialization(text, c) {
122
+ if (/import\s+com\.squareup\.moshi\./.test(text))
123
+ return 'moshi';
124
+ if (/import\s+com\.google\.gson\./.test(text))
125
+ return 'gson';
126
+ if (/@Serializable\b|import\s+kotlinx\.serialization\./.test(text))
127
+ return 'kotlinx';
128
+ return c.serialization || 'kotlinx';
129
+ }
130
+ function addKt(text, model, f, c) {
131
+ const m = new RegExp(`data\\s+class\\s+${esc(model)}\\s*(?:<[^>]*>)?\\s*\\(`).exec(text);
132
+ if (!m)
133
+ throw new ToolError('not_supported', `Could not find "data class ${model}(".`);
134
+ const open = m.index + m[0].length - 1;
135
+ const close = matchBrace(text, open, 'dart');
136
+ const ser = ktSerialization(text, c);
137
+ const pkg = /^package\s+([\w.]+)/m.exec(text)?.[1] ?? '';
138
+ const rendered = renderDataClass({ name: model, fields: [f] }, { ...c, serialization: ser }, pkg);
139
+ const lines = between(rendered, /^data class /, /^\)$/);
140
+ const imports = [...rendered.matchAll(/^import\s+([\w.]+)$/gm)].map((x) => x[1]);
141
+ const out = insertBefore(text, close, lines, { comma: true, unit: ' ', what: `${model} constructor` });
142
+ return addKotlinImports(out, imports, pkg);
143
+ }
144
+ function dartStyle(body, cls) {
145
+ if (new RegExp(`const\\s+factory\\s+${esc(cls)}\\s*\\(`).test(body))
146
+ return 'freezed';
147
+ if (new RegExp(`_\\$${esc(cls)}FromJson`).test(body))
148
+ return 'json_serializable';
149
+ return 'plain';
150
+ }
151
+ /** Index of the brace or paren that opens after `re` matches, or -1. */
152
+ function openAfter(text, masked, re, from = 0) {
153
+ re.lastIndex = 0;
154
+ const m = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
155
+ m.lastIndex = from;
156
+ const hit = m.exec(masked);
157
+ return hit ? hit.index + hit[0].length - 1 : -1;
158
+ }
159
+ function addDart(text, cls, f, c) {
160
+ const masked = maskCode(text, 'dart');
161
+ const head = new RegExp(`class\\s+${esc(cls)}\\b[^{]*\\{`).exec(masked);
162
+ if (!head)
163
+ throw new ToolError('not_supported', `Could not find "class ${cls}".`);
164
+ const bodyOpen = head.index + head[0].length - 1;
165
+ const bodyClose = matchBrace(text, bodyOpen, 'dart');
166
+ const style = dartStyle(text.slice(bodyOpen, bodyClose), cls);
167
+ const g = { c: { ...c, serialization: style === 'plain' ? 'manual' : style }, indent: ' ', cls: (n) => n };
168
+ const unit = c.indent === 'tab' ? '\t' : ' '.repeat(Number(c.indent) || 2);
169
+ const fail = (part) => new ToolError('not_supported', `${cls} does not have the ${part} codz expects, so it was left alone.`, 'Add the field by hand, or regenerate the model with codz api update.');
170
+ const one = { name: cls, fields: [f] };
171
+ let out = text;
172
+ // Work from the bottom up so earlier offsets stay valid.
173
+ const steps = [];
174
+ const m2 = maskCode(out, 'dart');
175
+ if (style === 'freezed') {
176
+ const rendered = renderFreezedModel(one, g, 'x');
177
+ const open = openAfter(out, m2, new RegExp(`const\\s+factory\\s+${esc(cls)}\\s*\\(\\s*\\{`), bodyOpen);
178
+ if (open < 0)
179
+ throw fail('named parameters in its factory constructor');
180
+ steps.push({ at: matchBrace(out, open, 'dart'), lines: between(rendered, /const factory .*\(\{$/, /^\s*\}\)/), comma: true, what: 'factory constructor' });
181
+ }
182
+ else {
183
+ const rendered = renderModel(one, g, 'x');
184
+ const ctor = openAfter(out, m2, new RegExp(`(?:const\\s+)?${esc(cls)}\\s*\\(\\s*\\{`), bodyOpen);
185
+ if (ctor < 0 || ctor > bodyClose)
186
+ throw fail('constructor with named parameters');
187
+ steps.push({ at: matchBrace(out, ctor, 'dart'), lines: between(rendered, /^\s*const \w+\(\{$/, /^\s*\}\);$/), comma: true, what: 'constructor' });
188
+ // Fields: after the last `final Type name;` of the class.
189
+ const fieldLines = rendered.split('\n').filter((l, i, all) => /^\s*final /.test(l) || ((/^\s*@JsonKey/.test(l) || /^\s*\/\/ codz:/.test(l)) && /^\s*(final|@JsonKey)/.test(all[i + 1] ?? '')));
190
+ const finals = [...m2.slice(bodyOpen, bodyClose).matchAll(/^[ \t]*(?:late\s+)?final\s+[^;=()]+;[ \t]*$/gm)];
191
+ if (!finals.length)
192
+ throw fail('final fields');
193
+ const last = finals[finals.length - 1];
194
+ const afterLast = bodyOpen + last.index + last[0].length;
195
+ steps.push({ at: -afterLast, lines: fieldLines, comma: false, what: 'fields' });
196
+ if (style === 'plain') {
197
+ const fromJson = openAfter(out, m2, new RegExp(`factory\\s+${esc(cls)}\\.fromJson\\s*\\([^)]*\\)\\s*(?:=>\\s*${esc(cls)}\\s*\\(|\\{[^]*?return\\s+${esc(cls)}\\s*\\()`), bodyOpen);
198
+ if (fromJson < 0)
199
+ throw fail('fromJson factory');
200
+ steps.push({ at: matchBrace(out, fromJson, 'dart'), lines: between(rendered, /fromJson\(/, /^\s*\);$/), comma: true, what: 'fromJson' });
201
+ const toJson = openAfter(out, m2, /toJson\s*\(\s*\)\s*(?:=>\s*(?:<[^>]*>\s*)?\{|\{\s*return\s*(?:<[^>]*>\s*)?\{)/, bodyOpen);
202
+ if (toJson < 0)
203
+ throw fail('toJson method');
204
+ steps.push({ at: matchBrace(out, toJson, 'dart'), lines: between(rendered, /toJson\(\) => \{$/, /^\s*\};$/), comma: true, what: 'toJson' });
205
+ }
206
+ }
207
+ for (const s of steps.sort((a, b) => Math.abs(b.at) - Math.abs(a.at))) {
208
+ if (s.at < 0) {
209
+ const at = -s.at;
210
+ const ind = indentAt(out, at - 1);
211
+ const strip = Math.min(...s.lines.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)[0].length));
212
+ out = out.slice(0, at) + '\n' + s.lines.map((l) => ind + l.slice(strip)).join('\n') + out.slice(at);
213
+ }
214
+ else
215
+ out = insertBefore(out, s.at, s.lines, { comma: s.comma, unit, what: `${cls} ${s.what}` });
216
+ }
217
+ void dartType;
218
+ return out;
219
+ }
220
+ function addSwift(text, model, f, current, c) {
221
+ const type = swiftType(f.type);
222
+ if (!type)
223
+ throw new ToolError('bad_args', `Swift needs a concrete type for ${f.jsonName}.`, `Types: ${FIELD_TYPES.replace('any, ', '')}.`);
224
+ const head = new RegExp(`struct\\s+${esc(model)}\\b[^{]*\\{`).exec(maskCode(text, 'dart'));
225
+ if (!head)
226
+ throw new ToolError('not_supported', `Could not find "struct ${model}".`);
227
+ const open = head.index + head[0].length - 1;
228
+ const close = matchBrace(text, open, 'dart');
229
+ const I = c.indent === 'tab' ? '\t' : ' '.repeat(Number(c.indent) || 4);
230
+ const name = swiftName(f.jsonName);
231
+ const bare = name.replace(/`/g, '');
232
+ const body = text.slice(open, close);
233
+ const lets = [...body.matchAll(/^[ \t]*(?:public\s+)?(?:let|var)\s+(`?\w+`?)\s*:[^\n]*$/gm)];
234
+ const keys = /enum\s+CodingKeys\s*:\s*String[^{]*\{/.exec(body);
235
+ let out = text;
236
+ const edits = [];
237
+ const decl = `let ${name}: ${type}${f.nullable ? '?' : ''}`;
238
+ if (lets.length) {
239
+ const last = lets[lets.length - 1];
240
+ const at = open + last.index + last[0].length;
241
+ edits.push({ at, insert: `\n${indentAt(text, at - 1)}${decl}` });
242
+ }
243
+ else
244
+ edits.push({ at: open + 1, insert: `\n${I}${decl}` });
245
+ const caseLine = bare === f.jsonName ? `case ${name}` : `case ${name} = "${swiftText(f.jsonName)}"`;
246
+ if (keys) {
247
+ const kOpen = open + keys.index + keys[0].length - 1;
248
+ const kClose = matchBrace(text, kOpen, 'dart');
249
+ edits.push({ at: lineStart(text, kClose), insert: `${indentAt(text, kClose)}${I}${caseLine}\n` });
250
+ }
251
+ else if (bare !== f.jsonName) {
252
+ // A new snake_case key needs CodingKeys; list the existing properties too.
253
+ const existing = lets.map((l) => l[1]).map((n) => {
254
+ const json = current.fields.find((x) => swiftName(x.name) === n)?.name ?? n.replace(/`/g, '');
255
+ return n.replace(/`/g, '') === json ? `case ${n}` : `case ${n} = "${swiftText(json)}"`;
256
+ });
257
+ const ind = indentAt(text, close) + I;
258
+ edits.push({ at: lineStart(text, close), insert: `\n${ind}enum CodingKeys: String, CodingKey {\n${[...existing, caseLine].map((l) => `${ind}${I}${l}`).join('\n')}\n${ind}}\n` });
259
+ }
260
+ for (const e of edits.sort((a, b) => b.at - a.at))
261
+ out = out.slice(0, e.at) + e.insert + out.slice(e.at);
262
+ return out;
263
+ }
264
+ // ---------- remove, per language ----------
265
+ /** Removes whole lines matching `re` inside [from, to), plus annotation or comment lines directly above them. */
266
+ function dropLines(text, from, to, re, aboveRe = /^\s*(\/\/ codz:|\/\*\*.*\*\/\s*$|@JsonKey\b[^\n]*\)\s*$)/) {
267
+ const L = text.split('\n');
268
+ let pos = 0;
269
+ const drop = new Set();
270
+ L.forEach((l, i) => {
271
+ const s = pos;
272
+ pos += l.length + 1;
273
+ if (s < from || s >= to || !re.test(l))
274
+ return;
275
+ drop.add(i);
276
+ for (let j = i - 1; j >= 0 && aboveRe.test(L[j]); j--)
277
+ drop.add(j);
278
+ });
279
+ return { text: L.filter((_, i) => !drop.has(i)).join('\n'), n: [...drop].filter((i) => re.test(L[i])).length };
280
+ }
281
+ function removeField(text, lang, model, f) {
282
+ const masked = maskCode(text, lang === 'ts' ? 'ts' : 'dart');
283
+ const headRe = lang === 'ts' ? null : lang === 'kt' ? new RegExp(`data\\s+class\\s+${esc(model)}\\s*(?:<[^>]*>)?\\s*\\(`) : lang === 'swift' ? new RegExp(`struct\\s+${esc(model)}\\b[^{]*\\{`) : new RegExp(`class\\s+${esc(model)}\\b[^{]*\\{`);
284
+ let from;
285
+ let to;
286
+ if (lang === 'ts') {
287
+ const b = findTsBlock(text, model);
288
+ if (!b)
289
+ throw new ToolError('not_supported', `Could not find "export interface ${model}".`);
290
+ [from, to] = [b.start, b.end];
291
+ }
292
+ else {
293
+ const h = headRe.exec(masked);
294
+ if (!h)
295
+ throw new ToolError('not_supported', `Could not find ${model}.`);
296
+ from = h.index;
297
+ to = matchBrace(text, h.index + h[0].length - 1, 'dart') + 1;
298
+ }
299
+ const j = esc(f.jsonName);
300
+ if (lang === 'ts')
301
+ return dropLines(text, from, to, new RegExp(`^\\s*(['"]?)${j}\\1\\??\\s*:`));
302
+ if (lang === 'kt') {
303
+ const n = esc(kotlinName(f.jsonName));
304
+ return dropLines(text, from, to, new RegExp(`^\\s*(@\\w+\\([^)]*\\)\\s*)*(override\\s+)?va[lr]\\s+${n}\\s*:`), /^\s*\/\/ codz:/);
305
+ }
306
+ if (lang === 'swift') {
307
+ const n = esc(swiftName(f.jsonName));
308
+ return dropLines(text, from, to, new RegExp(`^\\s*((public|private)\\s+)?(let|var)\\s+${n}\\s*:|^\\s*case\\s+${n}\\b(\\s*=\\s*"[^"]*")?\\s*$`), /^\s*\/\/ codz:/);
309
+ }
310
+ const n = esc(fieldName({ jsonName: f.jsonName, type: { kind: 'string' }, nullable: true }));
311
+ return dropLines(text, from, to, new RegExp([
312
+ `^\\s*(required\\s+)?this\\.${n}\\s*,?\\s*$`, // constructor
313
+ `^\\s*(@\\w+\\([^)]*\\)\\s*)*(required\\s+)?[\\w<>?, ]+\\s+${n}\\s*,?\\s*$`, // freezed parameter
314
+ `^\\s*${n}\\s*:.*,\\s*$`, // fromJson argument
315
+ `^\\s*(late\\s+)?final\\s+[\\w<>?, ]+\\s+${n}\\s*;\\s*$`, // field
316
+ `^\\s*['"]${j}['"]\\s*:.*,\\s*$`, // toJson entry
317
+ ].join('|')));
318
+ }
319
+ // ---------- plans ----------
320
+ /** Files outside the models that use the model, with the lines that use the given fields. */
321
+ function usages(root, map, lang, model, fields, files) {
322
+ const modelFiles = new Set(map.models.map((m) => m.file));
323
+ const word = new RegExp(`\\b${esc(model.name)}\\b`);
324
+ const users = [];
325
+ const hits = [];
326
+ const access = (json) => (lang === 'ts' ? json : lang === 'kt' ? kotlinName(json) : lang === 'swift' ? swiftName(json) : fieldName({ jsonName: json, type: { kind: 'string' }, nullable: true })).replace(/`/g, '');
327
+ for (const f of files) {
328
+ if (modelFiles.has(f))
329
+ continue;
330
+ const t = readText(join(root, f)) ?? '';
331
+ if (!word.test(t))
332
+ continue;
333
+ users.push(f);
334
+ t.split('\n').forEach((l, i) => {
335
+ for (const field of fields)
336
+ if (new RegExp(`\\.${esc(access(field))}\\b|\\[['"]${esc(field)}['"]\\]`).test(l))
337
+ hits.push(` ${f}:${i + 1} ${l.trim()}`);
338
+ });
339
+ }
340
+ return { users, hits };
341
+ }
342
+ function codeFilesOf(root, lang) {
343
+ if (lang === 'dart')
344
+ return walk(root, 'lib', { exts: ['.dart'] }).filter((f) => !/\.(g|freezed)\.dart$/.test(f));
345
+ if (lang === 'kt')
346
+ return walk(root, '.', { exts: ['.kt'], extraIgnored: ['build', '.gradle'] });
347
+ if (lang === 'swift')
348
+ return walk(root, '.', { exts: ['.swift'], extraIgnored: ['build', 'DerivedData'] });
349
+ return walk(root, '.', { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs'] }).filter((f) => !f.endsWith('.d.ts'));
350
+ }
351
+ export async function planFieldAdd(root, adapter, modelName, args) {
352
+ if (!args.length)
353
+ throw new ToolError('bad_args', 'Which field? codz field add <Model> <name:type> ...', 'Example: codz field add Product rating:double? tags:string[]');
354
+ const lang = fieldLangOf(adapter);
355
+ const conventions = loadConfig(root).conventions;
356
+ const { map } = await loadMap(root, adapter, conventions);
357
+ const model = findModel(map, modelName);
358
+ const fields = args.map((a) => parseFieldArg(a, map));
359
+ let text = readText(join(root, model.file));
360
+ if (text === null)
361
+ throw new ToolError('not_found', `Cannot read ${model.file}.`);
362
+ const summary = [];
363
+ const warnings = [];
364
+ const norm = (s) => s.replace(/[_\-\s]/g, '').toLowerCase();
365
+ for (const f of fields) {
366
+ if (model.fields.some((x) => norm(x.name) === norm(f.jsonName)))
367
+ throw new ToolError('field_exists', `${model.name} already has ${f.jsonName}.`, `To change its type: codz field remove ${model.name} ${f.jsonName}, then add it again.`);
368
+ const c = map.conventions;
369
+ text = lang === 'ts' ? addTs(text, model.name, f, c) : lang === 'kt' ? addKt(text, model.name, f, c) : lang === 'swift' ? addSwift(text, model.name, f, model, c) : addDart(text, model.name, f, c);
370
+ model.fields.push({ name: f.jsonName, type: '', nullable: f.nullable });
371
+ summary.push(`Add ${f.jsonName}${f.nullable ? '?' : ''}: ${describe(f.type)} to ${model.name} (${model.file})`);
372
+ }
373
+ const required = fields.filter((f) => !f.nullable && f.type.kind !== 'unknown').map((f) => f.jsonName);
374
+ if (required.length && lang !== 'ts')
375
+ warnings.push(`${required.join(', ')} ${required.length === 1 ? 'is' : 'are'} required: responses without ${required.length === 1 ? 'it' : 'them'} will fail to parse. Add ? (rating:double?) if older responses may not have ${required.length === 1 ? 'it' : 'them'}.`);
376
+ if (lang === 'dart' && /part\s+'[^']+\.(g|freezed)\.dart'/.test(text))
377
+ warnings.push('Run dart run build_runner build to regenerate the .g.dart / .freezed.dart files.');
378
+ const { users } = usages(root, map, lang, model, [], codeFilesOf(root, lang));
379
+ if (users.length)
380
+ summary.push(`${model.name} is used in ${users.length} file${users.length === 1 ? '' : 's'}; show the new field where it belongs:`, ...users.slice(0, 12).map((u) => ` ${u}`), ...(users.length > 12 ? [` … and ${users.length - 12} more`] : []));
381
+ summary.push('codz mock serve picks the new field up with sample data.');
382
+ return buildChangeSet(root, 'field add', { files: new Map([[model.file, text]]), summary, warnings, generated: [] });
383
+ }
384
+ export async function planFieldRemove(root, adapter, modelName, names) {
385
+ if (!names.length)
386
+ throw new ToolError('bad_args', 'Which field? codz field remove <Model> <field> ...');
387
+ const lang = fieldLangOf(adapter);
388
+ const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
389
+ const model = findModel(map, modelName);
390
+ let text = readText(join(root, model.file));
391
+ if (text === null)
392
+ throw new ToolError('not_found', `Cannot read ${model.file}.`);
393
+ const norm = (s) => s.replace(/[_\-\s]/g, '').toLowerCase();
394
+ const summary = [];
395
+ const removed = [];
396
+ for (const raw of names) {
397
+ const f = model.fields.find((x) => x.name === raw) ?? model.fields.find((x) => norm(x.name) === norm(raw));
398
+ if (!f)
399
+ throw new ToolError('no_field', `${model.name} has no field ${raw}.`, `Fields: ${model.fields.map((x) => x.name).join(', ')}`);
400
+ const r = removeField(text, lang, model.name, { jsonName: f.name });
401
+ if (!r.n)
402
+ throw new ToolError('not_supported', `Could not find the lines for ${f.name} in ${model.file}; remove it by hand.`);
403
+ text = r.text;
404
+ removed.push(f.name);
405
+ summary.push(`Remove ${f.name} from ${model.name} (${model.file})`);
406
+ }
407
+ const warnings = [];
408
+ if (lang === 'dart' && /part\s+'[^']+\.(g|freezed)\.dart'/.test(text))
409
+ warnings.push('Run dart run build_runner build to regenerate the .g.dart / .freezed.dart files.');
410
+ const { hits } = usages(root, map, lang, model, removed, codeFilesOf(root, lang));
411
+ if (hits.length)
412
+ warnings.push(`These lines still use ${removed.join(', ')}; update them:\n${hits.slice(0, 30).join('\n')}${hits.length > 30 ? `\n … and ${hits.length - 30} more` : ''}`);
413
+ else
414
+ summary.push(`No code outside the model uses ${removed.join(', ')}.`);
415
+ return buildChangeSet(root, 'field remove', { files: new Map([[model.file, text]]), summary, warnings, generated: [] });
416
+ }
417
+ function describe(t) {
418
+ return t.kind === 'array' ? `${describe(t.item)}[]` : t.kind === 'object' ? t.model : t.kind === 'unknown' ? 'any' : t.kind;
419
+ }
@@ -19,6 +19,7 @@ import { readText } from "../core/fsutil.js";
19
19
  import { ToolError } from "../core/types.js";
20
20
  import { camel, kebab, pascal, words } from "../input/naming.js";
21
21
  import { prefillFor } from "./prefill.js";
22
+ import { detectPaging, PAGE_SIZE, pagingSummary, pagingWarnings } from "./paging.js";
22
23
  function names(name) {
23
24
  const base = pascal(name.replace(/[-_ ]?(view|screen|page)$/i, ''));
24
25
  return { base, view: `${base}View`, vm: `${base}ViewModel`, title: words(base).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ') };
@@ -170,6 +171,8 @@ export function planIosScreen(root, map, opts) {
170
171
  throw new ToolError('screen_needs_data', `${ep.name}() returns nothing, so there is no data to show.`);
171
172
  const shape = shapeOf(T, map);
172
173
  const I = x.I;
174
+ if (opts.paginate || opts.search)
175
+ return iosPaged(x, ep, opts, call, shape, n, viewFile, vmFile);
173
176
  ensureLoadState(x);
174
177
  x.files.set(vmFile, [
175
178
  'import Foundation',
@@ -466,3 +469,154 @@ export function linkIosList(text, t) {
466
469
  }
467
470
  return changed ? out : null;
468
471
  }
472
+ /** A paged or searchable list: the view model keeps items, loading, error, query (and hasMore); the view uses .searchable and loads more at the end. */
473
+ function iosPaged(x, ep, opts, call, shape, n, viewFile, vmFile) {
474
+ const T = call.sig.returns;
475
+ if (!shape.list)
476
+ throw new ToolError('screen_not_list', `${ep.name}() returns ${T}, not a list; --paginate and --search are for list screens.`);
477
+ const model = shape.model;
478
+ const paging = detectPaging(call.sig.params.map((p) => ({ name: p.label, type: p.type })), { paginate: opts.paginate, search: opts.search, modelStringFields: (model?.fields ?? []).filter((f) => f.type === 'String').map((f) => f.name), titleField: titleOf(model)?.name });
479
+ const item = /^\[(.+)\]\??$/.exec(T)?.[1] ?? 'Any';
480
+ const I = x.I;
481
+ const L = (d, t) => `${I.repeat(d)}${t}`;
482
+ const pg = paging.paginate;
483
+ const searchable = Boolean(paging.search || paging.clientSearch);
484
+ const first = paging.page?.mode === 'offset' ? '0' : '1';
485
+ // Swift arguments go in declaration order.
486
+ const route = new Set(call.inits.map((p) => p.label));
487
+ const args = call.sig.params
488
+ .map((p) => {
489
+ if (route.has(p.label))
490
+ return `${p.label}: ${swiftName(p.label)}`;
491
+ if (pg && paging.page?.name === p.label)
492
+ return `${p.label}: next`;
493
+ if (pg && paging.size?.name === p.label)
494
+ return `${p.label}: Self.pageSize`;
495
+ if (paging.search?.name === p.label)
496
+ return `${p.label}: search.isEmpty ? nil : search`;
497
+ return null;
498
+ })
499
+ .filter((a) => !!a)
500
+ .join(', ');
501
+ const fields = paging.searchFields.map((f) => (model?.fields.find((m) => m.name === f)?.nullable ? `item.${swiftName(f)} ?? ""` : `item.${swiftName(f)}`));
502
+ x.files.set(vmFile, [
503
+ 'import Foundation',
504
+ '',
505
+ '@MainActor',
506
+ `final class ${n.vm}: ObservableObject {`,
507
+ L(1, `@Published private(set) var items: [${item}] = []`),
508
+ L(1, '@Published private(set) var loading = false'),
509
+ L(1, '@Published private(set) var error: String?'),
510
+ ...(pg ? [L(1, '@Published private(set) var hasMore = true')] : []),
511
+ ...(searchable ? [L(1, '@Published var query = ""')] : []),
512
+ ...(pg && paging.size ? ['', L(1, `private static let pageSize = ${PAGE_SIZE}`)] : []),
513
+ ...(pg && paging.page.mode === 'page' ? [L(1, 'private var page = 0')] : []),
514
+ L(1, 'private var task: Task<Void, Never>?'),
515
+ ...viewModelHead(x, n.vm, call.inits),
516
+ ...(paging.clientSearch
517
+ ? [
518
+ '',
519
+ L(1, '/// The items that match the search.'),
520
+ L(1, `var visible: [${item}] {`),
521
+ L(2, 'let q = query.trimmingCharacters(in: .whitespaces)'),
522
+ L(2, 'guard !q.isEmpty else { return items }'),
523
+ L(2, `return items.filter { item in [${fields.join(', ')}].contains { $0.localizedCaseInsensitiveContains(q) } }`),
524
+ L(1, '}'),
525
+ ]
526
+ : []),
527
+ '',
528
+ L(1, `/// Loads ${pg ? 'the first page again (reset) or the next one' : 'the list'}. A new search or refresh replaces a request still running.`),
529
+ L(1, `func load(${pg ? 'reset: Bool = false' : ''}) async {`),
530
+ ...(pg ? [L(2, 'if !reset && (loading || !hasMore) { return }')] : []),
531
+ L(2, 'task?.cancel()'),
532
+ L(2, `let task = Task { await fetch(${pg ? 'reset: reset' : ''}) }`),
533
+ L(2, 'self.task = task'),
534
+ L(2, 'await task.value'),
535
+ L(1, '}'),
536
+ '',
537
+ L(1, `private func fetch(${pg ? 'reset: Bool' : ''}) async {`),
538
+ L(2, 'loading = true'),
539
+ L(2, 'error = nil'),
540
+ ...(paging.search ? [L(2, 'let search = query.trimmingCharacters(in: .whitespaces)')] : []),
541
+ L(2, 'do {'),
542
+ ...(pg ? [L(3, `let next = reset ? ${first} : ${paging.page.mode === 'offset' ? 'items.count' : 'page + 1'}`)] : []),
543
+ L(3, `let rows = try await api.${ep.name}(${args})`),
544
+ L(3, 'if Task.isCancelled { return }'),
545
+ ...(pg ? [L(3, 'items = reset ? rows : items + rows'), ...(paging.page.mode === 'page' ? [L(3, 'page = next')] : []), L(3, `hasMore = ${paging.size ? 'rows.count == Self.pageSize' : '!rows.isEmpty'}`)] : [L(3, 'items = rows')]),
546
+ L(2, '} catch {'),
547
+ L(3, 'if Task.isCancelled { return }'),
548
+ L(3, 'self.error = error.localizedDescription'),
549
+ L(2, '}'),
550
+ L(2, 'loading = false'),
551
+ L(1, '}'),
552
+ '}',
553
+ '',
554
+ ].join('\n'));
555
+ x.summary.push(`Create ${vmFile}: ${n.vm} with items, loading, error${searchable ? ', query' : ''}${pg ? ', hasMore' : ''}, calling APIClient.${ep.name}()`);
556
+ const tf = titleOf(model);
557
+ const identifiable = !!model?.fields.some((f) => f.name === 'id' && !f.nullable);
558
+ const shown = paging.clientSearch ? 'model.visible' : 'model.items';
559
+ const reset = pg ? 'reset: true' : '';
560
+ const row = `Text(${tf ? show('item', tf) : 'String(describing: item)'})`;
561
+ x.files.set(viewFile, [
562
+ 'import SwiftUI',
563
+ '',
564
+ `struct ${n.view}: View {`,
565
+ ...viewInit(x, n.vm, call.inits),
566
+ '',
567
+ L(1, 'var body: some View {'),
568
+ L(2, 'content'),
569
+ L(3, `.navigationTitle("${n.title}")`),
570
+ ...(searchable ? [L(3, '.searchable(text: $model.query)')] : []),
571
+ ...(paging.search
572
+ ? [L(3, '// Loads on appear, and again after a short pause in typing.'), L(3, '.task(id: model.query) {'), L(4, 'if !model.query.isEmpty { try? await Task.sleep(nanoseconds: 400_000_000) }'), L(4, 'if Task.isCancelled { return }'), L(4, `await model.load(${reset})`), L(3, '}')]
573
+ : [L(3, `.task { await model.load(${reset}) }`)]),
574
+ L(3, `.refreshable { await model.load(${reset}) }`),
575
+ L(1, '}'),
576
+ '',
577
+ L(1, '@ViewBuilder'),
578
+ L(1, 'private var content: some View {'),
579
+ L(2, 'if model.items.isEmpty && model.loading {'),
580
+ L(3, 'ProgressView()'),
581
+ L(2, '} else if model.items.isEmpty, let error = model.error {'),
582
+ L(3, 'VStack(spacing: 12) {'),
583
+ L(4, 'Text(error)'),
584
+ L(5, '.multilineTextAlignment(.center)'),
585
+ L(4, `Button("Retry") { Task { await model.load(${reset}) } }`),
586
+ L(3, '}'),
587
+ L(3, '.padding()'),
588
+ L(2, `} else if ${shown}.isEmpty${pg ? ' && !model.hasMore' : ''} {`),
589
+ L(3, `Text(${searchable ? 'model.query.isEmpty ? "Nothing here yet." : "No matches."' : '"Nothing here yet."'})`),
590
+ L(4, '.foregroundStyle(.secondary)'),
591
+ L(2, '} else {'),
592
+ L(3, 'List {'),
593
+ ...(identifiable ? [L(4, `ForEach(${shown}) { item in`), L(5, row), L(4, '}')] : [L(4, `ForEach(Array(${shown}.enumerated()), id: \\.offset) { _, item in`), L(5, row), L(4, '}')]),
594
+ ...(pg
595
+ ? [
596
+ L(4, 'if model.error != nil {'),
597
+ L(5, 'Button("Retry") { Task { await model.load() } }'),
598
+ L(4, '} else if model.hasMore {'),
599
+ L(5, '// The end of the list is on screen: ask for the next page.'),
600
+ L(5, 'ProgressView()'),
601
+ L(6, '.frame(maxWidth: .infinity)'),
602
+ L(6, '.id(model.items.count)'),
603
+ L(6, '.onAppear { Task { await model.load() } }'),
604
+ L(4, '}'),
605
+ ]
606
+ : []),
607
+ L(3, '}'),
608
+ L(2, '}'),
609
+ L(1, '}'),
610
+ '}',
611
+ '',
612
+ '#Preview {',
613
+ L(1, 'NavigationStack {'),
614
+ L(2, `${n.view}(${call.inits.map((p) => `${swiftName(p.label)}: ${sample(p.type)}`).join(', ')})`),
615
+ L(1, '}'),
616
+ '}',
617
+ '',
618
+ ].join('\n'));
619
+ x.summary.push(`Create ${viewFile}: ${n.view} lists them and ${pagingSummary(paging)}`);
620
+ x.warnings.push(...pagingWarnings(paging));
621
+ return finish(x, 'screen add', n.view, call.inits, opts);
622
+ }
@@ -226,10 +226,19 @@ export async function planLocaleAdd(root, adapter, lang, opts = {}) {
226
226
  // ---------------- locale key ----------------
227
227
  export async function planLocaleKey(root, adapter, key, text, opts = {}) {
228
228
  const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
229
+ const r = await localeKeyFiles(root, adapter, map, key, text, opts);
230
+ return buildChangeSet(root, 'locale key', { files: r.files, summary: r.summary, warnings: r.warnings, generated: [] });
231
+ }
232
+ /** The locale file edits for one key; `overlay` holds files already changed in this plan (locale extract adds many keys). */
233
+ export async function localeKeyFiles(root, adapter, map, key, text, opts = {}) {
234
+ const read = (f) => opts.overlay?.get(f) ?? readText(join(root, f));
229
235
  const files = new Map();
230
236
  const summary = [];
231
237
  const warnings = [];
232
238
  let set = readLocales(root, map);
239
+ const plannedGetx = [...(opts.overlay?.keys() ?? [])].find((f) => f.endsWith('app_translations.dart'));
240
+ if (!set && plannedGetx)
241
+ set = { kind: 'getx', defaultLang: 'en', files: new Map([['en', plannedGetx]]), keys: new Map([['en', new Map()]]), translationsFile: plannedGetx };
233
242
  if (!set) {
234
243
  if (adapter.id === 'flutter' && (map.conventions.stateManagement === 'getx' || map.conventions.di === 'getx')) {
235
244
  createGetxTranslations(root, map, ['en'], files, summary, warnings);
@@ -267,7 +276,7 @@ export async function planLocaleKey(root, adapter, key, text, opts = {}) {
267
276
  if (set.kind === 'json' || set.kind === 'arb') {
268
277
  const placeholders = [...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
269
278
  for (const [lang, file] of set.files) {
270
- const raw = readText(join(root, file));
279
+ const raw = read(file);
271
280
  let next;
272
281
  if (set.kind === 'arb') {
273
282
  next = jsonSet(raw, [finalKey], valueFor(lang));
@@ -283,11 +292,11 @@ export async function planLocaleKey(root, adapter, key, text, opts = {}) {
283
292
  }
284
293
  else {
285
294
  // GetX: one map per language, maybe in different files.
286
- const pending = new Map(files);
295
+ const pending = new Map([...(opts.overlay ?? []), ...files]);
287
296
  const byFile = new Map();
288
297
  for (const [lang, file] of set.files) {
289
298
  const t = pending.get(file) ?? readText(join(root, file));
290
- const blocks = file === set.translationsFile && pending.has(file) ? getxBlocksFromText(t, file) : getxBlocks(root, set.translationsFile).filter((b) => b.file === file);
299
+ const blocks = pending.has(file) ? getxBlocksFromText(t, file) : getxBlocks(root, set.translationsFile).filter((b) => b.file === file);
291
300
  const b = blocks.find((x) => x.lang === lang);
292
301
  if (b)
293
302
  byFile.set(file, [...(byFile.get(file) ?? []), { lang, open: b.open, close: b.close }]);
@@ -317,7 +326,7 @@ export async function planLocaleKey(root, adapter, key, text, opts = {}) {
317
326
  }
318
327
  if (untranslated.length)
319
328
  warnings.push(`${finalKey} uses the ${set.defaultLang} text for ${untranslated.join(', ')}; translate it there, or pass --value ${untranslated[0]}="...".`);
320
- return buildChangeSet(root, 'locale key', { files, summary, warnings, generated: [] });
329
+ return { files, summary, warnings, key: finalKey };
321
330
  }
322
331
  function getxBlocksFromText(t, file) {
323
332
  const out = [];