opencrbasic 1.0.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.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,677 @@
1
+ """Instruction help read at run time from a user-configured local help directory.
2
+
3
+ The directory is a local copy of the logger vendor's HTML help (`Instructions/*.htm`,
4
+ `parameters/*.htm`). Nothing from it is stored in this package; the provider only parses pages
5
+ the user points it at (`docs/legal.md`, `docs/lsp-api.md` "Help provider"). Help parameter names
6
+ and prose are display information only: they never influence checker rules.
7
+ """
8
+
9
+ import re
10
+ from collections.abc import Iterable, Mapping, Sequence
11
+ from dataclasses import dataclass, field, replace
12
+ from html.parser import HTMLParser
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ __all__ = [
17
+ "ParamHelp",
18
+ "InstructionHelp",
19
+ "HelpProvider",
20
+ "PairedParam",
21
+ "PairedSignature",
22
+ "pair_signature",
23
+ "render_markdown",
24
+ "render_plain",
25
+ "render_signature_label",
26
+ "CoverageReport",
27
+ "coverage_report",
28
+ "FunctionCoverageReport",
29
+ "function_coverage_report",
30
+ "ArgCountRow",
31
+ "function_arg_count_classes",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ParamHelp:
37
+ name: str
38
+ link: str | None # target of the syntax link, as written (e.g. "../parameters/x.htm")
39
+ description: str # plain text; "" when the help has none
40
+ optional: bool = False # True only where the syntax line states it; False means "not stated"
41
+ repeatable: bool = False # `Item1, Item2, Item3...` collapsed to one `Item`; name is the stem
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class InstructionHelp:
46
+ name: str # the looked-up upper-case name
47
+ names: tuple[str, ...] # every upper-case name the page documents
48
+ title: str
49
+ syntax: str # plain text of the syntax form for `name` ("" when the page has none)
50
+ params: tuple[ParamHelp, ...]
51
+ summary: str
52
+ source_path: Path
53
+
54
+
55
+ class HelpLookup(Protocol):
56
+ """The help lookup interface used by hover and signature rendering."""
57
+
58
+ def lookup(self, name: str) -> InstructionHelp | None: ...
59
+
60
+
61
+ # --- HTML reduction -------------------------------------------------------------------------------
62
+
63
+ _BLOCK_TAGS = {"h1", "h2", "h3", "h4", "p", "li", "table", "pre"}
64
+ _HEADINGS = {"h1", "h2", "h3", "h4"}
65
+ _SKIP = {"script", "style"}
66
+
67
+
68
+ @dataclass
69
+ class _Anchor:
70
+ href: str
71
+ parts: list[str] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class _Block:
76
+ tag: str
77
+ text: str
78
+ anchors: list[tuple[int, int, str, str]] # (start, end, href, text) offsets into `text`
79
+
80
+
81
+ class _Reducer(HTMLParser):
82
+ """Collects headings, paragraphs and table rows as plain text; anchors keep their offsets."""
83
+
84
+ def __init__(self) -> None:
85
+ super().__init__(convert_charrefs=True)
86
+ self.blocks: list[_Block] = []
87
+ self._tag: str | None = None
88
+ self._buf: list[str] = []
89
+ self._len = 0
90
+ self._anchors: list[tuple[int, int, str, str]] = []
91
+ self._open: list[tuple[int, str]] = [] # (start offset, href) of open anchors
92
+ self._table = 0
93
+ self._skip = 0
94
+
95
+ def _emit(self, s: str) -> None:
96
+ self._buf.append(s)
97
+ self._len += len(s)
98
+
99
+ def _close(self) -> None:
100
+ if self._tag is not None:
101
+ text = "".join(self._buf)
102
+ self.blocks.append(_Block(self._tag, text, self._anchors))
103
+ self._tag, self._buf, self._len, self._anchors, self._open = None, [], 0, [], []
104
+
105
+ def handle_starttag(self, tag, attrs):
106
+ if tag in _SKIP:
107
+ self._skip += 1
108
+ elif tag == "table":
109
+ self._close()
110
+ self._table += 1
111
+ elif self._table and tag == "tr":
112
+ self._close()
113
+ self._tag = "tr"
114
+ elif self._table and tag in ("td", "th"):
115
+ if _flat("".join(self._buf)):
116
+ self._emit(" | ")
117
+ elif tag in ("p", "h1", "h2", "h3", "h4"):
118
+ if not self._table:
119
+ self._close()
120
+ self._tag = tag
121
+ elif tag == "br":
122
+ if self._tag is not None:
123
+ self._emit("\n")
124
+ elif tag == "a" and self._tag is not None:
125
+ self._open.append((self._len, dict(attrs).get("href") or ""))
126
+
127
+ def handle_startendtag(self, tag, attrs):
128
+ if tag == "br":
129
+ self.handle_starttag(tag, attrs)
130
+
131
+ def handle_endtag(self, tag):
132
+ if tag in _SKIP:
133
+ self._skip = max(0, self._skip - 1)
134
+ elif tag == "table":
135
+ self._close()
136
+ self._table = max(0, self._table - 1)
137
+ elif self._table and tag == "tr":
138
+ self._close()
139
+ elif tag in ("p", "h1", "h2", "h3", "h4"):
140
+ if not self._table:
141
+ self._close()
142
+ elif tag == "a" and self._open:
143
+ start, href = self._open.pop()
144
+ self._anchors.append((start, self._len, href, "".join(self._buf)[start : self._len]))
145
+
146
+ def handle_data(self, data):
147
+ if self._tag is not None and not self._skip:
148
+ self._emit(data)
149
+
150
+ def close(self):
151
+ super().close()
152
+ self._close()
153
+
154
+
155
+ def _collapse(text: str) -> str:
156
+ return re.sub(r"[ \t\r\f\v ]+", " ", re.sub(r"\s*\n\s*", "\n", text.replace(" ", " "))).strip()
157
+
158
+
159
+ def _flat(text: str) -> str:
160
+ return re.sub(r"\s+", " ", text.replace(" ", " ")).strip()
161
+
162
+
163
+ def _blocks(html: str) -> list[_Block]:
164
+ r = _Reducer()
165
+ r.feed(html)
166
+ r.close()
167
+ return r.blocks
168
+
169
+
170
+ # --- page parsing ----------------------------------------------------------------------------------
171
+
172
+ _FORM = re.compile(r"^\s*(?:[A-Za-z_][\w.]*\s*=\s*)?([A-Za-z_]\w*)\s*\(")
173
+ _OPTIONAL = re.compile(r"[\[(]\s*optional\s*[\])]", re.I)
174
+ _NAME = re.compile(r"^[A-Za-z_]\w*$")
175
+ # Brackets around exactly one name: `[ModbusOption]`, `[ Dimension ]`; `/` joins alternative names.
176
+ _BARE = re.compile(r"\[\s*([A-Za-z_][\w/]*)\s*\]")
177
+ # A parenthesised note after a bare statement name: `EndSequence (optional)`, `Data (or DataLong)`.
178
+ _NOTE = re.compile(r"^\s*(?:optional|or\s+[A-Za-z_]\w*)\s*$", re.I)
179
+
180
+
181
+ def _close(text: str, start: int) -> int:
182
+ """Index of the ')' closing a '(' that precedes `start`; len(text) when unclosed."""
183
+ depth = 1
184
+ for i in range(start, len(text)):
185
+ if text[i] == "(":
186
+ depth += 1
187
+ elif text[i] == ")":
188
+ depth -= 1
189
+ if depth == 0:
190
+ return i
191
+ return len(text)
192
+
193
+
194
+ def _spans(text: str, start: int, end: int, link_starts: set[int]) -> list[tuple[int, int]]:
195
+ """Parameter spans of text[start:end].
196
+
197
+ A span ends at a top-level ',' or ';', or where a parameter link starts after other name text
198
+ (a separator the help omitted, e.g. `Count </a>TotalCalls`). Separators inside (), [] or double
199
+ quotes do not split.
200
+ """
201
+ out: list[tuple[int, int]] = []
202
+ depth = 0
203
+ quoted = False
204
+ piece = start
205
+ for i in range(start, end):
206
+ if i in link_starts and depth == 0 and not quoted and _bare(text[piece:i]):
207
+ out.append((piece, i))
208
+ piece = i
209
+ ch = text[i]
210
+ if ch == '"':
211
+ quoted = not quoted
212
+ elif not quoted:
213
+ if ch in "([":
214
+ depth += 1
215
+ elif ch in ")]":
216
+ depth = max(0, depth - 1)
217
+ elif ch in ",;" and depth == 0:
218
+ out.append((piece, i))
219
+ piece = i + 1
220
+ out.append((piece, end))
221
+ return out
222
+
223
+
224
+ def _bare(raw: str) -> str:
225
+ """Name text of a span with optional markers and bracket notation removed ('' if none)."""
226
+ return re.sub(r"[\s\[\]]", "", _OPTIONAL.sub("", raw))
227
+
228
+
229
+ @dataclass
230
+ class _Form:
231
+ name: str
232
+ text: str
233
+ params: list[ParamHelp]
234
+
235
+
236
+ def _fold_repeat(params: list[ParamHelp]) -> list[ParamHelp]:
237
+ """A trailing `...` on the last name makes one repeatable parameter. A numbered run ending in
238
+ that name (`Item1, Item2, Item3...`) collapses into one parameter named after the stem (`Item`).
239
+ """
240
+ if not params or not params[-1].name.endswith("..."):
241
+ return params
242
+ last = params[-1]
243
+ stem = last.name[:-3].rstrip()
244
+ m = re.fullmatch(r"(.*?)(\d+)", stem)
245
+ base = m.group(1) if m else stem
246
+ if not _NAME.match(base):
247
+ return params
248
+ run = [last]
249
+ rest = list(params[:-1])
250
+ if m:
251
+ numbered = re.compile(re.escape(base) + r"\d+")
252
+ while rest and numbered.fullmatch(rest[-1].name):
253
+ run.insert(0, rest.pop())
254
+ return rest + [ParamHelp(base, run[0].link, "", any(p.optional for p in run), True)]
255
+
256
+
257
+ def _parse_form(block: _Block) -> _Form | None:
258
+ text = block.text
259
+ m = _FORM.match(text)
260
+ if not m:
261
+ return None
262
+ flat = _flat(text)
263
+ start = m.end()
264
+ end = _close(text, start)
265
+ params: list[ParamHelp] = []
266
+ if not _NOTE.match(text[start:end]):
267
+ links = [a for a in sorted(block.anchors) if "parameters/" in a[2].lower().replace("\\", "/")]
268
+ links = [a for a in links if not any(o is not a and a[0] <= o[0] and o[1] <= a[1] for o in links)]
269
+ # Every span of the syntax line is a parameter, linked or not. A span takes the parameter
270
+ # link that overlaps it: `Mult, Offse</a>t` is one link covering two names, and `Mult, Offset`
271
+ # with a link per name is two spans over two links.
272
+ previous_named = False
273
+ for a, b in _spans(text, start, end, {s for s, _, _, _ in links}):
274
+ raw = text[a:b]
275
+ body = _flat(_OPTIONAL.sub("", raw)).strip(" ,;")
276
+ bare = _BARE.fullmatch(body)
277
+ if bare:
278
+ # `[X]` around exactly one name: optional, brackets dropped.
279
+ name, optional = bare.group(1), True
280
+ else:
281
+ # Other brackets stay as written (`Mode [A,B,C]`) and never set optional.
282
+ name, optional = body, bool(_OPTIONAL.search(raw))
283
+ if name:
284
+ # The link whose overlap with this span holds name text (not just a separator).
285
+ href = next(
286
+ (h for s, e, h, _ in links if s < b and a < e and re.search(r"\w", text[max(s, a) : min(e, b)])),
287
+ None,
288
+ )
289
+ params.append(ParamHelp(name, href, "", optional))
290
+ elif previous_named and _OPTIONAL.search(raw):
291
+ # `FileOption, [optional],`: a marker that is a list item of its own applies to the
292
+ # parameter before it and creates no parameter.
293
+ params[-1] = replace(params[-1], optional=True)
294
+ previous_named = bool(name)
295
+ return _Form(m.group(1).upper(), flat, _fold_repeat(params))
296
+
297
+
298
+ def _norm(s: str) -> str:
299
+ return re.sub(r"[^a-z0-9]", "", s.lower())
300
+
301
+
302
+ def _heading_key(text: str) -> tuple[str, ...]:
303
+ """Normalised candidates a parameter heading answers to: the text before ` (`, and each
304
+ comma-separated piece of it."""
305
+ head = _flat(text).split("(")[0]
306
+ keys = [_norm(head)] + [_norm(p) for p in head.split(",")]
307
+ return tuple(k for k in keys if k)
308
+
309
+
310
+ def _description_with_rows(blocks: Sequence[_Block], index: int) -> str:
311
+ parts = [_collapse(blocks[index].text)]
312
+ for block in blocks[index + 1 :]:
313
+ if block.tag != "tr":
314
+ break
315
+ if text := _flat(block.text):
316
+ parts.append(text)
317
+ return "\n".join(parts)
318
+
319
+
320
+ def _describe(blocks: Sequence[_Block], name: str) -> str:
321
+ """Description paragraph under the heading paragraph answering to `name`, or ''."""
322
+ want = _norm(name)
323
+ if not want:
324
+ return ""
325
+ for i, b in enumerate(blocks):
326
+ if b.tag != "p" or "\n" in b.text.strip() or len(b.text) > 120:
327
+ continue
328
+ if want in _heading_key(b.text) and i + 1 < len(blocks):
329
+ for j in range(i + 1, min(i + 3, len(blocks))):
330
+ nxt = blocks[j]
331
+ t = _collapse(nxt.text)
332
+ if nxt.tag == "p" and t and not t.lower().startswith("type:"):
333
+ return _description_with_rows(blocks, j)
334
+ return ""
335
+ return ""
336
+
337
+
338
+ @dataclass
339
+ class _Page:
340
+ path: Path
341
+ title: str
342
+ summary: str
343
+ names: tuple[str, ...]
344
+ forms: dict[str, _Form]
345
+ param_blocks: list[_Block]
346
+ html_ok: bool = True
347
+
348
+
349
+ def _parse_page(path: Path) -> _Page | None:
350
+ try:
351
+ html = path.read_text(encoding="utf-8", errors="replace")
352
+ blocks = _blocks(html)
353
+ h1 = next((b for b in blocks if b.tag == "h1"), None)
354
+ syn = next((i for i, b in enumerate(blocks) if b.tag == "h2" and _flat(b.text).lower() == "syntax"), None)
355
+ if h1 is None or syn is None:
356
+ return None
357
+ title = _flat(h1.text)
358
+ names: list[str] = []
359
+ for part in title.split("(")[0].split("/"):
360
+ part = part.strip()
361
+ if _NAME.match(part):
362
+ names.append(part.upper())
363
+ forms: dict[str, _Form] = {}
364
+ for b in blocks[syn + 1 :]:
365
+ if b.tag in ("h2", "h3"):
366
+ break
367
+ if b.tag != "p":
368
+ continue
369
+ if _flat(b.text).lower().startswith("example"):
370
+ break
371
+ f = _parse_form(b)
372
+ if f is not None:
373
+ forms.setdefault(f.name, f)
374
+ if f.name not in names:
375
+ names.append(f.name)
376
+ if not names:
377
+ return None
378
+ h1i = blocks.index(h1)
379
+ summary = ""
380
+ for b in blocks[h1i + 1 : syn]:
381
+ t = _collapse(b.text)
382
+ if b.tag == "p" and t:
383
+ summary = t
384
+ break
385
+ pi = next((i for i, b in enumerate(blocks) if b.tag == "h2" and _flat(b.text).lower() == "parameters"), None)
386
+ pblocks: list[_Block] = []
387
+ if pi is not None:
388
+ for b in blocks[pi + 1 :]:
389
+ if b.tag == "h2":
390
+ break
391
+ pblocks.append(b)
392
+ return _Page(path, title, summary, tuple(names), forms, pblocks)
393
+ except Exception:
394
+ return None
395
+
396
+
397
+ # --- provider --------------------------------------------------------------------------------------
398
+
399
+
400
+ class HelpProvider:
401
+ """Lazy index over `<directory>/Instructions/*.htm`; lookups never raise."""
402
+
403
+ def __init__(self, directory: str | Path):
404
+ self.directory = Path(directory)
405
+ self._pages: dict[str, _Page] | None = None
406
+ self._cache: dict[str, InstructionHelp | None] = {}
407
+
408
+ def _index(self) -> dict[str, _Page]:
409
+ if self._pages is None:
410
+ pages: dict[str, _Page] = {}
411
+ try:
412
+ files = sorted((self.directory / "Instructions").glob("*.htm"))
413
+ except OSError:
414
+ files = []
415
+ for f in files:
416
+ page = _parse_page(f)
417
+ if page is not None:
418
+ for n in page.names:
419
+ pages.setdefault(n, page)
420
+ self._pages = pages
421
+ return self._pages
422
+
423
+ def names(self) -> tuple[str, ...]:
424
+ """Upper-case names that have a parsable help page."""
425
+ return tuple(sorted(self._index()))
426
+
427
+ def lookup(self, name: str) -> InstructionHelp | None:
428
+ key = name.upper()
429
+ if key not in self._cache:
430
+ try:
431
+ self._cache[key] = self._build(key)
432
+ except Exception:
433
+ self._cache[key] = None
434
+ return self._cache[key]
435
+
436
+ def _build(self, key: str) -> InstructionHelp | None:
437
+ page = self._index().get(key)
438
+ if page is None:
439
+ return None
440
+ form = page.forms.get(key)
441
+ params: list[ParamHelp] = []
442
+ for p in form.params if form else []:
443
+ desc = _describe(page.param_blocks, p.name)
444
+ if not desc and p.link:
445
+ desc = self._linked_description(p.link, p.name)
446
+ params.append(ParamHelp(p.name, p.link, desc, p.optional, p.repeatable))
447
+ return InstructionHelp(
448
+ key, page.names, page.title, form.text if form else "", tuple(params), page.summary, page.path
449
+ )
450
+
451
+ def _linked_description(self, link: str, name: str) -> str:
452
+ target = (self.directory / "Instructions" / link.replace("\\", "/")).resolve()
453
+ try:
454
+ if not target.is_file() or self.directory.resolve() not in target.parents:
455
+ return ""
456
+ blocks = _blocks(target.read_text(encoding="utf-8", errors="replace"))
457
+ except OSError, ValueError:
458
+ return ""
459
+ for i, nxt in enumerate(blocks[1:3], start=1):
460
+ t = _collapse(nxt.text)
461
+ if nxt.tag == "p" and t and not t.lower().startswith("type:"):
462
+ return _description_with_rows(blocks, i)
463
+ return ""
464
+
465
+
466
+ # --- pairing with catalog parameter lists ----------------------------------------------------------
467
+
468
+
469
+ @dataclass(frozen=True)
470
+ class PairedParam:
471
+ label: str # help name, with "..." when repeatable; `argN` when unpaired
472
+ type_code: int
473
+ description: str
474
+ optional: bool
475
+ from_help: bool
476
+ repeatable: bool = False
477
+
478
+
479
+ @dataclass(frozen=True)
480
+ class PairedSignature:
481
+ name: str
482
+ matched: bool # help parameter count equals the catalog count
483
+ help_count: int | None # None: no help
484
+ catalog_count: int
485
+ params: tuple[PairedParam, ...] # friendly when matched; otherwise catalog positions only
486
+ note: str # "" when matched; the mismatch reason otherwise
487
+
488
+
489
+ def pair_signature(name: str, type_codes: Sequence[int], help: InstructionHelp | None) -> PairedSignature:
490
+ """Pair help names with catalog positions only when the counts agree; never guess otherwise."""
491
+ n = len(type_codes)
492
+ if help is None:
493
+ return PairedSignature(
494
+ name,
495
+ False,
496
+ None,
497
+ n,
498
+ tuple(PairedParam(f"arg{i + 1}", t, "", False, False) for i, t in enumerate(type_codes)),
499
+ "no help",
500
+ )
501
+ h = len(help.params)
502
+ if h == n:
503
+ return PairedSignature(
504
+ name,
505
+ True,
506
+ h,
507
+ n,
508
+ tuple(
509
+ PairedParam(
510
+ p.name + "..." if p.repeatable else p.name, t, p.description, p.optional, True, p.repeatable
511
+ )
512
+ for p, t in zip(help.params, type_codes)
513
+ ),
514
+ "",
515
+ )
516
+ return PairedSignature(
517
+ name,
518
+ False,
519
+ h,
520
+ n,
521
+ tuple(PairedParam(f"arg{i + 1}", t, "", False, False) for i, t in enumerate(type_codes)),
522
+ f"help lists {h} parameters, catalog has {n}",
523
+ )
524
+
525
+
526
+ # --- rendering (separate from parsing) -------------------------------------------------------------
527
+
528
+
529
+ def render_signature_label(sig: PairedSignature) -> str:
530
+ inner = ", ".join(p.label + (" (optional)" if p.optional else "") for p in sig.params)
531
+ return f"{sig.name}({inner})"
532
+
533
+
534
+ def render_plain(help: InstructionHelp, sig: PairedSignature | None = None) -> str:
535
+ lines = [help.title, ""]
536
+ lines.append(render_signature_label(sig) if sig is not None and sig.matched else help.syntax)
537
+ if help.summary:
538
+ lines += ["", help.summary]
539
+ if sig is not None and sig.matched:
540
+ for p in sig.params:
541
+ if p.description:
542
+ lines.append(f"{p.label}: {p.description}")
543
+ return "\n".join(lines).strip()
544
+
545
+
546
+ _MD_SPECIAL = re.compile(r"([\\`*_{}\[\]<>#|])")
547
+
548
+
549
+ def _md(text: str) -> str:
550
+ return _MD_SPECIAL.sub(r"\\\1", text)
551
+
552
+
553
+ def render_markdown(help: InstructionHelp, sig: PairedSignature | None = None) -> str:
554
+ syntax = render_signature_label(sig) if sig is not None and sig.matched else help.syntax
555
+ out = [f"**{_md(help.title)}**", "", "```crbasic", syntax.replace("```", "'''"), "```"]
556
+ if help.summary:
557
+ out += ["", _md(help.summary)]
558
+ if sig is not None and sig.matched:
559
+ rows = [f"- `{p.label}`: {_md(p.description)}" for p in sig.params if p.description]
560
+ if rows:
561
+ out += [""] + rows
562
+ return "\n".join(out)
563
+
564
+
565
+ # --- coverage --------------------------------------------------------------------------------------
566
+
567
+
568
+ @dataclass(frozen=True)
569
+ class CoverageReport:
570
+ total: int
571
+ with_help: int
572
+ matching: int
573
+ mismatches: tuple[tuple[str, int, int], ...] # (name, help parameter count, catalog count)
574
+ without_help: tuple[str, ...]
575
+
576
+
577
+ def coverage_report(profile, provider: HelpProvider, names: Iterable[str] | None = None) -> CoverageReport:
578
+ """Documentation coverage for `profile.instructions` (separate from compiler-rule coverage)."""
579
+ table: Mapping[str, Mapping] = profile.instructions
580
+ wanted = sorted(table) if names is None else sorted(n.upper() for n in names if n.upper() in table)
581
+ with_help = 0
582
+ mism: list[tuple[str, int, int]] = []
583
+ missing: list[str] = []
584
+ for n in wanted:
585
+ h = provider.lookup(n)
586
+ if h is None:
587
+ missing.append(n)
588
+ continue
589
+ with_help += 1
590
+ sig = pair_signature(n, table[n]["params"], h)
591
+ if not sig.matched:
592
+ mism.append((n, len(h.params), len(table[n]["params"])))
593
+ return CoverageReport(len(wanted), with_help, with_help - len(mism), tuple(mism), tuple(missing))
594
+
595
+
596
+ @dataclass(frozen=True)
597
+ class FunctionCoverageReport:
598
+ total: int # rows of `profile.functions` considered
599
+ with_nargs: int # rows whose function table has `nargs`
600
+ with_help: int # rows with a parsable help page
601
+ checked: int # rows with help and `nargs`: count compared
602
+ matching: int # checked rows whose help count equals nargs
603
+ mismatches: tuple[tuple[str, int, int], ...] # (name, help parameter count, nargs)
604
+ without_help: tuple[str, ...]
605
+ unchecked: tuple[str, ...] # help but no `nargs`: nothing to compare with
606
+
607
+
608
+ def function_coverage_report(
609
+ profile, provider: HelpProvider, names: Iterable[str] | None = None
610
+ ) -> FunctionCoverageReport:
611
+ """Documentation coverage for `profile.functions`, separate from `coverage_report` (instructions)
612
+ and from compiler-rule coverage. Only rows with `nargs` are compared, by parameter count."""
613
+ table: Mapping[str, Mapping] = profile.functions
614
+ wanted = sorted(table) if names is None else sorted(n.upper() for n in names if n.upper() in table)
615
+ with_nargs = with_help = 0
616
+ mism: list[tuple[str, int, int]] = []
617
+ missing: list[str] = []
618
+ unchecked: list[str] = []
619
+ for n in wanted:
620
+ nargs = table[n].get("nargs")
621
+ if nargs is not None:
622
+ with_nargs += 1
623
+ h = provider.lookup(n)
624
+ if h is None:
625
+ missing.append(n)
626
+ continue
627
+ with_help += 1
628
+ if nargs is None:
629
+ unchecked.append(n)
630
+ elif len(h.params) != nargs:
631
+ mism.append((n, len(h.params), nargs))
632
+ checked = with_help - len(unchecked)
633
+ return FunctionCoverageReport(
634
+ len(wanted), with_nargs, with_help, checked, checked - len(mism), tuple(mism), tuple(missing), tuple(unchecked)
635
+ )
636
+
637
+
638
+ # `nargs` 6 is the 16-bit `argspec` maximum (low nibble 6, 2 bits per argument): docs/binary.md.
639
+ _PACKING_CAP = 6
640
+
641
+
642
+ @dataclass(frozen=True)
643
+ class ArgCountRow:
644
+ name: str
645
+ help_count: int
646
+ help_required: int # help parameters not marked optional
647
+ nargs: int
648
+ category: str # "packing_cap" | "optional_tail" | "other"
649
+
650
+
651
+ def function_arg_count_classes(
652
+ profile, provider: HelpProvider, names: Iterable[str] | None = None
653
+ ) -> tuple[ArgCountRow, ...]:
654
+ """Classify `profile.functions` whose help parameter count differs from `nargs`.
655
+
656
+ `packing_cap`: `nargs` is 6, the packed `argspec` maximum, so the table cannot list more.
657
+ `optional_tail`: not capped, and the help's required count equals `nargs` (the rest is optional).
658
+ `other`: neither. A classification only; it says nothing about which counts the compiler accepts.
659
+ """
660
+ table: Mapping[str, Mapping] = profile.functions
661
+ wanted = sorted(table) if names is None else sorted(n.upper() for n in names if n.upper() in table)
662
+ rows: list[ArgCountRow] = []
663
+ for n in wanted:
664
+ nargs = table[n].get("nargs")
665
+ h = provider.lookup(n)
666
+ if nargs is None or h is None or len(h.params) == nargs:
667
+ continue
668
+ required = sum(1 for p in h.params if not p.optional)
669
+ cat = (
670
+ "packing_cap"
671
+ if nargs == _PACKING_CAP
672
+ else "optional_tail"
673
+ if required == nargs and len(h.params) > nargs
674
+ else "other"
675
+ )
676
+ rows.append(ArgCountRow(n, len(h.params), required, nargs, cat))
677
+ return tuple(rows)