| 242 | |
| 243 | |
| 244 | def parse_mjxmacro(path: str) -> Dict[str, Any]: |
| 245 | with open(path, "r", encoding="utf-8") as f: |
| 246 | raw_text = f.read() |
| 247 | text = _strip_comments(raw_text) |
| 248 | lines = text.split("\n") |
| 249 | |
| 250 | out: Dict[str, Any] = { |
| 251 | "_meta": { |
| 252 | "source": os.path.relpath(path, PLUGIN_ROOT), |
| 253 | "schema_kind": "mjxmacro", |
| 254 | }, |
| 255 | "mjmodel_pointers": {}, # block_name -> list of entries |
| 256 | "mjdata_pointers": {}, |
| 257 | "struct_fields": {}, |
| 258 | } |
| 259 | |
| 260 | expected = ( |
| 261 | set(POINTER_BLOCKS_MJMODEL) | set(POINTER_BLOCKS_MJDATA) |
| 262 | | set(STRUCT_FIELD_BLOCKS) | KNOWN_UNCONSUMED_BLOCKS |
| 263 | ) |
| 264 | seen_blocks: set = set() |
| 265 | |
| 266 | i = 0 |
| 267 | while i < len(lines): |
| 268 | line = lines[i] |
| 269 | m = _BLOCK_START_RE.match(line) |
| 270 | if not m: |
| 271 | i += 1 |
| 272 | continue |
| 273 | block_name = m.group(1) |
| 274 | seen_blocks.add(block_name) |
| 275 | # Collect block body — entries on subsequent lines. |
| 276 | entries = _parse_block_body(lines, i + 1) |
| 277 | # Defensive sentinel: a non-entry, non-empty line ended body |
| 278 | # collection inside an EXPECTED block. The XNV regression was |
| 279 | # exactly this — surface it so the next new X-tag bumps cause a |
| 280 | # loud warning rather than silently truncating the snapshot. |
| 281 | end_idx = i + 1 + len(entries) |
| 282 | if (block_name in expected and end_idx < len(lines) |
| 283 | and lines[end_idx].strip() |
| 284 | and not _BODY_ENTRY_RE.match(lines[end_idx].strip())): |
| 285 | # If the very next line was also an entry-shaped token we |
| 286 | # missed (i.e. it has parens), warn loudly. |
| 287 | tail = lines[end_idx].strip() |
| 288 | if "(" in tail and tail.endswith(")") or tail.endswith(") \\"): |
| 289 | print( |
| 290 | f"warning: body collector stopped INSIDE block " |
| 291 | f"{block_name} at line {end_idx + 1}: '{tail[:80]}' — " |
| 292 | f"new X-macro tag? Update _X_TAG_RE in this script.", |
| 293 | file=sys.stderr, |
| 294 | ) |
| 295 | if block_name in POINTER_BLOCKS_MJMODEL: |
| 296 | parsed = [e for e in (_parse_pointer_entry(r) for r in entries) if e] |
| 297 | out["mjmodel_pointers"][block_name] = parsed |
| 298 | elif block_name in POINTER_BLOCKS_MJDATA: |
| 299 | parsed = [e for e in (_parse_pointer_entry(r) for r in entries) if e] |
| 300 | out["mjdata_pointers"][block_name] = parsed |
| 301 | elif block_name in STRUCT_FIELD_BLOCKS: |