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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
opencrbasic/helpdocs.py
ADDED
|
@@ -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)
|