vb6decompiler 0.2.0__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.
vb6decompiler/frm.py ADDED
@@ -0,0 +1,1107 @@
1
+ """VB6 ``.frm`` design-block parser (text only, stdlib only).
2
+
3
+ A VB6 form file is a text file: a ``VERSION 5.00`` header, a nested design block
4
+ opened by ``Begin <Type> <Name>`` and closed by ``End``, then the code section
5
+ which starts at ``Attribute VB_Name = "..."``. This module parses *only* the
6
+ design block; it never looks at the code and never touches ``.frx`` blobs.
7
+
8
+ Five properties of the format decide the shape of this API. They are not style
9
+ choices, they were measured on real decompiled output:
10
+
11
+ 1. **Only non-default properties are written.** A ``TextBox`` with no ``Locked``
12
+ line is editable; no ``Visible`` line means visible; no ``Enabled`` line means
13
+ enabled. Therefore an absent property is *never* the same as ``false``. Every
14
+ boolean the format can omit is exposed as a tri-state: ``True``, ``False`` or
15
+ ``None`` (not written). :func:`bool_tri_counts` reports the three counts
16
+ separately instead of folding "absent" into ``False``.
17
+
18
+ 2. **Text is CP1254 (Turkish).** Bytes are decoded with
19
+ ``errors="replace"`` and the raw bytes of every value stay reachable
20
+ (:attr:`Property.raw_bytes`), so a caller can re-decode or hash the original
21
+ without re-reading the file. Never ``latin-1``: it decodes without error and
22
+ silently produces wrong Turkish (``�`` vs ``ý``/``þ``).
23
+
24
+ 3. **Geometry is in twips.** Form-level ``ClientWidth``/``ClientHeight`` (and
25
+ ``Width``/``Height``) and every control's ``Left``/``Top``/``Width``/``Height``
26
+ are twips as written. Nothing is converted on the way in. Conversion is an
27
+ explicit, opt-in call (:attr:`Control.bounds_px`,
28
+ :func:`twips_to_px`) and rests on the *assumption* of 15 twips per pixel —
29
+ labelled as an assumption everywhere, because the file itself does not state
30
+ a scale.
31
+
32
+ 4. **Control arrays are one type and one name with an ``Index``.** Elements must
33
+ be grouped by ``(type, name)`` and ordered by ``Index``; they must not be
34
+ merged into a single control and must not be emitted as unrelated siblings
35
+ (:meth:`Form.control_arrays`).
36
+
37
+ 5. **``SSTab`` page ownership comes from the ``Begin``/``End`` nesting.** A child
38
+ of ``TabDlg.SSTab`` is written *inside* the ``Begin TabDlg.SSTab SSTab1``
39
+ block; there is no parent property on the child. Page captions, when they are
40
+ written at all, appear on the tab control itself as ``TabCaption(n) = "..."``
41
+ and the selected page as ``Tab = n``. Page *membership* is not in the text.
42
+
43
+ Measured on a real target (one decompiled VB6 application, 802 ``.frm`` files —
44
+ see ``docs/RULES.md`` §2: a parser ships with its coverage, not with "OK"):
45
+
46
+ * 369 files carry a design block (368 ``Begin VB.Form`` + 1 ``Begin VB.MDIForm``);
47
+ 433 are code-only stubs with no design block at all. The stubs are reported by
48
+ name, not skipped.
49
+ * 30,000 controls inside those 369 forms, 24,970 of them with all four of
50
+ ``Left``/``Top``/``Width``/``Height`` (the rest are ``Line``, ``Menu``,
51
+ ``Timer`` and non-visual OCX controls, which have no rectangle).
52
+ * Control arrays: 1,165 groups / 14,268 elements in 126 forms — and exactly
53
+ 14,268 ``Index =`` lines in the text, i.e. every array element writes one.
54
+ * ``SSTab``: 14 controls with 1,266 nested children, and **0** of them write
55
+ ``Tab`` or ``TabCaption(n)`` — the page data lives in the ``OleObjectBlob``
56
+ inside the matching ``.frx``. So this parser reports 0 pages for that target
57
+ instead of guessing a page count.
58
+ * Only ``Visible = 0 'False`` is ever written (432 lines, never ``Visible = -1``):
59
+ the explicit-``false`` case exists, the explicit-``true`` case does not. Reading
60
+ absence as ``False`` would flip 29,568 controls.
61
+ * One file is truncated mid-control and never closes its design block; it is
62
+ flagged ``complete=False`` with the cut line number, not silently repaired.
63
+
64
+ Re-run those numbers with :func:`scan_directory` / :func:`format_summary`, or
65
+ ``python -m vb6decompiler.frm <directory>``.
66
+ """
67
+ from __future__ import annotations
68
+
69
+ import os
70
+ import re
71
+ from dataclasses import dataclass, field
72
+ from typing import Iterable, Iterator, Sequence
73
+
74
+ __all__ = [
75
+ "TWIPS_PER_PX_ASSUMED",
76
+ "FrmError",
77
+ "NoDesignBlock",
78
+ "MalformedForm",
79
+ "ParseWarning",
80
+ "FrxRef",
81
+ "Property",
82
+ "Control",
83
+ "Form",
84
+ "ScanResult",
85
+ "parse_bytes",
86
+ "parse_frm_text",
87
+ "parse_frm",
88
+ "scan_directory",
89
+ "summarize",
90
+ "format_summary",
91
+ "bool_tri_counts",
92
+ "twips_to_px",
93
+ "decode_cp1254",
94
+ "TRUE_EXPLICIT",
95
+ "FALSE_EXPLICIT",
96
+ "ABSENT",
97
+ ]
98
+
99
+ #: Assumed device scale for the optional pixel conversion. **This is an
100
+ #: assumption, not a measurement**: a ``.frm`` file stores twips and never states
101
+ #: a scale; 15 twips/px is the classic 96 dpi value the downstream tool in this
102
+ #: target family uses. Every function that applies it says so in its name or
103
+ #: docstring, and all public geometry fields stay in twips.
104
+ TWIPS_PER_PX_ASSUMED = 15
105
+
106
+ #: Tri-state names. Strings rather than an ``enum`` so JSON output stays readable.
107
+ TRUE_EXPLICIT = "true" # property written and truthy
108
+ FALSE_EXPLICIT = "false" # property written and falsy
109
+ ABSENT = "absent" # property not written: VB6 default applies, *do not* read as false
110
+
111
+ #: Encoding of a VB6 ``.frm``'s non-ASCII text. Measured: Turkish captions in
112
+ #: the target decode correctly as CP1254 and incorrectly as latin-1.
113
+ FRM_ENCODING = "cp1254"
114
+
115
+ # --------------------------------------------------------------------------- grammar
116
+ # ``Begin VB.TextBox Sutun4`` / ``Begin TabDlg.SSTab SSTab1`` /
117
+ # ``Begin MSComctlLib.ProgressBar ProgressBar1``; a licenced OCX may appear as a
118
+ # GUID (``Begin {C62A69F0-...} Ctrl1``), which is why the type alternative allows
119
+ # braces.
120
+ RE_BEGIN = re.compile(r"^(?P<indent>[ \t]*)Begin\s+(?P<type>[A-Za-z_][\w.]*|\{[0-9A-Fa-f-]+\})"
121
+ r"\s+(?P<name>[A-Za-z_]\w*)\s*$")
122
+ #: ``End`` closes a control; ``EndProperty`` closes a ``BeginProperty`` group and
123
+ #: must be tested first (it also starts with "End").
124
+ RE_END = re.compile(r"^(?P<indent>[ \t]*)End\s*$")
125
+ RE_END_PROPERTY = re.compile(r"^(?P<indent>[ \t]*)EndProperty\s*$")
126
+ #: ``BeginProperty Font`` groups sit *inside* a control (9,438 occurrences in the
127
+ #: measured target, all of them ``Font``). Their members are flattened to
128
+ #: ``Font.Name``, ``Font.Size`` ... so the control keeps a single property map.
129
+ RE_BEGIN_PROPERTY = re.compile(r"^(?P<indent>[ \t]*)BeginProperty\s+(?P<type>[A-Za-z_][\w.]*)"
130
+ r"(?:\s+(?P<name>[A-Za-z_]\w*))?\s*$")
131
+ #: ``Name = value``. The optional ``(n)`` covers indexed properties
132
+ #: (``TabCaption(0) = "..."``, ``Item(3) = ...``).
133
+ RE_PROP = re.compile(r"^(?P<indent>[ \t]*)(?P<name>[A-Za-z_]\w*)"
134
+ r"(?:\((?P<param>\d+)\))?\s*=\s*(?P<value>.*?)\s*$")
135
+ #: The code section starts here; the design block never continues past it.
136
+ RE_CODE_START = re.compile(r"^\s*Attribute\s+VB_")
137
+
138
+ RE_HEX = re.compile(r"^&[Hh]([0-9A-Fa-f]+)&?$")
139
+ RE_INT = re.compile(r"^[-+]?\d+$")
140
+ #: VB6 writes non-integral numbers with the authoring machine's decimal
141
+ #: separator; the measured target writes ``Size = 8,25`` throughout (9,419
142
+ #: occurrences). A dot never appears in the 368 measured files.
143
+ RE_FLOAT_COMMA = re.compile(r"^[-+]?\d+,\d+$")
144
+ #: ``OleObjectBlob = "fDemo011.frx":1042`` — a reference into the sibling binary
145
+ #: resource file. The value is *not* text and *not* geometry.
146
+ #:
147
+ #: The offset is **hexadecimal without a prefix**, and that was measured, not
148
+ #: assumed: 1,626 references across the target's 369 design blocks, every one of
149
+ #: them an offset below the size of the ``.frx`` it names, zero above; 837 of them
150
+ #: are digits-only (``:1042``) and the hex reading is the only one that stays in
151
+ #: range for the whole file. Some are unambiguously hex (``:10AA``, ``:14A4``),
152
+ #: so a decimal reading of the digits-only ones would mix two bases in one file.
153
+ RE_FRX_REF = re.compile(r'^"((?:[^"]|"")*)"\s*:\s*([0-9A-Fa-f]+)$')
154
+
155
+ #: Value kinds produced by :func:`_parse_value`.
156
+ KIND_STR = "str"
157
+ KIND_BOOL = "bool"
158
+ KIND_INT = "int"
159
+ KIND_FLOAT = "float"
160
+ KIND_FRX = "frx"
161
+ KIND_UNKNOWN = "unknown"
162
+
163
+
164
+ class FrmError(ValueError):
165
+ """Base class for design-block problems that stop parsing."""
166
+
167
+
168
+ class NoDesignBlock(FrmError):
169
+ """The file has no ``Begin ...Form`` design block (code-only stub).
170
+
171
+ Measured: 434 of 802 files in the target directory are in this state. They
172
+ are raised on purpose rather than returned as an empty form, so a caller
173
+ cannot mistake "no design" for "a form with zero controls".
174
+ """
175
+
176
+
177
+ class MalformedForm(FrmError):
178
+ """The design block opened but could not be closed consistently."""
179
+
180
+
181
+ # --------------------------------------------------------------------------- values
182
+ @dataclass(frozen=True)
183
+ class FrxRef:
184
+ """A reference into the sibling ``.frx`` binary resource.
185
+
186
+ ``"fDemo011.frx":10AA`` means "offset 0x10AA of fDemo011.frx" — the offset is
187
+ hexadecimal (see :data:`RE_FRX_REF` for the measurement). ``offset_text``
188
+ keeps the digits exactly as written so a caller that disagrees with the hex
189
+ reading still has the evidence. The bytes are not read here; this module is
190
+ text-only.
191
+ """
192
+
193
+ file: str
194
+ offset: int
195
+ offset_text: str = ""
196
+
197
+ def __str__(self) -> str: # pragma: no cover - convenience only
198
+ return f"{self.file}:{self.offset_text or self.offset}"
199
+
200
+
201
+ @dataclass
202
+ class Property:
203
+ """One written ``Name = value`` line.
204
+
205
+ ``value`` is normalised (``int``/``float``/``bool``/``str``/:class:`FrxRef`);
206
+ ``raw`` is the value text as written with the VB6 type-hint comment removed
207
+ and ``comment`` is that removed part. Keeping the comment matters: it is the
208
+ only way to tell the boolean ``0 'False`` from the enumeration value
209
+ ``0`` (``BorderStyle = 0 'None``), and VB6 writes booleans as ``-1``/``0``.
210
+ """
211
+
212
+ name: str
213
+ value: object
214
+ kind: str
215
+ raw: str
216
+ raw_bytes: bytes
217
+ comment: str | None = None
218
+ param: int | None = None # index of `Name(n) = ...`, else None
219
+ line: int = 0
220
+
221
+ @property
222
+ def is_bool(self) -> bool:
223
+ return self.kind == KIND_BOOL
224
+
225
+
226
+ @dataclass
227
+ class ParseWarning:
228
+ """A line the parser could not interpret. Collected, never swallowed."""
229
+
230
+ path: str
231
+ line: int
232
+ text: str
233
+ reason: str
234
+
235
+ def __str__(self) -> str:
236
+ return f"{self.path}:{self.line}: {self.reason}: {self.text!r}"
237
+
238
+
239
+ def decode_cp1254(data: bytes) -> str:
240
+ """Decode VB6 text the way the format requires, keeping a lossy path explicit.
241
+
242
+ ``errors="replace"`` is deliberate: a decompiled file may contain bytes that
243
+ are not valid CP1254, and dropping the whole file would lose the other 25,000
244
+ controls. U+FFFD in the output is a *finding* that :func:`summarize` counts,
245
+ not something to hide. The raw bytes stay available on
246
+ :attr:`Property.raw_bytes`.
247
+ """
248
+ return data.decode(FRM_ENCODING, "replace")
249
+
250
+
251
+ def _split_comment(text: str) -> tuple[str, str | None]:
252
+ """Split ``-1 'True`` into ``('-1', 'True')``.
253
+
254
+ The apostrophe only starts a comment outside a double-quoted string; VB6
255
+ doubles embedded quotes (``""``), which toggles the flag twice and is
256
+ therefore safe here.
257
+ """
258
+ in_str = False
259
+ for i, ch in enumerate(text):
260
+ if ch == '"':
261
+ in_str = not in_str
262
+ elif ch == "'" and not in_str:
263
+ return text[:i].rstrip(), text[i + 1:].strip()
264
+ return text.rstrip(), None
265
+
266
+
267
+ def _unquote(text: str) -> str:
268
+ """Turn ``"a""b"`` into ``a"b`` (VB6 doubles embedded quotes)."""
269
+ body = text[1:-1]
270
+ return body.replace('""', '"')
271
+
272
+
273
+ def _parse_value(text: str, comment: str | None) -> tuple[object, str]:
274
+ """Classify a property value and return ``(value, kind)``.
275
+
276
+ The order of the tests is the grammar order, not a preference:
277
+
278
+ * an ``.frx`` reference is checked before a plain string because it *starts*
279
+ with a quote too;
280
+ * hex literals (``&HC0C0C0&``) are integers in VB6 and must not fall through
281
+ to ``str``;
282
+ * a ``-1``/``0`` carrying the comment ``'True``/``'False`` is a boolean, while
283
+ the same number with ``'None``/``'Fixed Single`` is an enumeration member.
284
+ """
285
+ if not text:
286
+ return None, KIND_UNKNOWN
287
+ m = RE_FRX_REF.match(text)
288
+ if m:
289
+ return FrxRef(m.group(1).replace('""', '"'), int(m.group(2), 16),
290
+ m.group(2)), KIND_FRX
291
+ if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
292
+ return _unquote(text), KIND_STR
293
+ m = RE_HEX.match(text)
294
+ if m:
295
+ return int(m.group(1), 16), KIND_INT
296
+ if text in ("True", "False"):
297
+ return text == "True", KIND_BOOL
298
+ if RE_INT.match(text):
299
+ n = int(text)
300
+ if comment in ("True", "False"):
301
+ return bool(n), KIND_BOOL
302
+ return n, KIND_INT
303
+ if RE_FLOAT_COMMA.match(text):
304
+ return float(text.replace(",", ".")), KIND_FLOAT
305
+ return text, KIND_UNKNOWN
306
+
307
+
308
+ # --------------------------------------------------------------------------- nodes
309
+ @dataclass
310
+ class Control:
311
+ """One ``Begin <type> <name>`` block inside the form.
312
+
313
+ ``parent`` is the enclosing control's name (``None`` for a direct child of the
314
+ form). It is derived from the ``Begin``/``End`` nesting — there is no parent
315
+ property on a VB6 control — which is also how ``SSTab`` ownership is decided
316
+ (see the module docstring, point 5).
317
+ """
318
+
319
+ type: str # full type as written, e.g. "TabDlg.SSTab"
320
+ name: str
321
+ props: dict[str, Property] = field(default_factory=dict)
322
+ children: list["Control"] = field(default_factory=list)
323
+ parent: str | None = None
324
+ parent_type: str | None = None
325
+ depth: int = 0
326
+ line: int = 0
327
+ indent: int = 0
328
+
329
+ # -- naming -------------------------------------------------------------
330
+ @property
331
+ def short_type(self) -> str:
332
+ """``TabDlg.SSTab`` -> ``SSTab`` (lib prefix dropped)."""
333
+ return self.type.rsplit(".", 1)[-1]
334
+
335
+ # -- raw property access ------------------------------------------------
336
+ def prop(self, name: str, param: int | None = None) -> Property | None:
337
+ """Written property or ``None``. ``None`` means *not written*, not false.
338
+
339
+ Indexed properties must be asked for with their index
340
+ (``prop("TabCaption", 1)``); without one this returns the plain
341
+ ``Name = ...`` property, else the first indexed variant.
342
+ """
343
+ if param is not None:
344
+ return self.props.get(f"{name}({param})")
345
+ p = self.props.get(name)
346
+ if p is not None:
347
+ return p
348
+ for cand in self.props.values():
349
+ if cand.name == name:
350
+ return cand
351
+ return None
352
+
353
+ def has(self, name: str) -> bool:
354
+ return self.prop(name) is not None
355
+
356
+ def tri(self, name: str) -> str:
357
+ """Tri-state of a boolean property: ``"true"``/``"false"``/``"absent"``.
358
+
359
+ Measured reason for the third state: the target writes only
360
+ ``Visible = 0 'False`` (432 lines) and never ``Visible = -1``; absence is
361
+ the *normal* way to say "at the default, i.e. visible".
362
+ """
363
+ p = self.prop(name)
364
+ if p is None:
365
+ return ABSENT
366
+ if p.kind == KIND_BOOL:
367
+ return TRUE_EXPLICIT if p.value else FALSE_EXPLICIT
368
+ # Written but not a boolean: ``BorderStyle = 0 'None`` and ``Visible = 0``
369
+ # look alike numerically, and only the VB6 type-hint comment separates
370
+ # them. Guessing here would flip enumerations into "false", so an
371
+ # un-hinted value is reported as absent rather than as a boolean.
372
+ return ABSENT
373
+
374
+ def bool_prop(self, name: str) -> bool | None:
375
+ """``True``/``False`` when written, ``None`` when absent (VB6 default)."""
376
+ tri = self.tri(name)
377
+ if tri == TRUE_EXPLICIT:
378
+ return True
379
+ if tri == FALSE_EXPLICIT:
380
+ return False
381
+ return None
382
+
383
+ def effective_bool(self, name: str, default: bool) -> bool:
384
+ """Resolve a tri-state against the *caller's* stated default.
385
+
386
+ The default is a parameter because this module does not carry a table of
387
+ VB6 per-class defaults; guessing one would be invented data. Passing
388
+ ``default=True`` for ``Visible`` on a ``TextBox`` is the caller's claim,
389
+ not this parser's.
390
+ """
391
+ v = self.bool_prop(name)
392
+ return default if v is None else v
393
+
394
+ def number(self, name: str) -> int | float | None:
395
+ p = self.prop(name)
396
+ if p is None or p.kind not in (KIND_INT, KIND_FLOAT):
397
+ return None
398
+ return p.value
399
+
400
+ def twips(self, name: str) -> int | None:
401
+ """A geometry property exactly as written (twips). ``None`` if absent."""
402
+ p = self.prop(name)
403
+ if p is None:
404
+ return None
405
+ if p.kind == KIND_FLOAT:
406
+ # Fractional twips exist (``Size = 8,25``) but not for geometry;
407
+ # keep the value rather than silently rounding.
408
+ return p.value # type: ignore[return-value]
409
+ if p.kind == KIND_INT:
410
+ return int(p.value) # type: ignore[arg-type]
411
+ return None
412
+
413
+ def string(self, name: str) -> str | None:
414
+ p = self.prop(name)
415
+ if p is None:
416
+ return None
417
+ return p.value if p.kind == KIND_STR else None
418
+
419
+ @property
420
+ def caption(self) -> str | None:
421
+ """``Caption`` as written (CP1254-decoded), or ``None`` if not written."""
422
+ return self.string("Caption")
423
+
424
+ @property
425
+ def text(self) -> str | None:
426
+ return self.string("Text")
427
+
428
+ @property
429
+ def index(self) -> int | None:
430
+ """Control-array ``Index``, or ``None`` when the control is not in an array."""
431
+ p = self.prop("Index")
432
+ if p is None or not isinstance(p.value, int):
433
+ return None
434
+ return int(p.value)
435
+
436
+ @property
437
+ def caption_bytes(self) -> bytes | None:
438
+ """Raw CP1254 bytes of ``Caption``, for callers that must not re-encode."""
439
+ p = self.prop("Caption")
440
+ return p.raw_bytes if p is not None and p.kind == KIND_STR else None
441
+
442
+ # -- geometry -----------------------------------------------------------
443
+ @property
444
+ def bounds(self) -> tuple[int, int, int, int] | None:
445
+ """``(left, top, width, height)`` in twips, or ``None`` if any is absent.
446
+
447
+ No conversion, no defaulting: a partially written rectangle returns
448
+ ``None`` instead of a guessed 0.
449
+ """
450
+ vals = [self.twips(k) for k in ("Left", "Top", "Width", "Height")]
451
+ if any(v is None for v in vals):
452
+ return None
453
+ return tuple(int(v) for v in vals) # type: ignore[return-value]
454
+
455
+ @property
456
+ def has_bounds(self) -> bool:
457
+ return self.bounds is not None
458
+
459
+ def bounds_px(self, twips_per_px: int = TWIPS_PER_PX_ASSUMED) -> tuple[float, ...] | None:
460
+ """Twips converted to pixels with the **assumed** 15 twips/px scale.
461
+
462
+ The file carries no scale, so this is a convenience for rendering only;
463
+ nothing in this module calls it implicitly.
464
+ """
465
+ b = self.bounds
466
+ if b is None:
467
+ return None
468
+ return tuple(v / twips_per_px for v in b)
469
+
470
+ @property
471
+ def line_coords(self) -> tuple[int, int, int, int] | None:
472
+ """``(X1, Y1, X2, Y2)`` for ``VB.Line`` (twips), which has no Left/Width."""
473
+ vals = [self.twips(k) for k in ("X1", "Y1", "X2", "Y2")]
474
+ if any(v is None for v in vals):
475
+ return None
476
+ return tuple(int(v) for v in vals) # type: ignore[return-value]
477
+
478
+ @property
479
+ def derived_bounds(self) -> tuple[int, int, int, int] | None:
480
+ """Bounding box **derived** from ``X1/Y1/X2/Y2`` (as the downstream tool does).
481
+
482
+ Named "derived" on purpose: the file does not contain these four numbers
483
+ for a ``Line``, so they must never be presented as written properties.
484
+ Width/height are clamped to 1 twip so a purely horizontal or vertical line
485
+ still has a drawable rectangle.
486
+ """
487
+ c = self.line_coords
488
+ if c is None:
489
+ return None
490
+ x1, y1, x2, y2 = c
491
+ return (min(x1, x2), min(y1, y2), max(abs(x2 - x1), 1), max(abs(y2 - y1), 1))
492
+
493
+ @property
494
+ def font(self) -> dict[str, object]:
495
+ """Flattened ``BeginProperty Font`` members (``{"Name": ..., "Size": ...}``)."""
496
+ out: dict[str, object] = {}
497
+ for key, prop in self.props.items():
498
+ if "." in key:
499
+ out[key.split(".", 1)[1]] = prop.value
500
+ return out
501
+
502
+ # -- tab pages ----------------------------------------------------------
503
+ def tab_pages(self) -> list[tuple[int, str | None]]:
504
+ """``[(page_index, caption_or_None), ...]`` from written ``TabCaption(n)``.
505
+
506
+ Only what the text contains: pages are sorted by index and a page whose
507
+ caption is empty/absent is reported with ``None``. An empty list means
508
+ *no page captions are written in this file* — measured on the target, all
509
+ 14 ``SSTab`` controls store their page data in the ``.frx``
510
+ ``OleObjectBlob`` and write neither ``Tab`` nor ``TabCaption``, so an
511
+ empty list here is the correct answer, not a parser gap.
512
+ """
513
+ pages: dict[int, str | None] = {}
514
+ for prop in self.props.values():
515
+ if prop.name == "TabCaption" and prop.param is not None:
516
+ pages[prop.param] = prop.value if prop.kind == KIND_STR else None # type: ignore[assignment]
517
+ return sorted(pages.items())
518
+
519
+ @property
520
+ def selected_page(self) -> int | None:
521
+ """``Tab = n`` on the ``SSTab`` itself: the page shown in the designer."""
522
+ p = self.prop("Tab")
523
+ if p is None or not isinstance(p.value, int):
524
+ return None
525
+ return int(p.value)
526
+
527
+
528
+ @dataclass
529
+ class Form:
530
+ """A parsed ``.frm`` design block."""
531
+
532
+ name: str
533
+ type: str
534
+ props: dict[str, Property] = field(default_factory=dict)
535
+ controls: list[Control] = field(default_factory=list) # flattened, document order
536
+ roots: list[Control] = field(default_factory=list) # direct children of the form
537
+ warnings: list[ParseWarning] = field(default_factory=list)
538
+ path: str | None = None
539
+ #: ``False`` when the design block had no closing ``End`` (truncated file).
540
+ #: The controls recovered before the cut are kept; this flag says so.
541
+ complete: bool = True
542
+ incomplete_reason: str | None = None
543
+
544
+ # -- form-level ---------------------------------------------------------
545
+ def prop(self, name: str) -> Property | None:
546
+ return self.props.get(name)
547
+
548
+ def string(self, name: str) -> str | None:
549
+ p = self.prop(name)
550
+ return p.value if p is not None and p.kind == KIND_STR else None
551
+
552
+ @property
553
+ def caption(self) -> str | None:
554
+ return self.string("Caption")
555
+
556
+ def twips(self, name: str) -> int | None:
557
+ p = self.prop(name)
558
+ return int(p.value) if p is not None and p.kind == KIND_INT else None
559
+
560
+ @property
561
+ def client_size(self) -> tuple[int, int] | None:
562
+ """``(ClientWidth, ClientHeight)`` in twips, or ``None`` if either absent.
563
+
564
+ Measured: all 369 parsed design forms write both, and the values match the
565
+ form geometry recovered from the compiled image (the ``--validate`` path of
566
+ the blob extractor this parser was derived from). A form that omits them
567
+ is reported as ``None``, never as 0 — the two are different facts.
568
+ """
569
+ w, h = self.twips("ClientWidth"), self.twips("ClientHeight")
570
+ if w is None or h is None:
571
+ return None
572
+ return (w, h)
573
+
574
+ @property
575
+ def outer_size(self) -> tuple[int, int] | None:
576
+ """``(Width, Height)`` in twips — the whole window, not the client area."""
577
+ w, h = self.twips("Width"), self.twips("Height")
578
+ if w is None or h is None:
579
+ return None
580
+ return (w, h)
581
+
582
+ def tri(self, name: str) -> str:
583
+ p = self.prop(name)
584
+ if p is None:
585
+ return ABSENT
586
+ if p.kind == KIND_BOOL:
587
+ return TRUE_EXPLICIT if p.value else FALSE_EXPLICIT
588
+ return ABSENT
589
+
590
+ # -- lookups ------------------------------------------------------------
591
+ def control(self, name: str, index: int | None = None) -> Control | None:
592
+ """Find a control by name; for an array pass the ``Index`` too.
593
+
594
+ VB6 identifiers are case-insensitive, but this lookup is exact on purpose:
595
+ an approximate match between two different names would be invented data
596
+ (see ``docs/RULES.md`` §4). Case-fold yourself if that is what you want.
597
+ """
598
+ for c in self.controls:
599
+ if c.name != name:
600
+ continue
601
+ if index is None or c.index == index:
602
+ return c
603
+ return None
604
+
605
+ def by_name(self, name: str) -> list[Control]:
606
+ return [c for c in self.controls if c.name == name]
607
+
608
+ def control_arrays(self) -> dict[tuple[str, str], list[Control]]:
609
+ """Control arrays grouped by ``(type, name)`` and ordered by ``Index``.
610
+
611
+ A VB6 control array is *one* control with one name and one type whose
612
+ elements differ only by ``Index``; each element is its own design record
613
+ with its own geometry. Merging them loses all but one position; keeping
614
+ them as unrelated siblings loses the fact that they are one object (event
615
+ handlers are ``Sub Sutun4_Change(Index As Integer)``).
616
+
617
+ A group counts as an array when at least one member writes ``Index``.
618
+ Measured trap: a same-named pair with *different* types is two scalar
619
+ controls, not an array — ``(type, name)`` is the key for that reason.
620
+ """
621
+ groups: dict[tuple[str, str], list[Control]] = {}
622
+ for c in self.controls:
623
+ groups.setdefault((c.short_type, c.name), []).append(c)
624
+ arrays: dict[tuple[str, str], list[Control]] = {}
625
+ for key, members in groups.items():
626
+ if any(m.index is not None for m in members):
627
+ # Sorted by Index; an element that (illegally) lacks Index sorts
628
+ # last rather than crashing the parse.
629
+ arrays[key] = sorted(members, key=lambda m: (m.index is None, m.index or 0))
630
+ return arrays
631
+
632
+ def sstabs(self) -> list[Control]:
633
+ """All ``SSTab`` controls of the form."""
634
+ return [c for c in self.controls if c.short_type == "SSTab"]
635
+
636
+ def sstab_children(self, sstab_name: str) -> list[Control]:
637
+ """Direct children of one ``SSTab`` — ownership by nesting, not by property."""
638
+ return [c for c in self.controls if c.parent == sstab_name]
639
+
640
+ def sstab_of(self, control: Control) -> str | None:
641
+ """Name of the nearest ``SSTab`` ancestor, walking the parse tree."""
642
+ cur = control.parent
643
+ while cur is not None:
644
+ node = self.control(cur)
645
+ if node is None:
646
+ return None
647
+ if node.short_type == "SSTab":
648
+ return node.name
649
+ cur = node.parent
650
+ return None
651
+
652
+ # -- tree ---------------------------------------------------------------
653
+ def walk(self) -> Iterator[Control]:
654
+ """Depth-first walk over ``roots`` (same order as ``controls``)."""
655
+ def rec(nodes: Sequence[Control]) -> Iterator[Control]:
656
+ for n in nodes:
657
+ yield n
658
+ yield from rec(n.children)
659
+ return rec(self.roots)
660
+
661
+
662
+ # --------------------------------------------------------------------------- parser
663
+ @dataclass
664
+ class _Frame:
665
+ """One open ``Begin`` block or ``BeginProperty`` group during parsing."""
666
+
667
+ kind: str # "control" | "group" | "form"
668
+ indent: int
669
+ line: int
670
+ control: Control | None = None
671
+ group: str | None = None
672
+
673
+
674
+ def parse_frm_text(text: str, *, path: str | None = None,
675
+ raw: bytes | None = None) -> Form:
676
+ """Parse a decoded design block.
677
+
678
+ ``text`` must already be decoded (see :func:`decode_cp1254`); ``raw`` is the
679
+ matching byte string and is only used to keep :attr:`Property.raw_bytes`
680
+ pointing at the file's own bytes. When ``raw`` is omitted the raw bytes are
681
+ the CP1254 encoding of ``text``, which is exact for every file this parser
682
+ can read and lossy only in the U+FFFD cases that are reported as warnings.
683
+ """
684
+ if raw is None:
685
+ raw = text.encode(FRM_ENCODING, "replace")
686
+ # Split the decoded text and the raw bytes in lockstep so a property's bytes
687
+ # can be located without re-encoding (CP1254 is single-byte, so byte offsets
688
+ # of ASCII '=' and quotes equal character offsets).
689
+ lines = text.split("\n")
690
+ raw_lines = raw.split(b"\n") if raw.count(b"\n") == len(lines) - 1 else []
691
+
692
+ form: Form | None = None
693
+ warnings: list[ParseWarning] = []
694
+ stack: list[_Frame] = []
695
+ all_controls: list[Control] = []
696
+ truncated = False
697
+
698
+ def warn(lineno: int, txt: str, reason: str) -> None:
699
+ warnings.append(ParseWarning(path or "<text>", lineno, txt, reason))
700
+
701
+ for lineno, raw_line in enumerate(lines, 1):
702
+ line = raw_line.rstrip("\r")
703
+ stripped = line.strip()
704
+
705
+ if not stripped:
706
+ continue
707
+ # A decompiler may emit a commented-out property (measured: `'Icon = n/a`).
708
+ # The leading apostrophe is VB6's comment marker, so the line is not data.
709
+ if stripped.startswith("'"):
710
+ continue
711
+ # A design block ends at the first Attribute statement (measured: always
712
+ # `Attribute VB_Name`); anything after it is VB6 code, not design data.
713
+ if RE_CODE_START.match(line):
714
+ break
715
+
716
+ m = RE_BEGIN.match(line)
717
+ if m:
718
+ indent = len(m.group("indent").expandtabs(4))
719
+ typ, name = m.group("type"), m.group("name")
720
+ # Indentation is the authoritative nesting signal for the siblings,
721
+ # `End` for closing: pop anything at the same or deeper level so a
722
+ # missing `End` degrades a subtree instead of corrupting the rest.
723
+ while stack and stack[-1].indent >= indent and stack[-1].kind != "form":
724
+ warn(lineno, line, f"'{name}' opened while {stack[-1].kind} "
725
+ f"from line {stack[-1].line} still open (unclosed End)")
726
+ stack.pop()
727
+ ctrl = Control(type=typ, name=name, line=lineno, indent=indent)
728
+ parent: Control | None = None
729
+ for frame in reversed(stack):
730
+ if frame.control is not None:
731
+ parent = frame.control
732
+ break
733
+ if parent is not None:
734
+ ctrl.parent = parent.name
735
+ ctrl.parent_type = parent.type
736
+ ctrl.depth = parent.depth + 1
737
+ parent.children.append(ctrl)
738
+ else:
739
+ ctrl.depth = 0
740
+ kind = "form" if (form is None and typ.rsplit(".", 1)[-1].endswith("Form")) else "control"
741
+ if kind == "form":
742
+ form = Form(name=name, type=typ, path=path)
743
+ stack.append(_Frame(kind="form", indent=indent, line=lineno))
744
+ continue
745
+ if form is None:
746
+ warn(lineno, line, "control before any form block")
747
+ all_controls.append(ctrl)
748
+ if parent is None and form is not None:
749
+ form.roots.append(ctrl)
750
+ stack.append(_Frame(kind="control", indent=indent, line=lineno, control=ctrl))
751
+ continue
752
+
753
+ if RE_END_PROPERTY.match(line):
754
+ indent = len(RE_END_PROPERTY.match(line).group("indent").expandtabs(4))
755
+ if stack and stack[-1].kind == "group":
756
+ stack.pop()
757
+ else:
758
+ warn(lineno, line, "EndProperty without BeginProperty")
759
+ continue
760
+
761
+ if RE_BEGIN_PROPERTY.match(line):
762
+ indent = len(RE_BEGIN_PROPERTY.match(line).group("indent").expandtabs(4))
763
+ grp = RE_BEGIN_PROPERTY.match(line)
764
+ stack.append(_Frame(kind="group", indent=indent, line=lineno,
765
+ group=grp.group("type")))
766
+ continue
767
+
768
+ if RE_END.match(line):
769
+ indent = len(RE_END.match(line).group("indent").expandtabs(4))
770
+ if not stack:
771
+ warn(lineno, line, "End without Begin")
772
+ continue
773
+ if stack[-1].kind == "group":
774
+ warn(lineno, line, "End inside BeginProperty group")
775
+ stack.pop()
776
+ # Pop the innermost frame at this indent level.
777
+ while stack and stack[-1].indent > indent:
778
+ warn(lineno, line, f"forced close of {stack[-1].kind} "
779
+ f"from line {stack[-1].line}")
780
+ stack.pop()
781
+ closed = stack.pop() if stack else None
782
+ # The root form's `End` is the authoritative end of the design block:
783
+ # measured, the code that follows does not always start with
784
+ # `Attribute VB_Name` (one file continues with `Public mblnAktif As
785
+ # Boolean` and even a disassembly listing), so waiting for the
786
+ # Attribute line would swallow code into the design block.
787
+ if closed is not None and closed.kind == "form":
788
+ break
789
+ continue
790
+
791
+ m = RE_PROP.match(line)
792
+ if m:
793
+ if not stack:
794
+ # Header lines before `Begin VB.Form` (`VERSION`, `Object`) are
795
+ # not form properties; ignore them silently.
796
+ continue
797
+ frame = stack[-1]
798
+ base = m.group("name")
799
+ param = int(m.group("param")) if m.group("param") else None
800
+ if frame.kind == "group":
801
+ # `BeginProperty Font ... EndProperty` members belong to the
802
+ # control that owns the group, which is further down the stack.
803
+ owner = next((f.control for f in reversed(stack)
804
+ if f.kind == "control"), None)
805
+ key = f"{frame.group}.{base}"
806
+ else:
807
+ owner = frame.control if frame.kind == "control" else None
808
+ # Indexed properties (`TabCaption(0)`, `TabCaption(1)`, ...) keep
809
+ # their index in the key: keying by name alone would let page 1
810
+ # overwrite page 0 and report a single page.
811
+ key = base if param is None else f"{base}({param})"
812
+ if frame.kind == "form" and form is not None:
813
+ owner = None
814
+ value_text, comment = _split_comment(m.group("value"))
815
+ vstart = line.find(m.group("value"))
816
+ vend = vstart + len(value_text) if vstart >= 0 else -1
817
+ if raw_lines and lineno - 1 < len(raw_lines) and vstart >= 0:
818
+ vbytes = raw_lines[lineno - 1][vstart:vend]
819
+ else:
820
+ vbytes = value_text.encode(FRM_ENCODING, "replace")
821
+ value, kind = _parse_value(value_text, comment)
822
+ if kind == KIND_UNKNOWN:
823
+ warn(lineno, line, "unrecognised property value")
824
+ prop = Property(name=base, value=value, kind=kind,
825
+ raw=value_text, raw_bytes=vbytes, comment=comment,
826
+ param=param, line=lineno)
827
+ if owner is not None:
828
+ owner.props[key] = prop
829
+ elif frame.kind == "form" and form is not None:
830
+ form.props[key] = prop
831
+ elif frame.kind == "group":
832
+ warn(lineno, line, "property group outside a control")
833
+ continue
834
+
835
+ if form is None:
836
+ # Everything before the `Begin VB.Form` line is file header
837
+ # (`VERSION 5.00`, `Object = "{guid}"; "path"`). It is not design
838
+ # data, so it is skipped without a warning.
839
+ continue
840
+ # A line inside an open form that is neither Begin/End/property/comment
841
+ # means the design block stopped without its closing `End` — a truncated
842
+ # or hand-edited file. Reported once, with the recovery count, instead of
843
+ # one warning per code line: measured case, `formRec01` ends inside a
844
+ # `Begin VB.PictureBox` and continues straight into `Public mblnAktif As
845
+ # Boolean` and a disassembly listing.
846
+ warn(lineno, line, f"design block ended without closing End at line {lineno}; "
847
+ f"{len(all_controls)} control(s) recovered before it")
848
+ truncated = True
849
+ break
850
+
851
+ if form is None:
852
+ raise NoDesignBlock(f"{path or '<text>'}: no Begin ...Form design block")
853
+ form.complete = not truncated
854
+ if not truncated and any(f.kind != "form" for f in stack):
855
+ # Reaching the end of the text with blocks still open means the closing
856
+ # `End`s are missing; the controls found are kept and the file is flagged.
857
+ form.complete = False
858
+ warn(len(lines), "<eof>", f"{sum(1 for f in stack if f.kind != 'form')} block(s) "
859
+ "still open at end of design block")
860
+ form.controls = all_controls
861
+ form.warnings = warnings
862
+ form.incomplete_reason = warnings[-1].reason if (warnings and not form.complete) else None
863
+ return form
864
+
865
+
866
+ def parse_bytes(data: bytes, *, path: str | None = None) -> Form:
867
+ """Decode ``data`` as CP1254 and parse its design block.
868
+
869
+ The whole file is decoded, not just the design block: finding the block
870
+ boundary requires scanning to ``Attribute``, and byte offsets must stay in
871
+ step with character offsets (true for a single-byte codec like CP1254).
872
+ """
873
+ return parse_frm_text(decode_cp1254(data), path=path, raw=data)
874
+
875
+
876
+ def parse_frm(path: str) -> Form:
877
+ """Parse one ``.frm`` file from disk. Raises :class:`NoDesignBlock` if it has none."""
878
+ with open(path, "rb") as fh:
879
+ data = fh.read()
880
+ return parse_bytes(data, path=path)
881
+
882
+
883
+ # --------------------------------------------------------------------------- directory
884
+ @dataclass
885
+ class ScanResult:
886
+ """Outcome of :func:`scan_directory`. Nothing is dropped silently.
887
+
888
+ * ``forms`` — parsed design blocks.
889
+ * ``no_design`` — files with no design block (code-only stubs). Listed by
890
+ path, not skipped: "no design block" is a measurement, not a failure of the
891
+ parser, and must not be confused with "form with zero controls".
892
+ * ``failures`` — ``(path, message)`` for files that *did* open a design block
893
+ and could not be finished.
894
+ * ``warnings`` — per-file line-level problems, kept with their file names.
895
+ """
896
+
897
+ root: str
898
+ files_seen: int = 0
899
+ forms: list[Form] = field(default_factory=list)
900
+ no_design: list[str] = field(default_factory=list)
901
+ failures: list[tuple[str, str]] = field(default_factory=list)
902
+ warnings: list[ParseWarning] = field(default_factory=list)
903
+
904
+
905
+ def _frm_files(root: str, recursive: bool = False) -> list[str]:
906
+ out: list[str] = []
907
+ if recursive:
908
+ for dirpath, _dirnames, filenames in os.walk(root):
909
+ for fn in filenames:
910
+ if fn.lower().endswith(".frm"):
911
+ out.append(os.path.join(dirpath, fn))
912
+ else:
913
+ for fn in os.listdir(root):
914
+ p = os.path.join(root, fn)
915
+ if fn.lower().endswith(".frm") and os.path.isfile(p):
916
+ out.append(p)
917
+ return sorted(out)
918
+
919
+
920
+ def scan_directory(root: str, *, recursive: bool = False) -> ScanResult:
921
+ """Parse every ``.frm`` under ``root`` and account for all of them."""
922
+ res = ScanResult(root=root)
923
+ for path in _frm_files(root, recursive):
924
+ res.files_seen += 1
925
+ try:
926
+ form = parse_frm(path)
927
+ except NoDesignBlock:
928
+ res.no_design.append(path)
929
+ continue
930
+ except (FrmError, OSError) as exc: # MalformedForm / unreadable file
931
+ res.failures.append((path, str(exc)))
932
+ continue
933
+ res.forms.append(form)
934
+ res.warnings.extend(form.warnings)
935
+ return res
936
+
937
+
938
+ # --------------------------------------------------------------------------- reporting
939
+ def bool_tri_counts(controls: Iterable[Control], name: str) -> dict[str, int]:
940
+ """Count ``true`` / ``false`` / ``absent`` for one boolean property.
941
+
942
+ Three states, three numbers, always summing to the denominator the caller
943
+ supplied. Collapsing ``absent`` into ``false`` is the single biggest way to
944
+ get a VB6 design wrong, so it has no shorthand here.
945
+ """
946
+ counts = {TRUE_EXPLICIT: 0, FALSE_EXPLICIT: 0, ABSENT: 0}
947
+ for c in controls:
948
+ counts[c.tri(name)] += 1
949
+ return counts
950
+
951
+
952
+ def twips_to_px(value: float, twips_per_px: int = TWIPS_PER_PX_ASSUMED) -> float:
953
+ """Convert twips to pixels using the **assumed** 15 twips/px scale."""
954
+ if twips_per_px <= 0:
955
+ raise ValueError("twips_per_px must be positive")
956
+ return value / twips_per_px
957
+
958
+
959
+ def summarize(res: ScanResult) -> dict[str, object]:
960
+ """Turn a :class:`ScanResult` into counts. Every count carries its denominator."""
961
+ controls = [c for f in res.forms for c in f.controls]
962
+ by_type: dict[str, int] = {}
963
+ for c in controls:
964
+ by_type[c.short_type] = by_type.get(c.short_type, 0) + 1
965
+
966
+ forms_with_arrays = 0
967
+ array_groups = 0
968
+ array_elements = 0
969
+ arrays_with_gaps = 0
970
+ for f in res.forms:
971
+ arrays = f.control_arrays()
972
+ if arrays:
973
+ forms_with_arrays += 1
974
+ array_groups += len(arrays)
975
+ for members in arrays.values():
976
+ array_elements += len(members)
977
+ idx = sorted(m.index for m in members if m.index is not None)
978
+ if idx and idx != list(range(len(idx))):
979
+ arrays_with_gaps += 1
980
+
981
+ sstab_controls = [c for c in controls if c.short_type == "SSTab"]
982
+ sstab_forms = [f for f in res.forms if f.sstabs()]
983
+ sstab_children = 0
984
+ pages_with_caption = 0
985
+ pages_without_caption = 0
986
+ selected_written = 0
987
+ for f in res.forms:
988
+ for s in f.sstabs():
989
+ sstab_children += len(f.sstab_children(s.name))
990
+ for _idx, cap in s.tab_pages():
991
+ if cap:
992
+ pages_with_caption += 1
993
+ else:
994
+ pages_without_caption += 1
995
+ if s.selected_page is not None:
996
+ selected_written += 1
997
+
998
+ replacement_captions = sum(
999
+ 1 for c in controls
1000
+ if isinstance(c.caption, str) and "\ufffd" in c.caption
1001
+ )
1002
+ # A Caption whose value is a `.frx` reference (`Caption = "fX.frx":14A4`) is
1003
+ # stored as binary, usually a Unicode/exotic caption; the text is not in the
1004
+ # design block, so it is counted here instead of being reported as "no caption".
1005
+ captions_in_frx = sum(
1006
+ 1 for c in controls
1007
+ if c.prop("Caption") is not None and c.prop("Caption").kind == KIND_FRX # type: ignore[union-attr]
1008
+ )
1009
+
1010
+ client_sizes = sum(1 for f in res.forms if f.client_size is not None)
1011
+ incomplete = [f for f in res.forms if not f.complete]
1012
+
1013
+ return {
1014
+ "directory": res.root,
1015
+ "files_seen": res.files_seen,
1016
+ "files_parsed": len(res.forms),
1017
+ "files_without_design_block": len(res.no_design),
1018
+ "files_without_design_block_paths": list(res.no_design),
1019
+ "parse_failures": len(res.failures),
1020
+ "parse_failure_details": list(res.failures),
1021
+ "controls_total": len(controls),
1022
+ "controls_with_bounds": sum(1 for c in controls if c.has_bounds),
1023
+ "controls_by_type": dict(sorted(by_type.items(), key=lambda kv: -kv[1])),
1024
+ "forms_with_control_arrays": forms_with_arrays,
1025
+ "control_array_groups": array_groups,
1026
+ "control_array_elements": array_elements,
1027
+ "control_arrays_with_index_gaps": arrays_with_gaps,
1028
+ "sstab_controls": len(sstab_controls),
1029
+ "sstab_forms": len(sstab_forms),
1030
+ "sstab_children": sstab_children,
1031
+ "sstab_pages_with_caption": pages_with_caption,
1032
+ "sstab_pages_without_caption": pages_without_caption,
1033
+ "sstab_selected_page_written": selected_written,
1034
+ "forms_with_client_size": client_sizes,
1035
+ "forms_incomplete": len(incomplete),
1036
+ "incomplete_forms": [(f.path, f.incomplete_reason) for f in incomplete],
1037
+ "captions_containing_replacement_char": replacement_captions,
1038
+ "captions_stored_in_frx": captions_in_frx,
1039
+ "bool_tristate": {
1040
+ name: bool_tri_counts(controls, name)
1041
+ for name in ("Visible", "Enabled", "Locked", "MultiLine")
1042
+ },
1043
+ "warnings": len(res.warnings),
1044
+ "warning_examples": [str(w) for w in res.warnings[:10]],
1045
+ }
1046
+
1047
+
1048
+ def format_summary(summary: dict[str, object]) -> str:
1049
+ """Render :func:`summarize` output as text with denominators on every line."""
1050
+ s = summary
1051
+ lines = [
1052
+ f"directory: {s['directory']}",
1053
+ f"files: {s['files_parsed']} parsed / {s['files_seen']} seen "
1054
+ f"({s['files_without_design_block']} without design block, "
1055
+ f"{s['parse_failures']} failures)",
1056
+ f"controls: {s['controls_total']} "
1057
+ f"({s['controls_with_bounds']} with written Left/Top/Width/Height)",
1058
+ f"forms with control arrays: {s['forms_with_control_arrays']} / {s['files_parsed']}",
1059
+ f"control arrays: {s['control_array_groups']} groups, "
1060
+ f"{s['control_array_elements']} elements "
1061
+ f"({s['control_arrays_with_index_gaps']} with non-contiguous Index)",
1062
+ f"SSTab: {s['sstab_controls']} controls in {s['sstab_forms']} forms, "
1063
+ f"{s['sstab_children']} nested children, pages written: "
1064
+ f"{s['sstab_pages_with_caption']} with caption + "
1065
+ f"{s['sstab_pages_without_caption']} without "
1066
+ f"(Tab= written on {s['sstab_selected_page_written']})",
1067
+ f"forms writing ClientWidth+ClientHeight: {s['forms_with_client_size']} / {s['files_parsed']}",
1068
+ f"incomplete design blocks (no closing End): {s['forms_incomplete']} / {s['files_parsed']}",
1069
+ f"captions containing U+FFFD after CP1254 decode: "
1070
+ f"{s['captions_containing_replacement_char']} / {s['controls_total']}",
1071
+ f"captions stored in .frx (text not in the design block): "
1072
+ f"{s['captions_stored_in_frx']} / {s['controls_total']}",
1073
+ ]
1074
+ tri = s["bool_tristate"]
1075
+ for name in ("Visible", "Enabled", "Locked", "MultiLine"):
1076
+ c = tri[name] # type: ignore[index]
1077
+ lines.append(
1078
+ f" {name}: true={c[TRUE_EXPLICIT]} false={c[FALSE_EXPLICIT]} "
1079
+ f"absent={c[ABSENT]} of {s['controls_total']}"
1080
+ )
1081
+ if s["parse_failures"]:
1082
+ lines.append("parse failures:")
1083
+ lines.extend(f" {p}: {msg}" for p, msg in s["parse_failure_details"]) # type: ignore[union-attr]
1084
+ if s["warnings"]:
1085
+ lines.append(f"warnings: {s['warnings']} (first {min(10, s['warnings'])})")
1086
+ lines.extend(f" {w}" for w in s["warning_examples"]) # type: ignore[union-attr]
1087
+ return "\n".join(lines)
1088
+
1089
+
1090
+ def main(argv: list[str] | None = None) -> int:
1091
+ """``python -m vb6decompiler.frm <dir> [--recursive] [--json]`` — coverage report."""
1092
+ import argparse
1093
+ import json
1094
+
1095
+ ap = argparse.ArgumentParser(description="Parse VB6 .frm design blocks and report coverage.")
1096
+ ap.add_argument("directory", help="directory containing .frm files")
1097
+ ap.add_argument("--recursive", action="store_true", help="walk subdirectories")
1098
+ ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
1099
+ args = ap.parse_args(argv)
1100
+ res = scan_directory(args.directory, recursive=args.recursive)
1101
+ s = summarize(res)
1102
+ print(json.dumps(s, indent=1, ensure_ascii=False) if args.json else format_summary(s))
1103
+ return 0 if not res.failures else 1
1104
+
1105
+
1106
+ if __name__ == "__main__": # pragma: no cover
1107
+ raise SystemExit(main())