3"""The register symbol table the Hardware User's Manual actually publishes.
5``cite_check.py`` answers "is this citation well-formed, and does it name a
6real chapter and a page inside that chapter?". It cannot answer "does the
7cited text describe the register being touched?", and it cannot answer "does
8this register exist at all?". Three landed defects lived in that gap, each
9carrying a citation naming a REAL chapter and a REAL page:
11* the whole ``ra8_rsip`` crypto family addressed registers HUM Ch 52 does not
12 publish -- that chapter is six pages long and describes no registers at all;
13* ``ra8_ptp_regs.h`` declared a thirteen-register window at ``0x403E_0100``,
14 a reserved hole in the GPTP aperture, and the demo printed ``clock PASS``
15 because a reserved aperture echoed back its own writes (#498);
16* ``ra8_etha_regs.h`` declared ``EASCR`` at ``0x0580``, which appears nowhere
17 in HUM Ch 32, and ~26 ETHA citations pointed at real pages describing other
20Nothing mechanically connected a register SYMBOL in our headers to the symbol
21table the manual publishes. This module is that connection: it parses the
22per-register description subsections out of the committed manual PDF, which
23have a rigidly regular shape --
25 32.3.2.6 EATMFSCq : Transmission Maximum Frame Size Configuration ...
26 Base address: ETHAm = 0x403C_A000 + 0x2000 x m (m = 0, 1)
27 Offset address: 0x0040 + 0x4 x q
29-- yielding ``(chapter, section, symbol, offset, page, name)`` for every
30register in the manual. That is a strictly stronger source than the chapter
31register-list tables (Table X.Y), because the subsection heading carries the
32page number the register is actually described on, which is exactly what the
33"plausible but false citation" defects got wrong.
35Two properties this module is built around, both distilled by the #190 audit
36and both easy to get wrong here:
38* **No check may compare a constant to itself.** A committed snapshot of the
39 extracted names, diffed against a committed header, proves nothing -- both
40 sides are ours to edit. So the authority is always the PDF; the committed
41 CSV is a reviewable convenience whose freshness is separately proven by
43* **Every scan needs a vacuity floor.** A PDF text extraction that silently
44 yielded zero rows would report every register in the tree clean, forever.
45 That is the single most likely failure mode here -- a poppler upgrade, a
46 re-typeset manual revision, a changed heading style -- so
47 :func:`assert_not_vacuous` is a hard, absolute floor that runs before any
48 caller is allowed to use the result.
51from __future__
import annotations
58from dataclasses
import dataclass, replace
59from pathlib
import Path
61REPO_ROOT = Path(__file__).resolve().parents[2]
64HUM_PDF = REPO_ROOT /
"docs" /
"reference" /
"ra8d2-hardware-user-manual.pdf"
67HUM_CSV = REPO_ROOT /
"docs" /
"reference" /
"HUM_REGISTERS.csv"
99HEADING_RE = re.compile(
100 r"^\s*(?P<chapter>\d{1,2})\.(?P<section>\d{1,3}(?:\.\d{1,3})*)"
101 r"\s{2,}(?P<symbol>[A-Za-z][A-Za-z0-9_]{1,23}"
102 r"(?:\s*[/,]\s*[A-Za-z][A-Za-z0-9_]{1,23})*)"
103 r"\s*:\s*(?P<name>[A-Za-z(]\S*(?:\s+\S+)*?)\s*$"
107ALTERNATE_SEP_RE = re.compile(
r"\s*[/,]\s*")
113BASE_OFFSET_RE = re.compile(
r"^(0[xX][0-9A-Fa-f_]+)")
118OFFSET_RE = re.compile(
r"^\s*Offset address\s*:\s*(?P<expr>\S.*?)\s*$")
132SECTION_RE = re.compile(
r"^\s*(?P<chapter>\d{1,2})\.(?P<section>\d{1,3}(?:\.\d{1,3})*)\s{2,}\S")
139MIN_UPPERCASE_IN_SYMBOL = 2
142MAX_ASCII_CODEPOINT = 127
145def _looks_like_register_symbol(symbol: str) -> bool:
146 """True when `symbol` has the shape of a HUM register abbreviation."""
147 upper = sum(1
for ch
in symbol
if ch.isupper())
148 lower = sum(1
for ch
in symbol
if ch.islower())
149 return upper >= MIN_UPPERCASE_IN_SYMBOL
and upper > lower
156TOC_LEADER_RE = re.compile(
r"\.{4,}\s*\d{1,5}\s*$")
161FOOTER_RE = re.compile(
r"Page\s+(?P<page>\d{1,5})\s+of\s+\d{4,5}")
165OFFSET_LOOKAHEAD_LINES = 14
173ASCII_FOLD = {
"\u00d7":
"*",
"\u00b5":
"u"}
183MIN_CHAPTERS_WITH_REGISTERS = 50
184MIN_ROWS_WITH_OFFSET = 1200
187class HumExtractionError(RuntimeError):
188 """The manual could not be turned into a usable register symbol table."""
191@dataclass(frozen=
True)
193 """One register as the Hardware User's Manual describes it.
196 chapter: HUM chapter number the register is described in.
197 section: Full subsection number, e.g. ``3.2.6`` within that chapter.
198 symbol: Register abbreviation exactly as printed, index letter and
199 all (``EATMFSCq``, ``PTPTIVCt``, ``BCNTnAER``).
200 offset: Base byte offset as printed, e.g. ``0x0040``; empty when the
201 register is documented by absolute address rather than offset.
202 offset_expr: The full offset expression including any array stride,
203 e.g. ``0x0040 + 0x4 * q``; empty alongside an empty ``offset``.
204 page: Printed page the register description begins on.
205 page_end: Last page the description can still run onto -- the page of
206 the next section heading. Bit-field tables routinely spill onto
207 the following page, so a citation anywhere in ``page..page_end``
208 is pointing at this register's description.
209 name: The register's full name as printed.
221 def covers(self, first: int, last: int) -> bool:
222 """True when the cited page span overlaps this description's pages."""
223 return first <= self.page_end
and last >= self.page
226 def canonical(self) -> str:
227 """The symbol with index letters removed, for matching against C code."""
228 return canonical_symbol(self.symbol)
231def canonical_symbol(symbol: str) -> str:
232 """Reduce a register abbreviation to the form a C header would spell.
234 The manual writes array registers with a lowercase index letter wherever
235 it reads best -- ``EATMFSCq``, ``IPCSEMn``, ``BCNTnAER``, ``PDCFCH00RCHn``
236 -- while a C header names the array once (``EATMFSC[8]``). Since every
237 genuine abbreviation is uppercase and digits, dropping every lowercase
238 letter normalises both spellings onto one key. Across the whole manual
239 this collides for exactly five symbols, and in each case the colliding
240 pair is the same register written with two different index letters
241 (``PVDmCR0`` / ``PVDnCR0``), so no distinct register is masked.
243 The strip happens BEFORE the upper-case fold, not after: folding first
244 would leave no lowercase letters to remove and quietly turn this into the
245 identity function, so ``EATMFSCq`` and ``EATMFSC`` would stop matching.
246 Callers holding a name in some other case (an offset enum suffix, say)
247 must upper-case it themselves before calling.
249 return re.sub(
r"[a-z]",
"", symbol).upper()
252def _fold_ascii(text: str, context: str) -> str:
253 """Return `text` as 7-bit ASCII, or raise if it holds an unknown character."""
254 out =
"".join(ASCII_FOLD.get(ch, ch)
for ch
in text)
255 bad = sorted({ch
for ch
in out
if ord(ch) > MAX_ASCII_CODEPOINT})
257 codes =
", ".join(f
"U+{ord(ch):04X}" for ch
in bad)
259 f
"non-ASCII character(s) {codes} in {context!r}; add a transliteration "
260 f
"to hum_regmap.ASCII_FOLD rather than dropping the character"
262 raise HumExtractionError(msg)
266def _pdftotext(pdf: Path) -> str:
267 """Run ``pdftotext -layout`` over the manual and return its text.
270 HumExtractionError: pdftotext is missing, the PDF is absent, or the
271 extraction failed. A gate that skipped here would report every
274 if shutil.which(
"pdftotext")
is None:
275 msg =
"pdftotext not found on PATH (install poppler-utils)"
276 raise HumExtractionError(msg)
277 if not pdf.is_file():
278 msg = f
"manual PDF not found: {pdf}"
279 raise HumExtractionError(msg)
280 result = subprocess.run(
281 [
"pdftotext",
"-layout", str(pdf),
"-"],
285 if result.returncode != 0:
286 detail = result.stderr.decode(
"utf-8",
"replace").strip()
287 msg = f
"pdftotext failed on {pdf.name}: {detail}"
288 raise HumExtractionError(msg)
289 return result.stdout.decode(
"utf-8",
"replace")
292def _find_offset(lines: list[str], start: int) -> str:
293 """Return the offset expression printed below the heading at `start`."""
294 limit =
min(start + OFFSET_LOOKAHEAD_LINES, len(lines))
295 for index
in range(start + 1, limit):
296 match = OFFSET_RE.match(lines[index])
297 if match
is not None:
298 return match.group(
"expr")
299 if HEADING_RE.match(lines[index])
is not None:
304def _join_wrapped(lines: list[str], index: int) -> str:
305 """Return the heading's register name, re-joining a typeset line wrap.
307 A long heading wraps mid-name -- "... Configuration Register q (q = 0"
308 then "to 7)" on the next line. An unbalanced parenthesis is the reliable
309 tell, and pulling in the continuation keeps the committed name readable
310 instead of truncated.
312 match = HEADING_RE.match(lines[index])
313 name =
"" if match
is None else match.group(
"name")
314 if name.count(
"(") <= name.count(
")")
or index + 1 >= len(lines):
316 continuation = lines[index + 1].strip()
317 if not continuation
or HEADING_RE.match(lines[index + 1])
is not None:
319 return f
"{name} {continuation}"
322def _heading_rows(lines: list[str], index: int, page_number: int) -> list[HumRegister]:
323 """Build the register rows a single heading line declares, if any.
325 A heading may name several access widths of one register at once
326 (``TDR/TDRLL/TDRLH``); each alternate becomes its own row sharing the
327 section, offset and page. ``page_end`` is filled in later, once the next
328 section boundary is known.
330 match = HEADING_RE.match(lines[index])
331 if match
is None or TOC_LEADER_RE.search(match.group(
"name")):
333 if int(match.group(
"chapter")) < 1:
335 parts = ALTERNATE_SEP_RE.split(match.group(
"symbol"))
336 alternates = [s
for s
in parts
if _looks_like_register_symbol(s)]
339 expr = _fold_ascii(_find_offset(lines, index), f
"offset of {alternates[0]}")
340 base_match = BASE_OFFSET_RE.match(expr)
341 base = base_match.group(1)
if base_match
else ""
342 name = _fold_ascii(_join_wrapped(lines, index), f
"name of {alternates[0]}")
345 chapter=int(match.group(
"chapter")),
346 section=match.group(
"section"),
351 page_end=page_number,
354 for symbol
in alternates
358def _scan_text(text: str) -> list[HumRegister]:
359 """Walk the extracted manual and resolve each register's page range.
361 Every numbered section heading -- register-bearing or not -- is recorded in
362 document order. A register's description runs from its own heading page up
363 to the page of the next heading, which is how a bit-field table spilling
364 onto the following page stays inside the register it belongs to.
366 boundaries: list[tuple[int, list[HumRegister]]] = []
367 for page_text
in text.split(
"\f"):
368 footer = FOOTER_RE.search(page_text)
371 page_number = int(footer.group(
"page"))
372 lines = page_text.split(
"\n")
373 for index, line
in enumerate(lines):
374 if SECTION_RE.match(line)
is None:
376 boundaries.append((page_number, _heading_rows(lines, index, page_number)))
378 rows: list[HumRegister] = []
379 for position, (page_number, here)
in enumerate(boundaries):
380 following = boundaries[position + 1][0]
if position + 1 < len(boundaries)
else page_number
381 end = max(page_number, following)
382 rows.extend(replace(row, page_end=end)
for row
in here)
383 rows.sort(key=
lambda row: (row.chapter, row.page, row.section, row.symbol))
387def extract_from_pdf(pdf: Path = HUM_PDF) -> list[HumRegister]:
388 """Parse every register description subsection out of the manual.
391 HumExtractionError: the extraction failed or produced a vacuous result.
393 rows = _scan_text(_pdftotext(pdf))
394 assert_not_vacuous(rows)
398def assert_not_vacuous(rows: list[HumRegister]) ->
None:
399 """Fail loudly when an extraction is too thin to have worked.
401 An empty or near-empty symbol table makes every downstream check pass
402 unconditionally, which reads as a clean tree. This is the guard that turns
403 that failure mode into a red gate.
406 HumExtractionError: any floor in this module was not met.
408 chapters = {row.chapter
for row
in rows}
409 with_offset = sum(1
for row
in rows
if row.offset)
411 if len(rows) < MIN_TOTAL_ROWS:
412 problems.append(f
"{len(rows)} register rows < floor {MIN_TOTAL_ROWS}")
413 if len(chapters) < MIN_CHAPTERS_WITH_REGISTERS:
415 f
"{len(chapters)} chapters with registers < floor {MIN_CHAPTERS_WITH_REGISTERS}"
417 if with_offset < MIN_ROWS_WITH_OFFSET:
418 problems.append(f
"{with_offset} rows carry an offset < floor {MIN_ROWS_WITH_OFFSET}")
421 "HUM register extraction is vacuous -- every downstream check would "
422 "pass unconditionally: " +
"; ".join(problems)
424 raise HumExtractionError(msg)
427def to_csv(rows: list[HumRegister]) -> str:
428 """Serialise the symbol table to the committed CSV form."""
429 buffer = io.StringIO(newline=
"")
430 writer = csv.writer(buffer, lineterminator=
"\n")
431 writer.writerow(CSV_FIELDS)
445 return buffer.getvalue()
448def from_csv(text: str) -> list[HumRegister]:
449 """Parse the committed CSV form back into rows.
452 HumExtractionError: the CSV header does not match :data:`CSV_FIELDS`,
453 or the result is vacuous.
455 reader = csv.reader(io.StringIO(text, newline=
""))
457 header = next(reader)
458 except StopIteration
as exc:
459 msg =
"register map CSV is empty"
460 raise HumExtractionError(msg)
from exc
461 if tuple(header) != CSV_FIELDS:
462 msg = f
"register map CSV header {header} != {list(CSV_FIELDS)}"
463 raise HumExtractionError(msg)
466 chapter=int(record[0]),
470 offset_expr=record[4],
472 page_end=int(record[6]),
478 assert_not_vacuous(rows)
483 """Chapter-indexed lookup over the manual's register symbol table."""
485 def __init__(self, rows: list[HumRegister]) ->
None:
486 """Index `rows` by chapter and canonical symbol."""
487 assert_not_vacuous(rows)
489 self._by_chapter: dict[int, dict[str, list[HumRegister]]] = {}
491 self._by_chapter.setdefault(row.chapter, {}).setdefault(row.canonical, []).append(row)
494 def chapters(self) -> set[int]:
495 """Every chapter that publishes at least one register."""
496 return set(self._by_chapter)
498 def lookup(self, chapter: int, symbol: str) -> list[HumRegister]:
499 """Every description of `symbol` inside `chapter`; empty when absent."""
500 return self._by_chapter.get(chapter, {}).get(canonical_symbol(symbol), [])
502 def find_anywhere(self, symbol: str) -> list[HumRegister]:
503 """Every description of `symbol` in any chapter.
505 Used only to sharpen a diagnostic: a symbol that is real but cited
506 against the wrong chapter is a different (and much smaller) mistake
507 than one the manual never mentions.
509 key = canonical_symbol(symbol)
511 row
for chapter
in self._by_chapter
for row
in self._by_chapter[chapter].get(key, [])
515def load(csv_path: Path = HUM_CSV) -> RegisterMap:
516 """Load the committed register map CSV.
519 HumExtractionError: the CSV is missing, malformed or vacuous.
521 if not csv_path.is_file():
522 msg = f
"register map not found: {csv_path} (run scripts/gen/gen_hum_register_map.py)"
523 raise HumExtractionError(msg)
524 return RegisterMap(from_csv(csv_path.read_text(encoding=
"utf-8")))
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.