baucli 1.0.1__py3-none-any.whl

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.
baucli/apex_rebuild.py ADDED
@@ -0,0 +1,1458 @@
1
+ """APEX page rebuild — rewrite labels / alignment / format masks in .apx files
2
+ from BAU table metadata, and optionally push the rewritten page back into
3
+ the running APEX instance via `sql apex import`.
4
+
5
+ Scope (matches the agreement with the user):
6
+ - Page items inside form regions: `pageItem <NAME> ( ... )`
7
+ - IR/IG columns inside report regions: `column <NAME> ( ... )`
8
+ - savedReport `displayColumn <NAME> ( ... )` — sequence reordering only.
9
+
10
+ Attributes rewritten when a BAU match is found:
11
+ * `label: ...` on pageItem → BAU.LABEL
12
+ * `heading { heading: ... }` on column → BAU.LABEL
13
+ * `help { helpText: ... }` on pageItem → BAU.DESCRIPTION
14
+ (single-line collapsed; help is always the LAST nested block — the
15
+ dominant position across every sampled export)
16
+ * `settings { textCase: upper|lower }` → BAU.TEXT_CASE U/L
17
+ (textField blocks only; Initcap has no APEX equivalent and is skipped)
18
+ * `appearance { formatMask: ... }` → BAU.FORMAT_MASK (added if missing)
19
+ * `layout { columnAlignment: ... }` → BAU.ALIGN_REPORT or ALIGN_FORM
20
+ (mapped L/C/R → start/center/end; missing means "leave as-is")
21
+ * `type: hidden` when BAU.HIDDEN = Y — display-only properties
22
+ (formatMask, columnAlignment) are STRIPPED from blocks that are or
23
+ become hidden: the APEX 26 parser rejects them there (zero occurrences
24
+ inside `type: hidden` blocks across all sampled exports)
25
+ * `type: displayOnly` when BAU.READ_ONLY = Y and BAU.HIDDEN != Y (IR cols)
26
+ * `readOnly { type: always }` when BAU.READ_ONLY = Y (pageItem)
27
+
28
+ Design choices
29
+ ==============
30
+ The .apx format isn't well-described publicly, but it is a deterministic
31
+ indented key/value grammar with balanced parentheses for blocks. A full
32
+ parser would be overkill for ~10 attributes; instead the module:
33
+ 1. Loads the file as text.
34
+ 2. Scans for the markers `pageItem <NAME> (`, `column <NAME> (`,
35
+ `displayColumn <NAME> (`.
36
+ 3. For each, walks forward to the matching `)` (paren-depth aware,
37
+ string-literal aware) to delimit the block.
38
+ 4. Within the block, applies idempotent text edits — read the existing
39
+ value, decide on new value, splice the new text back into the buffer.
40
+
41
+ The script never reorders blocks (would be too invasive without a proper
42
+ parser). For column ordering, only `sequence:` inside layout/displayColumn
43
+ is touched, leaving the textual block order alone.
44
+
45
+ Discovery
46
+ =========
47
+ The .apx file is found via the project convention:
48
+ <project-root>/apex/<workspace>/pages/p<padded-id>-*.apx
49
+ The function `find_apex_file` returns the unique match or raises with a
50
+ diagnostic message listing what it tried.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ import re
56
+ import subprocess
57
+ from dataclasses import dataclass, field
58
+ from pathlib import Path
59
+ from typing import Iterable
60
+
61
+
62
+ # ─────────────────────────────────────────────────────────────────────────────
63
+ # Data carrier for the BAU metadata
64
+ # ─────────────────────────────────────────────────────────────────────────────
65
+ @dataclass
66
+ class BauColumn:
67
+ name: str
68
+ label: str | None = None
69
+ description: str | None = None
70
+ format_mask: str | None = None
71
+ align_report: str | None = None # 'L'|'C'|'R' or None
72
+ align_form: str | None = None
73
+ text_case: str | None = None
74
+ hidden: bool = False
75
+ read_only: bool = False
76
+ show_in_forms: bool = True
77
+ show_in_reports: bool = True
78
+ report_display_order: int | None = None
79
+ form_display_order: int | None = None
80
+ is_pk: bool = False
81
+ is_fk: bool = False
82
+ type: str | None = None
83
+ form_item_type: str | None = None # exact APEXLang token (checkbox, ...)
84
+ # UI default for the generated item: form_default (explicit/rule, used
85
+ # as-is) takes precedence over the raw table data_default (filtered to
86
+ # literals by _safe_default).
87
+ form_default: str | None = None
88
+ data_default: str | None = None
89
+ # CHECK-constraint domain as (return_value, display_label) pairs — used
90
+ # to synthesize a static LOV when FORM_ITEM_TYPE needs one.
91
+ domain_values: list = field(default_factory=list)
92
+
93
+ @classmethod
94
+ def from_api(cls, d: dict) -> "BauColumn":
95
+ return cls(
96
+ name=d.get("name", "").upper(),
97
+ label=d.get("label"),
98
+ description=d.get("description"),
99
+ format_mask=d.get("format_mask"),
100
+ align_report=d.get("align_report"),
101
+ align_form=d.get("align_form"),
102
+ text_case=d.get("text_case"),
103
+ hidden=bool(d.get("hidden")),
104
+ read_only=bool(d.get("read_only")),
105
+ show_in_forms=d.get("show_in_forms") != False,
106
+ show_in_reports=d.get("show_in_reports") != False,
107
+ report_display_order=d.get("report_display_order"),
108
+ form_display_order=d.get("form_display_order"),
109
+ is_pk=bool(d.get("is_pk")),
110
+ is_fk=bool(d.get("is_fk")),
111
+ type=d.get("type"),
112
+ form_item_type=d.get("form_item_type"),
113
+ form_default=d.get("form_default"),
114
+ data_default=d.get("data_default"),
115
+ domain_values=[
116
+ (dv.get("value"), dv.get("label") or dv.get("value"))
117
+ for dv in (d.get("domain_values") or [])
118
+ if dv.get("value") is not None
119
+ ],
120
+ )
121
+
122
+
123
+ # IR/IG `column` blocks use `layout { columnAlignment: start|center|end }`.
124
+ ALIGN_MAP = {"L": "start", "C": "center", "R": "end"}
125
+ # `pageItem` blocks use `layout { alignment: left|center|right }` — a
126
+ # different key AND vocabulary (835 columnAlignment occurrences in the
127
+ # sampled exports, all in columns; pageItems only ever carry `alignment`).
128
+ ALIGN_MAP_ITEM = {"L": "left", "C": "center", "R": "right"}
129
+
130
+ # BAU TEXT_CASE → APEXLang `settings { textCase: ... }` token. 'I' (Initcap)
131
+ # has no APEX equivalent and is intentionally absent.
132
+ TEXT_CASE_MAP = {"U": "upper", "L": "lower"}
133
+
134
+ # Item types that legitimately carry an `lov { ... }` block. When
135
+ # FORM_ITEM_TYPE retypes an item away from these, the leftover lov block
136
+ # is invalid and must be stripped.
137
+ LOV_ITEM_TYPES = {"selectList", "radioGroup", "popupLov", "shuttle",
138
+ "checkboxGroup"}
139
+
140
+ # Item types where `appearance { height: N }` (number of rows) is valid.
141
+ # Every other type rejects it as INVALID_PROPERTY at import, so when a
142
+ # FORM_ITEM_TYPE retype moves an item away from these the leftover height
143
+ # must be stripped (e.g. a textField with height:1 retyped to radioGroup).
144
+ HEIGHT_ITEM_TYPES = {"textarea", "richTextEditor", "markdownEditor"}
145
+
146
+
147
+ # ─────────────────────────────────────────────────────────────────────────────
148
+ # Auto-discovery of tables referenced by a page
149
+ # ─────────────────────────────────────────────────────────────────────────────
150
+ # Common SQL identifiers that are not table names but appear after FROM / JOIN
151
+ # (subquery aliases, set-keywords, system pseudo-tables, CTE markers).
152
+ _NOT_A_TABLE = {
153
+ "DUAL", "SELECT", "WHERE", "WITH", "ORDER", "GROUP",
154
+ "LATERAL", "TABLE", "VALUES", "UNION", "JOIN", "ON",
155
+ }
156
+
157
+
158
+ def discover_tables(source: str) -> list[str]:
159
+ """Return the uppercase table names referenced by a page, in priority
160
+ order.
161
+
162
+ Looks at two signals:
163
+
164
+ 1. `tableName: X` lines — APEXLang's explicit table reference in form
165
+ regions and table-mode IRs.
166
+ 2. `FROM <schema>.<table>` and `JOIN <schema>.<table>` inside `sqlQuery`
167
+ blocks (triple-backtick fences). The schema prefix is stripped.
168
+
169
+ ORDER MATTERS: `tableName:`-sourced tables come first (file order), then
170
+ FROM/JOIN-sourced ones. The caller aggregates column metadata first-wins,
171
+ so on column-name collisions (ID, ACTIVE and audit columns exist in
172
+ EVERY BAU-convention table) the form's base table takes precedence over
173
+ lookup/join tables.
174
+
175
+ SQL identifiers that occur after FROM/JOIN but aren't tables (DUAL,
176
+ subquery aliases, etc.) are filtered out by name. Anything not known to
177
+ BAU just returns empty `columns` and contributes nothing — so the cost
178
+ of a false positive here is one extra REST call.
179
+ """
180
+ ordered: list[str] = []
181
+ seen: set[str] = set()
182
+
183
+ def _add(name: str) -> None:
184
+ if name not in seen:
185
+ seen.add(name)
186
+ ordered.append(name)
187
+
188
+ for m in re.finditer(
189
+ r"^\s*tableName\s*:\s*([A-Za-z_][\w]*)", source, re.MULTILINE
190
+ ):
191
+ _add(m.group(1).upper())
192
+
193
+ for m in re.finditer(
194
+ r"\b(?:from|join)\s+(?:[A-Za-z_][\w]*\.)?([A-Za-z_][\w]*)",
195
+ source, re.IGNORECASE,
196
+ ):
197
+ name = m.group(1).upper()
198
+ if name in _NOT_A_TABLE:
199
+ continue
200
+ _add(name)
201
+
202
+ return ordered
203
+
204
+
205
+ # Simple [schema.]table.column / column reference (the "this column IS the
206
+ # stored value" case). Anything else aliased to a column name (scalar
207
+ # subquery, function call, CASE, concat …) is a DERIVED display value.
208
+ _BARE_COLREF = re.compile(
209
+ r'^"?[A-Za-z_][\w$#]*"?(?:\s*\.\s*"?[A-Za-z_][\w$#]*"?){0,2}$')
210
+ # Split a select item into (expression, output-alias). Alias = trailing
211
+ # identifier, with optional `AS`. Lazy head + `$` anchor force the alias to
212
+ # be the LAST token even past CASE/END.
213
+ _SELECT_ALIAS = re.compile(
214
+ r'^(.*?)\s+(?:as\s+)?"?([A-Za-z_][\w$#]*)"?$',
215
+ re.IGNORECASE | re.DOTALL)
216
+
217
+
218
+ def derived_display_cols(source: str) -> set[str]:
219
+ """Return the UPPER output names of select-list projections that render a
220
+ DERIVED value (scalar subquery / function / expression) aliased to a
221
+ column name — e.g. `(select nome from … where id = t.SETOR_ID) SETOR_ID`.
222
+
223
+ Such columns display a STRING (the looked-up name), so the numeric-ID
224
+ metadata BAU holds for the underlying column (center align, 000000 mask)
225
+ must NOT be applied. Parsing is paren-aware so subquery commas/FROMs don't
226
+ break the split. Best-effort: on any oddity the column is simply omitted
227
+ (treated as non-derived), never raising."""
228
+ out: set[str] = set()
229
+ try:
230
+ for m in re.finditer(r"sqlQuery\s*:\s*```(?:sql)?\n(.*?)```",
231
+ source, re.DOTALL | re.IGNORECASE):
232
+ sql = m.group(1)
233
+ # Isolate the top-level select list: after the first `select`,
234
+ # up to the matching top-level `from` (depth 0).
235
+ sm = re.search(r"\bselect\b", sql, re.IGNORECASE)
236
+ if not sm:
237
+ continue
238
+ i, depth, start = sm.end(), 0, sm.end()
239
+ list_end = None
240
+ while i < len(sql):
241
+ ch = sql[i]
242
+ if ch == "(":
243
+ depth += 1
244
+ elif ch == ")":
245
+ depth -= 1
246
+ elif depth == 0 and re.match(r"\bfrom\b", sql[i:], re.IGNORECASE):
247
+ list_end = i
248
+ break
249
+ i += 1
250
+ select_list = sql[start:list_end if list_end is not None else len(sql)]
251
+ # Paren-aware split on top-level commas.
252
+ items, depth, buf = [], 0, []
253
+ for ch in select_list:
254
+ if ch == "(":
255
+ depth += 1
256
+ elif ch == ")":
257
+ depth -= 1
258
+ if ch == "," and depth == 0:
259
+ items.append("".join(buf)); buf = []
260
+ else:
261
+ buf.append(ch)
262
+ if buf:
263
+ items.append("".join(buf))
264
+ for raw in items:
265
+ item = raw.strip()
266
+ if not item:
267
+ continue
268
+ am = _SELECT_ALIAS.match(item)
269
+ if am:
270
+ expr, alias = am.group(1).strip(), am.group(2)
271
+ else:
272
+ expr, alias = item, item.split(".")[-1].strip('"')
273
+ if not _BARE_COLREF.match(expr):
274
+ out.add(alias.upper())
275
+ except Exception:
276
+ return out
277
+ return out
278
+
279
+
280
+ def bau_columns_differ(a: "BauColumn", b: "BauColumn") -> bool:
281
+ """True when two same-named columns (from different tables) would drive
282
+ different page edits — the signal for the cross-table collision warning
283
+ in the CLI. Identity fields (name, is_pk, is_fk, type) are ignored."""
284
+ keys = (
285
+ "label", "description", "format_mask", "align_report", "align_form",
286
+ "text_case", "hidden", "read_only",
287
+ "report_display_order", "form_display_order",
288
+ )
289
+ return any(getattr(a, k) != getattr(b, k) for k in keys)
290
+
291
+
292
+ # ─────────────────────────────────────────────────────────────────────────────
293
+ # SQLcl `apex import` driver
294
+ # ─────────────────────────────────────────────────────────────────────────────
295
+ DEFAULT_SQLCL = (
296
+ "$HOME/.vscode/extensions/oracle.sql-developer-26.1.2-darwin-arm64/"
297
+ "dbtools/sqlcl/bin/sql"
298
+ )
299
+
300
+
301
+ @dataclass
302
+ class ImportResult:
303
+ returncode: int
304
+ stdout: str
305
+ stderr: str
306
+
307
+ @property
308
+ def ok(self) -> bool:
309
+ # SQLcl exits 0 on success. Errors that don't bubble to exit-code
310
+ # (like ORA-* inside the script, or APEXLang-level "workspace is
311
+ # invalid" diagnostics) need string-matching the output.
312
+ if self.returncode != 0:
313
+ return False
314
+ haystack = (self.stdout or "") + "\n" + (self.stderr or "")
315
+ # The import's own verdict line is authoritative. SQLcl 26.1 prints
316
+ # APEXLang compile WARNINGS (e.g. "Invalid template option value")
317
+ # for unrelated pages and still imports fine — token-scanning alone
318
+ # would flag those as failures.
319
+ success_markers = ("importação bem-sucedida", "import successful")
320
+ if any(s in haystack.lower() for s in success_markers):
321
+ return True
322
+ bad_tokens = (
323
+ "ORA-", "PLS-", "SP2-",
324
+ "SQL Error",
325
+ "inválido", "invalid", # PT/EN: invalid argument / workspace
326
+ "Falha", "failure",
327
+ "Erro de", "Error:",
328
+ "Token inesperado", # SQLcl parse error
329
+ )
330
+ return not any(t.lower() in haystack.lower() for t in bad_tokens)
331
+
332
+
333
+ def run_apex_import(
334
+ sqlcl_path: str,
335
+ connection: str,
336
+ workspace: str,
337
+ input_path: Path,
338
+ deployment: Path | None = None,
339
+ ) -> ImportResult:
340
+ """Invoke `sql apex import -workspace <ws> -input <app_root>` against a
341
+ named SQLcl connection. Returns the captured stdout/stderr.
342
+
343
+ `input_path` must be the APP ROOT of a split export — the directory
344
+ holding `application.apx`, `pages/` and `shared-components/`. SQLcl's
345
+ `-input` only accepts a single FILE when that file contains the WHOLE
346
+ application; importing one page's .apx standalone fails with
347
+ REFERENCE_NOT_FOUND on theme references such as `@/text`, because the
348
+ shared components the page points at aren't in the input.
349
+
350
+ `deployment` optionally points at a deployments/*.json manifest (sets
351
+ the target application id and runtime options).
352
+
353
+ Connection MUST already exist in the user's SQLcl connection manager
354
+ (the same `connect -name <NAME>` pattern used elsewhere in the project).
355
+ SQLcl 26.1+ is required for the `apex import` subcommand — older
356
+ standalone SQLcl installations don't ship it.
357
+ """
358
+ sql = os.path.expandvars(sqlcl_path)
359
+ if not Path(sql).is_file():
360
+ return ImportResult(
361
+ returncode=127, stdout="",
362
+ stderr=f"SQLcl not found at {sql}. "
363
+ f"Override with --sqlcl <path>.",
364
+ )
365
+
366
+ dep = f" -deployment {deployment}" if deployment else ""
367
+ script = (
368
+ f"set echo off\n"
369
+ f"set feedback on\n"
370
+ f"connect -name {connection}\n"
371
+ f"apex import -workspace {workspace} -input {input_path}{dep}\n"
372
+ f"exit\n"
373
+ )
374
+ proc = subprocess.run(
375
+ [sql, "/nolog"],
376
+ input=script, capture_output=True, text=True, timeout=300,
377
+ )
378
+ # SQLcl exits 0 even when `connect -name` fails (Unknown connection /
379
+ # SP2-0640: not connected) — the `apex import` then runs against no
380
+ # session and is a silent no-op. Detect that and surface a real failure
381
+ # so callers don't report "returned code 0" as success.
382
+ combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).lower()
383
+ conn_markers = (
384
+ "unknown connection", "sp2-0640", "not connected", "não conectado",
385
+ "nao conectado",
386
+ )
387
+ rc = proc.returncode
388
+ if rc == 0 and any(m in combined for m in conn_markers):
389
+ rc = 2
390
+ extra = (f"\n[baucli] SQLcl could not open named connection "
391
+ f"'{connection}'. Check bau.toml [connection].name (or "
392
+ f"--connection) against your SQLcl connections "
393
+ f"(`sql -name {connection}` / connmgr list).")
394
+ return ImportResult(returncode=rc,
395
+ stdout=proc.stdout,
396
+ stderr=(proc.stderr or "") + extra)
397
+ return ImportResult(
398
+ returncode=rc,
399
+ stdout=proc.stdout,
400
+ stderr=proc.stderr,
401
+ )
402
+
403
+
404
+ # ─────────────────────────────────────────────────────────────────────────────
405
+ # File discovery
406
+ # ─────────────────────────────────────────────────────────────────────────────
407
+ def _resolve_root_and_appdir(start: Path, app_dir: str) -> tuple[Path | None, str]:
408
+ """Walk up from `start` looking for the first ancestor that contains
409
+ `apex/<app_dir>/pages/`. Returns `(ancestor, effective_app_dir)` or
410
+ `(None, app_dir)` if not found.
411
+
412
+ This lets the user run from any subdirectory of the project — instead
413
+ of failing because cwd happened to be `demo_crm/teste/` while the app
414
+ lives in `demo_crm/apex/...`.
415
+
416
+ Two UX tolerances on `app_dir`:
417
+ * a leading `apex/` is stripped (`--app-dir apex/app_104` works);
418
+ * when `apex/<app_dir>` exists but holds the pages one level deeper
419
+ in a SINGLE subdirectory (SQLcl split exports nest the app under
420
+ its alias, e.g. `app_104/demo-crm/pages`), that child is
421
+ auto-selected.
422
+ """
423
+ app_dir = app_dir.strip("/")
424
+ if app_dir.startswith("apex/"):
425
+ app_dir = app_dir[5:]
426
+ for d in [start, *start.parents]:
427
+ base = d / "apex" / app_dir
428
+ if (base / "pages").is_dir():
429
+ return d, app_dir
430
+ if base.is_dir():
431
+ kids = [k for k in sorted(base.iterdir())
432
+ if k.is_dir() and (k / "pages").is_dir()]
433
+ if len(kids) == 1:
434
+ return d, f"{app_dir}/{kids[0].name}"
435
+ return None, app_dir
436
+
437
+
438
+ def _resolve_project_root(start: Path, app_dir: str) -> Path | None:
439
+ """Backward-compatible wrapper over `_resolve_root_and_appdir`."""
440
+ root, _ = _resolve_root_and_appdir(start, app_dir)
441
+ return root
442
+
443
+
444
+ def find_pages_referencing(
445
+ project_root: Path, app_dir: str, tables: Iterable[str] = ()
446
+ ) -> tuple[Path, list[Path]]:
447
+ """Find every .apx in `<project>/apex/<app_dir>/pages/` whose source
448
+ references any of the given tables (as detected by `discover_tables`).
449
+
450
+ Used by the directory-wide rebuild modes:
451
+ * `--table FOO` (no --page) sweeps every page where FOO appears in a
452
+ `tableName:` line or a `FROM/JOIN` clause;
453
+ * NO --table and NO --page sweeps EVERY page in the app — pass an
454
+ empty `tables` and all .apx files are returned (pages whose tables
455
+ aren't in BAU are skipped later by the rebuild loop).
456
+
457
+ Returns `(resolved_root, sorted_list_of_apx_paths)`.
458
+ """
459
+ resolved, app_dir = _resolve_root_and_appdir(project_root, app_dir)
460
+ if resolved is None:
461
+ chain = [str(d / "apex" / app_dir / "pages") for d in
462
+ [project_root, *project_root.parents][:5]]
463
+ raise FileNotFoundError(
464
+ f"App directory '{app_dir}' has no pages folder under cwd or any "
465
+ f"of its parents. Tried (closest first):\n - "
466
+ + "\n - ".join(chain)
467
+ )
468
+ targets = {t.upper() for t in tables}
469
+ pages_dir = resolved / "apex" / app_dir / "pages"
470
+ matches: list[Path] = []
471
+ for f in sorted(pages_dir.glob("*.apx")):
472
+ if not targets:
473
+ matches.append(f)
474
+ continue
475
+ try:
476
+ txt = f.read_text(encoding="utf-8")
477
+ except OSError:
478
+ continue
479
+ if targets & set(discover_tables(txt)):
480
+ matches.append(f)
481
+ return resolved, matches
482
+
483
+
484
+ def find_apex_file(
485
+ project_root: Path, app_dir: str, page_id: int
486
+ ) -> tuple[Path, Path]:
487
+ """Locate the .apx file for one page inside an app directory.
488
+
489
+ The convention is `<project-root>/apex/<app_dir>/pages/p<padded>-*.apx`.
490
+ `project_root` is treated as a *hint*: if `apex/<app_dir>/pages/` isn't
491
+ directly under it, we walk up looking for the closest ancestor that has
492
+ it. Returns `(resolved_root, apex_file_path)` so the caller can render
493
+ paths relative to the actually-used root.
494
+ """
495
+ padded = f"p{page_id:05d}-*.apx"
496
+ resolved, app_dir = _resolve_root_and_appdir(project_root, app_dir)
497
+ if resolved is None:
498
+ # Show the chain we walked so the user can see where we looked.
499
+ chain = [str(d / "apex" / app_dir / "pages") for d in
500
+ [project_root, *project_root.parents][:5]]
501
+ raise FileNotFoundError(
502
+ f"App directory '{app_dir}' has no pages folder under cwd or any "
503
+ f"of its parents. Tried (closest first):\n - "
504
+ + "\n - ".join(chain)
505
+ + f"\nHint: cd to your project root or pass --project-root "
506
+ f"<path-containing-apex/>."
507
+ )
508
+ pages = resolved / "apex" / app_dir / "pages"
509
+ candidates = list(pages.glob(padded))
510
+ if not candidates:
511
+ raise FileNotFoundError(
512
+ f"No .apx for page {page_id} in {pages.relative_to(resolved)} "
513
+ f"(looked for {padded})."
514
+ )
515
+ if len(candidates) > 1:
516
+ listing = "\n - ".join(str(p.relative_to(resolved)) for p in candidates)
517
+ raise FileNotFoundError(
518
+ f"Multiple .apx files match page {page_id}:\n - {listing}"
519
+ )
520
+ return resolved, candidates[0]
521
+
522
+
523
+ # ─────────────────────────────────────────────────────────────────────────────
524
+ # Block extraction — finds `pageItem NAME (`, `column NAME (`, etc.
525
+ # ─────────────────────────────────────────────────────────────────────────────
526
+ _BLOCK_RE = re.compile(
527
+ r"""
528
+ ^([ \t]*) # leading indent
529
+ (pageItem|column|displayColumn) # block type
530
+ [ \t]+
531
+ ([A-Za-z_][A-Za-z0-9_]*) # block name
532
+ [ \t]*\( # opening paren
533
+ """,
534
+ re.VERBOSE | re.MULTILINE,
535
+ )
536
+
537
+
538
+ @dataclass
539
+ class Block:
540
+ kind: str # 'pageItem' | 'column' | 'displayColumn'
541
+ name: str
542
+ indent: str
543
+ start: int # offset of the opening `(` line in the source
544
+ end: int # offset just past the matching `)`
545
+ text: str # the full block text (from indent to closing `)`)
546
+
547
+ @property
548
+ def bau_key(self) -> str:
549
+ """Column name to look up in BAU metadata.
550
+
551
+ pageItem names usually follow P<page>_<COLUMN_NAME>; strip the
552
+ prefix when present so e.g. `P13_DOMAIN_CODE` matches BAU column
553
+ `DOMAIN_CODE`.
554
+ """
555
+ n = self.name.upper()
556
+ m = re.match(r"^P\d+_(.+)$", n)
557
+ return (m.group(1) if m else n)
558
+
559
+
560
+ def find_matching_paren(text: str, open_idx: int) -> int:
561
+ """Return offset just past the `)` that matches the `(` at `open_idx`.
562
+
563
+ Walks the buffer char by char, tracks paren depth, and skips over double-
564
+ quoted string literals and ```-fenced multi-line strings (used by
565
+ APEXLang for embedded SQL). Comments aren't expected inside block bodies.
566
+ """
567
+ assert text[open_idx] == "(", f"expected '(' at {open_idx}"
568
+ depth = 1
569
+ i = open_idx + 1
570
+ n = len(text)
571
+ while i < n:
572
+ c = text[i]
573
+ # SQL/MD-style triple-backtick block — skip until closing fence.
574
+ if c == "`" and text[i : i + 3] == "```":
575
+ end = text.find("```", i + 3)
576
+ i = (end + 3) if end != -1 else n
577
+ continue
578
+ # Double-quoted string literal — APEXLang doesn't typically use these,
579
+ # but a defensive skip protects against false-positive parens inside.
580
+ if c == '"':
581
+ i = _skip_quoted(text, i, '"') + 1
582
+ continue
583
+ if c == "(":
584
+ depth += 1
585
+ elif c == ")":
586
+ depth -= 1
587
+ if depth == 0:
588
+ return i + 1
589
+ i += 1
590
+ raise ValueError(f"unbalanced paren starting at offset {open_idx}")
591
+
592
+
593
+ def _skip_quoted(text: str, start: int, quote: str) -> int:
594
+ """Return the offset of the closing quote (matching `quote`), supporting
595
+ backslash escapes. If unterminated, returns len(text)-1."""
596
+ i = start + 1
597
+ n = len(text)
598
+ while i < n:
599
+ if text[i] == "\\" and i + 1 < n:
600
+ i += 2
601
+ continue
602
+ if text[i] == quote:
603
+ return i
604
+ i += 1
605
+ return n - 1
606
+
607
+
608
+ def iter_blocks(source: str, kinds: Iterable[str] = ("pageItem", "column",
609
+ "displayColumn")
610
+ ) -> list[Block]:
611
+ """Return every `kind NAME ( ... )` block in source, in file order."""
612
+ kinds = set(kinds)
613
+ out: list[Block] = []
614
+ for m in _BLOCK_RE.finditer(source):
615
+ if m.group(2) not in kinds:
616
+ continue
617
+ open_paren = source.find("(", m.end() - 1)
618
+ if open_paren == -1:
619
+ continue
620
+ try:
621
+ end = find_matching_paren(source, open_paren)
622
+ except ValueError:
623
+ continue
624
+ out.append(Block(
625
+ kind=m.group(2),
626
+ name=m.group(3),
627
+ indent=m.group(1),
628
+ start=m.start(),
629
+ end=end,
630
+ text=source[m.start():end],
631
+ ))
632
+ return out
633
+
634
+
635
+ # ─────────────────────────────────────────────────────────────────────────────
636
+ # Block-level edits — operate on the block text only, return the new text.
637
+ # Each editor is idempotent: running twice gives the same result.
638
+ # ─────────────────────────────────────────────────────────────────────────────
639
+ _KV_RE = lambda key: re.compile(
640
+ rf"^([ \t]*){re.escape(key)}\s*:\s*(.*?)$", re.MULTILINE
641
+ )
642
+
643
+
644
+ def _insert_before_close_line(block: str, insertion: str) -> str:
645
+ """Insert `insertion` (already newline-terminated) on its own line(s)
646
+ immediately before the line that contains the block's closing `)`.
647
+
648
+ Splicing at the bare `block.rfind(")")` would land AFTER the closing
649
+ paren's own indent, doubling the leading whitespace of every line in
650
+ the insertion. Walking back to the start of that line first puts the
651
+ new content on its own lines, with the `)` line untouched.
652
+ """
653
+ close = block.rfind(")")
654
+ line_start = block.rfind("\n", 0, close) + 1 # 0 if no newline (shouldn't happen)
655
+ return block[:line_start] + insertion + block[line_start:]
656
+
657
+
658
+ # For each block the engine may insert, which sibling blocks MUST come
659
+ # after it according to APEX MMD 26's parser. Insertion is placed
660
+ # immediately before the first matching successor found in the column
661
+ # body. When no successor is found, falls back to inserting just before
662
+ # the closing `)` of the parent.
663
+ #
664
+ # Modeling chose this map-of-successors over a single global ordering
665
+ # because empirically the 2,186 column blocks in the project's sample
666
+ # apps disagree on edge cases (e.g. some have `settings → layout`,
667
+ # others have `layout → settings`). What the parser cares about is the
668
+ # specific pairings that violate the schema — and the only one we keep
669
+ # bumping into is `appearance` after `source`. The successor lists below
670
+ # encode every such constraint relevant to the blocks we insert,
671
+ # avoiding false confidence in a globally-unique canonical order.
672
+ INSERT_SUCCESSORS: dict[str, tuple[str, ...]] = {
673
+ "heading": ("layout", "lov", "settings", "validation",
674
+ "appearance", "link", "source", "advanced", "security"),
675
+ "layout": ("lov", "settings", "validation",
676
+ "appearance", "link", "source", "advanced", "security"),
677
+ "appearance": ("source", "advanced", "security",
678
+ "exportPrinting", "sorting", "columnFilter"),
679
+ # `settings` precedes layout/source/etc. in EVERY sampled export
680
+ # (409 settings→layout pairs, zero reversed).
681
+ "settings": ("layout", "lov", "validation", "appearance", "link",
682
+ "source", "advanced", "security", "sessionState",
683
+ "default", "columnFilter", "sorting", "exportPrinting"),
684
+ # pageItem order in the samples: label → lov → layout → ...
685
+ "lov": ("layout", "validation", "appearance", "link", "source",
686
+ "advanced", "security", "sessionState", "default"),
687
+ # `default` is a late block (after source/appearance) but before
688
+ # security/advanced/help — place it ahead of the first of those present.
689
+ "default": ("security", "advanced", "help"),
690
+ # `help` is the LAST nested block in the samples (259 ✕→help pairs
691
+ # dominate) — the empty successor list makes the fallback ("insert
692
+ # before the closing paren") land it exactly there.
693
+ "help": (),
694
+ }
695
+
696
+
697
+ # Server-side / expression defaults that must NOT become a form item default
698
+ # — they are applied by the DB on INSERT, not chosen in the UI. The safe
699
+ # filter accepts ONLY a quoted string literal or a plain number; everything
700
+ # else (sequences, SYSDATE/SYSTIMESTAMP, SYS_GUID(), USER, expressions, bare
701
+ # identifiers) is rejected.
702
+ def _safe_default(raw: str | None) -> str | None:
703
+ """Return a UI-safe literal from a raw DB DATA_DEFAULT, or None when the
704
+ default is server-side / an expression. Strips the quotes of a string
705
+ literal ('S' → S) and passes numbers through (0, -1, 1.5)."""
706
+ if not raw:
707
+ return None
708
+ v = raw.strip()
709
+ if not v:
710
+ return None
711
+ m = re.match(r"^'(.*)'$", v, re.DOTALL) # quoted string literal
712
+ if m:
713
+ return m.group(1).replace("''", "'") or None
714
+ if re.match(r"^-?\d+(\.\d+)?$", v): # numeric literal
715
+ return v
716
+ return None # anything else is unsafe
717
+
718
+
719
+ def _insert_block_in_order(block: str, new_block_name: str,
720
+ content: str) -> str:
721
+ """Insert `content` immediately before the first sibling listed in
722
+ INSERT_SUCCESSORS[new_block_name] that exists in `block`. Falls back
723
+ to inserting before the closing `)` when no successor is found —
724
+ that's safe because the parser only rejects out-of-order placement,
725
+ and putting a new block last when no other constraints exist matches
726
+ what the samples do."""
727
+ successors = INSERT_SUCCESSORS.get(new_block_name, ())
728
+ for later in successors:
729
+ m = re.search(
730
+ rf"^([ \t]*){re.escape(later)}\s*\{{",
731
+ block, re.MULTILINE,
732
+ )
733
+ if m:
734
+ return block[:m.start()] + content + block[m.start():]
735
+ return _insert_before_close_line(block, content)
736
+
737
+
738
+ def _set_or_add_kv(block: str, key: str, value: str, indent: str) -> str:
739
+ """Set `key: value` inside block. If key already exists, replace its
740
+ value. Otherwise insert just before the closing `)`."""
741
+ if value is None:
742
+ return block
743
+ pat = _KV_RE(key)
744
+ if pat.search(block):
745
+ return pat.sub(lambda m: f"{m.group(1)}{key}: {value}", block, count=1)
746
+ return _insert_before_close_line(block, f"{indent}{key}: {value}\n")
747
+
748
+
749
+ def _top_kv_match(block: str, key: str, inner_indent: str):
750
+ """Match `key: value` only at the block's OWN indentation level. A bare
751
+ multiline search for e.g. `type:` also hits the nested one inside
752
+ `source { type: sqlQuery }` — depth filtering by indent avoids editing
753
+ the wrong property."""
754
+ for m in _KV_RE(key).finditer(block):
755
+ if m.group(1) == inner_indent:
756
+ return m
757
+ return None
758
+
759
+
760
+ def _set_top_kv(block: str, key: str, value: str, inner_indent: str) -> str:
761
+ """Set a block-level scalar (`type: hidden` etc.) at depth 1 only.
762
+ When absent, insert as the FIRST line of the block — scalar properties
763
+ like `type:` lead every sampled pageItem/column block, and the parser
764
+ can reject them after nested blocks."""
765
+ m = _top_kv_match(block, key, inner_indent)
766
+ if m:
767
+ return block[:m.start()] + f"{m.group(1)}{key}: {value}" + block[m.end():]
768
+ open_idx = block.find("(")
769
+ line_end = block.find("\n", open_idx)
770
+ if line_end == -1:
771
+ return _insert_before_close_line(block, f"{inner_indent}{key}: {value}\n")
772
+ return (block[:line_end + 1]
773
+ + f"{inner_indent}{key}: {value}\n"
774
+ + block[line_end + 1:])
775
+
776
+
777
+ def _remove_nested_kv(block: str, parent: str, key: str) -> tuple[str, str | None]:
778
+ """Remove `key: ...` from inside a multi-line `parent { ... }` block.
779
+ Returns `(new_block, removed_value)`; `removed_value` is None when the
780
+ key wasn't present. When the removal leaves the parent empty, the whole
781
+ parent block is dropped — no sampled export carries an empty `{}`."""
782
+ multi_pat = re.compile(
783
+ rf"^([ \t]*){re.escape(parent)}\s*\{{(.*?)^\1\}}",
784
+ re.MULTILINE | re.DOTALL,
785
+ )
786
+ m = multi_pat.search(block)
787
+ if not m:
788
+ return block, None
789
+ inner = m.group(2)
790
+ kv = _KV_RE(key).search(inner)
791
+ if not kv:
792
+ return block, None
793
+ removed = kv.group(2).strip()
794
+ line_end = inner.find("\n", kv.end())
795
+ line_end = len(inner) if line_end == -1 else line_end + 1
796
+ new_inner = inner[:kv.start()] + inner[line_end:]
797
+ if not new_inner.strip():
798
+ end = m.end()
799
+ if block[end:end + 1] == "\n":
800
+ end += 1
801
+ return block[:m.start()] + block[end:], removed
802
+ rebuilt = f"{m.group(1)}{parent} {{{new_inner}{m.group(1)}}}"
803
+ return block[:m.start()] + rebuilt + block[m.end():], removed
804
+
805
+
806
+ def _set_or_add_nested_kv(block: str, parent: str, key: str, value: str,
807
+ indent: str, inner_indent: str) -> str:
808
+ """Set `parent { ... key: value ... }`. If parent block exists (either as
809
+ a multi-line block or a single-line `parent { … }`), update the key
810
+ inside it — expanding to multi-line form for predictable indentation.
811
+ If the parent block doesn't exist, insert a new multi-line block at
812
+ the canonical APEXLang position."""
813
+ if value is None:
814
+ return block
815
+
816
+ # Multi-line: parent { ... \n<indent>} — closing `}` on its own line.
817
+ multi_pat = re.compile(
818
+ rf"^([ \t]*){re.escape(parent)}\s*\{{(.*?)^\1\}}",
819
+ re.MULTILINE | re.DOTALL,
820
+ )
821
+ # Single-line: parent { ... } on one line, no nested braces inside.
822
+ inline_pat = re.compile(
823
+ rf"^([ \t]*){re.escape(parent)}\s*\{{\s*([^{{}}\n]*?)\s*\}}\s*$",
824
+ re.MULTILINE,
825
+ )
826
+
827
+ m = multi_pat.search(block)
828
+ is_inline = False
829
+ if not m:
830
+ m = inline_pat.search(block)
831
+ is_inline = m is not None
832
+
833
+ if m:
834
+ block_indent = m.group(1)
835
+ inner = m.group(2)
836
+ kv = _KV_RE(key)
837
+ if kv.search(inner):
838
+ new_inner = kv.sub(
839
+ lambda mm: f"{mm.group(1)}{key}: {value}", inner, count=1
840
+ )
841
+ else:
842
+ # Append the new key, preserving the existing entry indent when
843
+ # the block is multi-line. For inline blocks, fall back to
844
+ # `inner_indent` since the inner had no indentation at all.
845
+ existing = re.search(r"^([ \t]+)\S", inner, re.MULTILINE)
846
+ child_indent = existing.group(1) if existing else inner_indent
847
+ new_inner = inner.rstrip("\n") + f"\n{child_indent}{key}: {value}\n"
848
+
849
+ if is_inline:
850
+ # Expand inline → multi-line for consistent shape. The inner
851
+ # captured from the single-line regex has no leading newline /
852
+ # indentation, so rebuild it with proper indents.
853
+ inner_lines = [
854
+ ln for ln in new_inner.strip().splitlines() if ln.strip()
855
+ ]
856
+ rebuilt = "\n".join(
857
+ f"{inner_indent}{ln.strip()}" for ln in inner_lines
858
+ )
859
+ replacement = (
860
+ f"{block_indent}{parent} {{\n{rebuilt}\n{block_indent}}}"
861
+ )
862
+ else:
863
+ replacement = (
864
+ f"{block_indent}{parent} {{{new_inner}{block_indent}}}"
865
+ )
866
+ return block[:m.start()] + replacement + block[m.end():]
867
+
868
+ # parent block doesn't exist — insert at the canonical APEXLang
869
+ # ordering position so the parser doesn't reject it (e.g. `appearance`
870
+ # must come before `source` in APEX 26).
871
+ insertion = (
872
+ f"{indent}{parent} {{\n"
873
+ f"{inner_indent}{key}: {value}\n"
874
+ f"{indent}}}\n"
875
+ )
876
+ return _insert_block_in_order(block, parent, insertion)
877
+
878
+
879
+ # ─────────────────────────────────────────────────────────────────────────────
880
+ # Top-level rebuild — given source text + dict of BauColumn, return new source
881
+ # plus a list of (column_name, what_changed) tuples.
882
+ # ─────────────────────────────────────────────────────────────────────────────
883
+ @dataclass
884
+ class Change:
885
+ block: Block
886
+ field: str
887
+ old: str | None
888
+ new: str | None
889
+
890
+ def render(self) -> str:
891
+ # Escape `[…]` for Rich — otherwise `[pageItem …]` is consumed as
892
+ # markup and disappears from the terminal output. Long values
893
+ # (helpText carries full column descriptions) are truncated.
894
+ def _t(v):
895
+ return (v[:57] + "…") if isinstance(v, str) and len(v) > 58 else v
896
+ return (f" \\[{self.block.kind} {self.block.name}] {self.field}: "
897
+ f"{_t(self.old)!r} → {_t(self.new)!r}")
898
+
899
+
900
+ @dataclass
901
+ class RebuildResult:
902
+ new_source: str
903
+ changes: list[Change] = field(default_factory=list)
904
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (block name, reason)
905
+
906
+
907
+ def _quote_apexlang(s: str) -> str:
908
+ """APEXLang values are bare tokens or quoted with single/double quotes
909
+ depending on content. For labels we use the raw form (no surrounding
910
+ quotes) because that's what existing files in this repo do."""
911
+ # Strip leading/trailing whitespace; preserve PT-BR accents as-is.
912
+ return s.strip() if s else ""
913
+
914
+
915
+ def rebuild_apex_source(source: str, columns: dict[str, BauColumn],
916
+ rows_per_page: int | None = None) -> RebuildResult:
917
+ """Apply BAU column metadata to every recognized block in `source`.
918
+
919
+ When `rows_per_page` is given, every savedReport `view { rowsPerPage: N }`
920
+ on the page is set to it (the BAU_CONFIG DOCTAB.REPORT_ROWS_PER_PAGE value,
921
+ resolved by GET_COLUMNS per schema)."""
922
+ blocks = iter_blocks(source, kinds=("pageItem", "column", "displayColumn"))
923
+ # Apply from the END of the file backwards so offsets in earlier blocks
924
+ # remain valid as we splice text in.
925
+ changes: list[Change] = []
926
+ skipped: list[tuple[str, str]] = []
927
+ buf = source
928
+ # Columns whose IR sqlQuery projects a DERIVED value (FK shown as a name
929
+ # via scalar subquery, etc.) — they display a string, so numeric-ID
930
+ # metadata (center align / 000000 mask) must not be applied to them.
931
+ derived_cols = derived_display_cols(source)
932
+ blocks_sorted = sorted(blocks, key=lambda b: -b.start)
933
+
934
+ for blk in blocks_sorted:
935
+ col = columns.get(blk.bau_key)
936
+ if not col and blk.bau_key.endswith("_LABEL"):
937
+ # Domain columns are surfaced in IRs/IGs as a friendly-label
938
+ # alias (e.g. `ATIVO_LABEL` → FNC_BAU_DOMAIN_LABEL(...)). The
939
+ # alias is not a BAU column, so fall back to the base column so
940
+ # heading / format / align and especially REPORT_DISPLAY_ORDER
941
+ # (column ordering) still apply. Mirrors the conformity check's
942
+ # `_LABEL` normalization. Exact match wins first, so a real
943
+ # column literally named `*_LABEL` is never shadowed.
944
+ col = columns.get(blk.bau_key[:-len("_LABEL")])
945
+ if not col:
946
+ skipped.append((blk.name, "no matching BAU column"))
947
+ continue
948
+
949
+ # The block's text in `buf` may have shifted if we already edited
950
+ # something *after* it. We're walking back-to-front so all later
951
+ # edits are already applied to buf; we re-extract this block's text
952
+ # from buf using its still-valid offsets (offsets earlier in file
953
+ # are unaffected by later splices).
954
+ block_text = buf[blk.start:blk.end]
955
+ new_block = block_text
956
+ # Inner indent: best guess from first non-blank inner line.
957
+ first_inner = re.search(r"^([ \t]+)\S", block_text[block_text.find("(") + 1:],
958
+ re.MULTILINE)
959
+ inner_indent = (blk.indent + " ") if not first_inner else first_inner.group(1)
960
+ deep_indent = inner_indent + " "
961
+
962
+ # ── label / heading / mask / align / hidden — most edits are
963
+ # skipped for `displayColumn` because that block is just a "use
964
+ # this column at this slot" pointer inside savedReport. Heading/
965
+ # mask/etc. ARE legal there (as overrides) but pages rarely set
966
+ # them by hand — forcing the BAU defaults in would clutter every
967
+ # saved report. We touch only `sequence` for displayColumn.
968
+
969
+ # Detect the block's own `type:` — depth-filtered, otherwise the
970
+ # nested `source { type: sqlQuery }` is matched instead.
971
+ type_match = _top_kv_match(new_block, "type", inner_indent)
972
+ block_type = type_match.group(2).strip() if type_match else None
973
+ block_is_hidden = (block_type == "hidden")
974
+ # APEX 26's parser rejects display-related properties on hidden
975
+ # columns/items (columnAlignment, formatMask, textCase, helpText —
976
+ # zero occurrences inside `type: hidden` blocks across all sampled
977
+ # exports). A block that BAU marks HIDDEN is flipped further below,
978
+ # so treat it as hidden ALREADY: skip adding those properties and
979
+ # strip any it carries.
980
+ effective_hidden = block_is_hidden or (
981
+ col.hidden and blk.kind != "displayColumn"
982
+ )
983
+
984
+ if blk.kind != "displayColumn" and effective_hidden:
985
+ for parent, key in (("appearance", "formatMask"),
986
+ ("layout", "columnAlignment"),
987
+ ("layout", "alignment")):
988
+ stripped, removed = _remove_nested_kv(new_block, parent, key)
989
+ if removed is not None:
990
+ new_block = stripped
991
+ changes.append(Change(blk, key, removed, None))
992
+ # `label` (scalar OR block form) is INVALID_PROPERTY on hidden
993
+ # pageItems (heading on hidden columns is fine — 190 occurrences
994
+ # in the samples; label inside hidden pageItems: zero).
995
+ if blk.kind == "pageItem":
996
+ lm = _top_kv_match(new_block, "label", inner_indent)
997
+ if lm:
998
+ line_end = new_block.find("\n", lm.end())
999
+ line_end = (len(new_block) if line_end == -1
1000
+ else line_end + 1)
1001
+ changes.append(
1002
+ Change(blk, "label", lm.group(2).strip(), None))
1003
+ new_block = new_block[:lm.start()] + new_block[line_end:]
1004
+ lb = re.search(r"^([ \t]*)label\s*\{.*?^\1\}\n?",
1005
+ new_block, re.MULTILINE | re.DOTALL)
1006
+ if lb:
1007
+ changes.append(Change(blk, "label", "{…}", None))
1008
+ new_block = new_block[:lb.start()] + new_block[lb.end():]
1009
+
1010
+ # ── form item type (pageItem only) ─────────────────────────────
1011
+ # BAU.FORM_ITEM_TYPE is the exact APEXLang token (checkbox, switch,
1012
+ # selectList, ...). Precedence: HIDDEN / READ_ONLY win; a page-side
1013
+ # `type: hidden` is also left alone (the page hides items for its
1014
+ # own reasons, e.g. dashboard filters).
1015
+ if (blk.kind == "pageItem" and col.form_item_type
1016
+ and not effective_hidden and not col.read_only
1017
+ and block_type not in ("hidden", col.form_item_type)):
1018
+ new_type = col.form_item_type
1019
+ has_lov = re.search(r"^[ \t]*lov\s*\{", new_block, re.MULTILINE)
1020
+ if new_type in LOV_ITEM_TYPES and not has_lov and not col.domain_values:
1021
+ # FK lookups etc. — can't synthesize an LOV query.
1022
+ skipped.append((blk.name,
1023
+ f"form_item_type {new_type} needs an lov "
1024
+ f"block (no BAU domain values to synthesize "
1025
+ f"one — FK lookup?)"))
1026
+ else:
1027
+ if new_type in LOV_ITEM_TYPES and not has_lov:
1028
+ # Synthesize a static LOV from the BAU-documented
1029
+ # CHECK-constraint domain (display;return pairs in the
1030
+ # STATIC2 grammar). `,` and `;` are structural in that
1031
+ # grammar — sanitized out of labels/values.
1032
+ def _san(s: str) -> str:
1033
+ return ((s or "").replace(",", " ")
1034
+ .replace(";", " ").strip())
1035
+ pairs = ",".join(f"{_san(lbl)};{_san(val)}"
1036
+ for val, lbl in col.domain_values)
1037
+ # Closed CHECK domain → NULL is never a valid member, so
1038
+ # the synthesized LOV must NOT offer a null option
1039
+ # (`displayNullValue: false`); otherwise APEX renders an
1040
+ # empty radio/entry that can't be saved on a NOT NULL flag.
1041
+ lov_block = (
1042
+ f"{inner_indent}lov {{\n"
1043
+ f"{deep_indent}type: staticValues\n"
1044
+ f"{deep_indent}staticValues: STATIC2:{pairs}\n"
1045
+ f"{deep_indent}displayExtraValues: false\n"
1046
+ f"{deep_indent}displayNullValue: false\n"
1047
+ f"{inner_indent}}}\n"
1048
+ )
1049
+ new_block = _insert_block_in_order(new_block, "lov",
1050
+ lov_block)
1051
+ changes.append(
1052
+ Change(blk, "lov", None, f"STATIC2:{pairs}"))
1053
+ has_lov = True
1054
+ # type-specific child blocks don't survive a retype: a
1055
+ # leftover lov on a checkbox (or textField settings on a
1056
+ # selectList) is INVALID_PROPERTY at import.
1057
+ if new_type not in LOV_ITEM_TYPES and has_lov:
1058
+ lov_m = re.search(r"^([ \t]*)lov\s*\{.*?^\1\}\n?",
1059
+ new_block, re.MULTILINE | re.DOTALL)
1060
+ if lov_m:
1061
+ changes.append(Change(blk, "lov", "{…}", None))
1062
+ new_block = (new_block[:lov_m.start()]
1063
+ + new_block[lov_m.end():])
1064
+ set_m = re.search(r"^([ \t]*)settings\s*\{.*?^\1\}\n?",
1065
+ new_block, re.MULTILINE | re.DOTALL)
1066
+ if set_m:
1067
+ changes.append(Change(blk, "settings", "{…}", None))
1068
+ new_block = (new_block[:set_m.start()]
1069
+ + new_block[set_m.end():])
1070
+ new_block = _set_top_kv(new_block, "type", new_type,
1071
+ inner_indent)
1072
+ changes.append(Change(blk, "type", block_type, new_type))
1073
+ block_type = new_type
1074
+
1075
+ # Single checkbox numa coluna com domínio: o strip de settings
1076
+ # acima removeu os valores antigos e checkbox não usa lov —
1077
+ # sem checkedValue/uncheckedValue o item envia valor inválido e
1078
+ # viola o CHECK da coluna (ex.: CK_..._ATIVO: ATIVO IN ('S','N')).
1079
+ # Sintetiza o settings a partir do domínio documentado.
1080
+ if new_type == "checkbox":
1081
+ if len(col.domain_values) == 2:
1082
+ _truthy = {"S", "Y", "1", "T", "SIM", "YES", "TRUE", "ATIVO"}
1083
+ _vals = [val for val, _lbl in col.domain_values]
1084
+ _checked = next((v for v in _vals
1085
+ if (v or "").upper() in _truthy), _vals[0])
1086
+ _unchecked = next((v for v in _vals if v != _checked),
1087
+ _vals[-1])
1088
+ settings_block = (
1089
+ f"{inner_indent}settings {{\n"
1090
+ f"{deep_indent}useDefaults: false\n"
1091
+ f"{deep_indent}checkedValue: {_checked}\n"
1092
+ f"{deep_indent}uncheckedValue: {_unchecked}\n"
1093
+ f"{inner_indent}}}\n"
1094
+ )
1095
+ new_block = _insert_block_in_order(
1096
+ new_block, "settings", settings_block)
1097
+ changes.append(Change(blk, "settings", None,
1098
+ f"checked={_checked}/unchecked={_unchecked}"))
1099
+ else:
1100
+ # domínio não-booleano (≠ 2 valores) não cabe num single
1101
+ # checkbox — deixa selectList/radioGroup tratar via lov.
1102
+ skipped.append((blk.name,
1103
+ f"checkbox requer domínio booleano (2 valores); "
1104
+ f"a coluna tem {len(col.domain_values)} — "
1105
+ f"considere FORM_ITEM_TYPE=selectList"))
1106
+
1107
+ # Self-heal stray `appearance { height: N }`: it is only valid on
1108
+ # multi-row types (textarea / editors). On any other pageItem it is
1109
+ # INVALID_PROPERTY at import and aborts the whole app import. Run
1110
+ # this regardless of whether a retype happened THIS run, so files a
1111
+ # previous rebuild left with e.g. a radioGroup + height:1 self-heal
1112
+ # on the next pass (the retype branch above won't re-fire once the
1113
+ # type already matches).
1114
+ if (blk.kind == "pageItem" and block_type
1115
+ and block_type not in HEIGHT_ITEM_TYPES):
1116
+ new_block, removed_h = _remove_nested_kv(
1117
+ new_block, "appearance", "height")
1118
+ if removed_h is not None:
1119
+ changes.append(Change(blk, "height", removed_h, None))
1120
+
1121
+ # Self-heal NULL option on closed-domain LOV items: a CHECK domain is
1122
+ # a closed set (NULL is never a member), so a selectList/radioGroup/etc.
1123
+ # over it must carry `lov { displayNullValue: false }`. Without it APEX
1124
+ # shows an empty entry that can't be saved on a NOT NULL flag. Runs
1125
+ # regardless of retype, so items a previous rebuild synthesized without
1126
+ # it (or hand-built static LOVs) self-heal on the next pass.
1127
+ if (blk.kind == "pageItem" and block_type in LOV_ITEM_TYPES
1128
+ and col.domain_values):
1129
+ has_static = re.search(
1130
+ r"^[ \t]*lov\s*\{[^}]*staticValues",
1131
+ new_block, re.MULTILINE | re.DOTALL)
1132
+ has_null_kv = re.search(
1133
+ r"^[ \t]*lov\s*\{.*?displayNullValue",
1134
+ new_block, re.MULTILINE | re.DOTALL)
1135
+ if has_static and not has_null_kv:
1136
+ new_block = _set_or_add_nested_kv(
1137
+ new_block, "lov", "displayNullValue", "false",
1138
+ inner_indent, deep_indent)
1139
+ changes.append(Change(blk, "displayNullValue", None, "false"))
1140
+
1141
+ # Default do item de formulário: form_default (regra/explícito, usado
1142
+ # como-está) > data_default do banco filtrado a literais. Só em itens
1143
+ # editáveis (não hidden/displayOnly/RO/PK — defaults ali não fazem
1144
+ # sentido ou são server-side). Idempotente: só escreve se mudou.
1145
+ if (blk.kind == "pageItem" and not effective_hidden
1146
+ and not col.read_only and not col.is_pk
1147
+ and block_type not in ("hidden", "displayOnly")):
1148
+ eff_default = ((col.form_default or "").strip() or None) \
1149
+ or _safe_default(col.data_default)
1150
+ if eff_default is not None:
1151
+ sv = (eff_default if re.match(r"^[A-Za-z0-9_.\-]+$", eff_default)
1152
+ else _quote_apexlang(eff_default))
1153
+ cur_default = None
1154
+ dm = re.search(r"^([ \t]*)default\s*\{.*?^\1\}",
1155
+ new_block, re.MULTILINE | re.DOTALL)
1156
+ if dm:
1157
+ sm = _KV_RE("staticValue").search(dm.group(0))
1158
+ cur_default = sm.group(2).strip() if sm else None
1159
+ if cur_default != sv:
1160
+ new_block = _set_or_add_nested_kv(
1161
+ new_block, "default", "type", "static",
1162
+ inner_indent, deep_indent)
1163
+ new_block = _set_or_add_nested_kv(
1164
+ new_block, "default", "staticValue", sv,
1165
+ inner_indent, deep_indent)
1166
+ changes.append(Change(blk, "default", cur_default, sv))
1167
+
1168
+ if blk.kind != "displayColumn" and col.label:
1169
+ new_label = _quote_apexlang(col.label)
1170
+ if blk.kind == "pageItem":
1171
+ if effective_hidden:
1172
+ pass # label is INVALID_PROPERTY on hidden pageItems
1173
+ else:
1174
+ # APEXLang has TWO label forms: the scalar `label: X`
1175
+ # and a `label { label: X; alignment: ... }` block
1176
+ # (used whenever the label carries extra attributes —
1177
+ # 665 alignment-in-label occurrences in the samples).
1178
+ # Prefer the block when present; both forms on the same
1179
+ # item is INVALID_PROPERTY, so a stray scalar next to a
1180
+ # block is removed (self-repair).
1181
+ lbl_blk_re = re.compile(
1182
+ r"^([ \t]*)label\s*\{(.*?)^\1\}",
1183
+ re.MULTILINE | re.DOTALL,
1184
+ )
1185
+ bm = lbl_blk_re.search(new_block)
1186
+ scalar_m = _top_kv_match(new_block, "label", inner_indent)
1187
+ if bm and scalar_m:
1188
+ line_end = new_block.find("\n", scalar_m.end())
1189
+ line_end = (len(new_block) if line_end == -1
1190
+ else line_end + 1)
1191
+ changes.append(Change(
1192
+ blk, "label",
1193
+ scalar_m.group(2).strip(), "(dup scalar removed)"))
1194
+ new_block = (new_block[:scalar_m.start()]
1195
+ + new_block[line_end:])
1196
+ bm = lbl_blk_re.search(new_block)
1197
+ scalar_m = None
1198
+ if bm:
1199
+ kv = _KV_RE("label").search(bm.group(2))
1200
+ old_label = kv.group(2).strip() if kv else None
1201
+ if old_label != new_label:
1202
+ new_block = _set_or_add_nested_kv(
1203
+ new_block, "label", "label", new_label,
1204
+ inner_indent, deep_indent,
1205
+ )
1206
+ changes.append(
1207
+ Change(blk, "label", old_label, new_label))
1208
+ else:
1209
+ old_label = (scalar_m.group(2).strip()
1210
+ if scalar_m else None)
1211
+ if old_label != new_label:
1212
+ if scalar_m:
1213
+ new_block = (
1214
+ new_block[:scalar_m.start()]
1215
+ + f"{inner_indent}label: {new_label}"
1216
+ + new_block[scalar_m.end():]
1217
+ )
1218
+ elif type_match:
1219
+ # samples order scalars `type:` then
1220
+ # `label:` — insert after the type line
1221
+ le = new_block.find("\n", type_match.end())
1222
+ at = len(new_block) if le == -1 else le + 1
1223
+ new_block = (
1224
+ new_block[:at]
1225
+ + f"{inner_indent}label: {new_label}\n"
1226
+ + new_block[at:]
1227
+ )
1228
+ else:
1229
+ new_block = _set_top_kv(
1230
+ new_block, "label", new_label,
1231
+ inner_indent)
1232
+ changes.append(
1233
+ Change(blk, "label", old_label, new_label))
1234
+ else: # column / displayColumn — heading is nested
1235
+ inner = re.search(
1236
+ r"^([ \t]*)heading\s*\{(.*?)^\1\}",
1237
+ new_block, re.MULTILINE | re.DOTALL,
1238
+ )
1239
+ old_heading_val = None
1240
+ if inner:
1241
+ kv = _KV_RE("heading").search(inner.group(2))
1242
+ if kv:
1243
+ old_heading_val = kv.group(2).strip()
1244
+ if old_heading_val != new_label:
1245
+ new_block = _set_or_add_nested_kv(
1246
+ new_block, "heading", "heading", new_label,
1247
+ inner_indent, deep_indent,
1248
+ )
1249
+ changes.append(Change(blk, "heading", old_heading_val, new_label))
1250
+
1251
+ # ── help text (pageItem only) ──────────────────────────────────
1252
+ # BAU.DESCRIPTION → `help { helpText: ... }`. The value is a free
1253
+ # single-line token in APEXLang, so multi-line descriptions are
1254
+ # collapsed. IR/IG `column` blocks have no help property.
1255
+ if (blk.kind == "pageItem" and col.description
1256
+ and not effective_hidden):
1257
+ help_text = " ".join(col.description.split())
1258
+ inner_help = re.search(
1259
+ r"^([ \t]*)help\s*\{(.*?)^\1\}",
1260
+ new_block, re.MULTILINE | re.DOTALL,
1261
+ )
1262
+ old_help = None
1263
+ if inner_help:
1264
+ kv = _KV_RE("helpText").search(inner_help.group(2))
1265
+ if kv:
1266
+ old_help = kv.group(2).strip()
1267
+ if old_help != help_text:
1268
+ new_block = _set_or_add_nested_kv(
1269
+ new_block, "help", "helpText", help_text,
1270
+ inner_indent, deep_indent,
1271
+ )
1272
+ changes.append(Change(blk, "helpText", old_help, help_text))
1273
+
1274
+ # ── text case ──────────────────────────────────────────────────
1275
+ # BAU.TEXT_CASE U/L → `settings { textCase: upper|lower }`. Only
1276
+ # `type: textField` blocks carry the property in the samples;
1277
+ # other item types reject it.
1278
+ if (blk.kind != "displayColumn" and not effective_hidden
1279
+ and col.text_case in TEXT_CASE_MAP
1280
+ and block_type == "textField"):
1281
+ mapped_case = TEXT_CASE_MAP[col.text_case]
1282
+ inner_settings = re.search(
1283
+ r"^([ \t]*)settings\s*\{(.*?)^\1\}",
1284
+ new_block, re.MULTILINE | re.DOTALL,
1285
+ )
1286
+ old_case = None
1287
+ if inner_settings:
1288
+ kv = _KV_RE("textCase").search(inner_settings.group(2))
1289
+ if kv:
1290
+ old_case = kv.group(2).strip()
1291
+ if old_case != mapped_case:
1292
+ new_block = _set_or_add_nested_kv(
1293
+ new_block, "settings", "textCase", mapped_case,
1294
+ inner_indent, deep_indent,
1295
+ )
1296
+ changes.append(Change(blk, "textCase", old_case, mapped_case))
1297
+
1298
+ # ── LOV-rendered column/item → display is a STRING ─────────────
1299
+ # A column/item shown through an LOV (e.g. an FK *_ID rendered as the
1300
+ # referenced NAME, or a select list) displays a lookup string. BAU's
1301
+ # metadata describes the underlying numeric ID (center align + a
1302
+ # numeric mask like 000000), which is wrong for a string. Detect the
1303
+ # `lov { }` block (present for IR LOV columns; synthesized above for
1304
+ # retyped select lists) and: drop the numeric mask + force left align.
1305
+ # `lov { }` block → unambiguous string display. A SQL-derived
1306
+ # projection (subquery/function aliased to the column) is treated as a
1307
+ # string display ONLY when the column looks like a numeric ID — the
1308
+ # "ID shown as the looked-up name" case the numeric-ID metadata
1309
+ # (center align + 0000 mask) is wrong for. ID signature: FK/PK flags
1310
+ # (often unset when constraints aren't synced), OR center align, OR an
1311
+ # all-zeros mask, OR an ID-style name (ID / *_ID / ID_*). This avoids
1312
+ # touching a derived NUMERIC measure (e.g. `(qtd*preco) VALOR`, right
1313
+ # align + decimal mask), which matches none of these.
1314
+ _id_like = (col.is_fk or col.is_pk
1315
+ or (col.align_report or "").upper() == "C"
1316
+ or bool(re.fullmatch(r"0+", col.format_mask or ""))
1317
+ or bool(re.search(r"(^ID$|_ID$|^ID_)", blk.name.upper())))
1318
+ block_has_lov = bool(
1319
+ re.search(r"^[ \t]*lov\s*\{", new_block, re.MULTILINE)) \
1320
+ or (blk.name.upper() in derived_cols and _id_like)
1321
+ if (blk.kind != "displayColumn" and block_has_lov
1322
+ and not effective_hidden):
1323
+ stripped, removed = _remove_nested_kv(
1324
+ new_block, "appearance", "formatMask")
1325
+ if removed is not None:
1326
+ new_block = stripped
1327
+ changes.append(Change(blk, "formatMask", removed, None))
1328
+
1329
+ # ── format mask ────────────────────────────────────────────────
1330
+ # Skipped on hidden blocks — APEX 26 raises INVALID_PROPERTY — and on
1331
+ # LOV columns (the displayed value is a string, not the numeric ID).
1332
+ if (blk.kind != "displayColumn" and col.format_mask
1333
+ and not effective_hidden and not block_has_lov):
1334
+ old_mask_m = re.search(
1335
+ r"^[ \t]*formatMask\s*:\s*(.*?)$", new_block, re.MULTILINE
1336
+ )
1337
+ old_mask = old_mask_m.group(1).strip() if old_mask_m else None
1338
+ if old_mask != col.format_mask:
1339
+ new_block = _set_or_add_nested_kv(
1340
+ new_block, "appearance", "formatMask", col.format_mask,
1341
+ inner_indent, deep_indent,
1342
+ )
1343
+ changes.append(Change(blk, "formatMask", old_mask, col.format_mask))
1344
+
1345
+ # ── alignment ──────────────────────────────────────────────────
1346
+ # Also skipped on hidden blocks (same parser rule as formatMask).
1347
+ # pageItem and column use DIFFERENT keys and vocabularies — see
1348
+ # ALIGN_MAP / ALIGN_MAP_ITEM.
1349
+ if blk.kind == "pageItem":
1350
+ align_src, align_key, amap = col.align_form, "alignment", ALIGN_MAP_ITEM
1351
+ else:
1352
+ align_src, align_key, amap = col.align_report, "columnAlignment", ALIGN_MAP
1353
+ # LOV columns show a string → left-align, overriding BAU's numeric-ID
1354
+ # center default (see the LOV/format-mask note above).
1355
+ if block_has_lov:
1356
+ align_src = "L"
1357
+ if (blk.kind != "displayColumn" and align_src in amap
1358
+ and not effective_hidden):
1359
+ mapped = amap[align_src]
1360
+ old_align_m = re.search(
1361
+ rf"^[ \t]*{align_key}\s*:\s*(.*?)$", new_block, re.MULTILINE
1362
+ )
1363
+ old_align = old_align_m.group(1).strip() if old_align_m else None
1364
+ # `start` is the IR column default: APEX's import round-trip
1365
+ # drops an explicit `columnAlignment: start`, so writing it
1366
+ # would re-surface as phantom drift on every following run.
1367
+ if (blk.kind == "column" and old_align is None
1368
+ and mapped == "start"):
1369
+ pass
1370
+ elif old_align != mapped:
1371
+ new_block = _set_or_add_nested_kv(
1372
+ new_block, "layout", align_key, mapped,
1373
+ inner_indent, deep_indent,
1374
+ )
1375
+ changes.append(Change(blk, align_key, old_align, mapped))
1376
+
1377
+ # ── hidden / readOnly ──────────────────────────────────────────
1378
+ # `_set_top_kv` edits the block's OWN `type:` (depth-filtered), not
1379
+ # the nested `source { type: ... }`.
1380
+ if blk.kind != "displayColumn" and col.hidden:
1381
+ if block_type != "hidden":
1382
+ new_block = _set_top_kv(new_block, "type", "hidden",
1383
+ inner_indent)
1384
+ changes.append(Change(blk, "type", block_type, "hidden"))
1385
+ elif col.read_only and blk.kind not in ("pageItem", "displayColumn"):
1386
+ if block_type not in (None, "displayOnly"):
1387
+ # leave non-displayOnly types alone (e.g. textField with explicit RO)
1388
+ pass
1389
+ elif block_type != "displayOnly":
1390
+ new_block = _set_top_kv(new_block, "type", "displayOnly",
1391
+ inner_indent)
1392
+ changes.append(Change(blk, "type", block_type, "displayOnly"))
1393
+
1394
+ # ── sequence (block ordering) ──────────────────────────────────
1395
+ # BAU's REPORT_DISPLAY_ORDER / FORM_DISPLAY_ORDER are the FINAL
1396
+ # sequence values: PRC_BAU_DOCTAB_ORDER_COLUMNS already steps them
1397
+ # by 10 (10, 20, 30 …) precisely so manual insertions fit between.
1398
+ # Use them verbatim — multiplying again double-stepped the sequence
1399
+ # (e.g. 70 → 700). All three block kinds consume the stored value
1400
+ # directly (savedReport `displayColumn` always did).
1401
+ target_seq = None
1402
+ if blk.kind == "pageItem" and col.form_display_order is not None:
1403
+ target_seq = col.form_display_order
1404
+ elif blk.kind == "column" and col.report_display_order is not None:
1405
+ target_seq = col.report_display_order
1406
+ elif blk.kind == "displayColumn" and col.report_display_order is not None:
1407
+ target_seq = col.report_display_order
1408
+
1409
+ if target_seq is not None:
1410
+ if blk.kind == "displayColumn":
1411
+ # `sequence: N` directly inside the displayColumn block
1412
+ seq_m = _KV_RE("sequence").search(new_block)
1413
+ old_seq = seq_m.group(2).strip() if seq_m else None
1414
+ target_str = str(target_seq)
1415
+ if old_seq != target_str:
1416
+ new_block = _set_or_add_kv(new_block, "sequence",
1417
+ target_str, inner_indent)
1418
+ changes.append(Change(blk, "sequence", old_seq, target_str))
1419
+ else:
1420
+ # layout { sequence: N }
1421
+ layout_re = re.compile(
1422
+ r"^([ \t]*)layout\s*\{(.*?)^\1\}",
1423
+ re.MULTILINE | re.DOTALL,
1424
+ )
1425
+ lm = layout_re.search(new_block)
1426
+ old_seq = None
1427
+ if lm:
1428
+ kv = _KV_RE("sequence").search(lm.group(2))
1429
+ if kv:
1430
+ old_seq = kv.group(2).strip()
1431
+ target_str = str(target_seq)
1432
+ if old_seq != target_str:
1433
+ new_block = _set_or_add_nested_kv(
1434
+ new_block, "layout", "sequence", target_str,
1435
+ inner_indent, deep_indent,
1436
+ )
1437
+ changes.append(Change(blk, "sequence", old_seq, target_str))
1438
+
1439
+ # Splice back into the buffer.
1440
+ buf = buf[:blk.start] + new_block + buf[blk.end:]
1441
+
1442
+ changes = list(reversed(changes))
1443
+
1444
+ # rowsPerPage dos IRs: seta todo `view { rowsPerPage: N }` do page para o
1445
+ # valor de config (só aparece em savedReport de interactiveReport). Feito
1446
+ # no buffer final (é region-level, fora dos blocos de coluna/item).
1447
+ if rows_per_page and rows_per_page > 0:
1448
+ rpp_blk = Block("savedReport", "view", "", 0, 0, "")
1449
+
1450
+ def _sub_rpp(m):
1451
+ if m.group(2) != str(rows_per_page):
1452
+ changes.append(Change(rpp_blk, "rowsPerPage",
1453
+ m.group(2), str(rows_per_page)))
1454
+ return f"{m.group(1)}{rows_per_page}"
1455
+
1456
+ buf = re.sub(r"(rowsPerPage:\s*)(\d+)", _sub_rpp, buf)
1457
+
1458
+ return RebuildResult(new_source=buf, changes=changes, skipped=skipped)