Files
Mujoco_WASM/python/mujoco/introspect/codegen/ast_processor.py
T
Yuval Tassa 135b482c9d Copybara import of the project:
--
7f117fa7a7060b36687e7544e8d7393d14a1fa90 by Yuval Tassa <tassa@google.com>:

Handle clang AST loc nodes with elided 'line' in introspect codegen.

Apple's math.h (included directly by mujoco.h) contains an anonymous union
whose clang-JSON 'loc' lacks the 'line' key (clang elides it when unchanged
from the previous node), crashing AstProcessor._make_anonymous_key with a
KeyError during bindings regeneration on macOS. glibc's math.h has no such
union, so this never bites on Linux.

Fall back to an offset-based key for line-less nodes; these system-header
structs are never referenced by MuJoCo types, so the key only needs to be
unique.

COPYBARA_INTEGRATE_REVIEW=https://github.com/google-deepmind/mujoco/pull/3411 from yuvaltassa:introspect-loc-fallback 7f117fa7a7060b36687e7544e8d7393d14a1fa90
PiperOrigin-RevId: 949626713
Change-Id: I9d99b427885ede18b075db6d4e507aa8bc0d049a
2026-07-17 10:02:30 -07:00

370 lines
13 KiB
Python

# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Parses MuJoCo enums, structs, and functions from a Clang AST JSON dump.
The JSON input can be generated with, e.g:
clang -Xclang -ast-dump=json -fsyntax-only -fparse-all-comments -x c mujoco.h
"""
import itertools
import json
import os
import re
from typing import Any, Mapping, Union
from introspect import ast_nodes
from introspect import type_parsing
_ANONYMOUS_KEY_PATTERN = re.compile(r'\d+:\d+(?=\))')
_ARRAY_COMMENT_PATTERN = re.compile(r'(.+?)\s+\(([^\(\)]+) x ([^\(\)]+)\)\Z')
ClangJsonNode = Mapping[str, Any]
class _AnonymousTypePlaceholder(ast_nodes.ValueType):
def __init__(self, anonymous_key: str):
self.name = anonymous_key
self.is_const = False
self.is_volatile = False
class AstProcessor:
"""A Clang AST JSON node visitor for."""
def __init__(self, raw_headers: Mapping[str, str], excluded: list[str]):
self._enums = {}
self._exported_enums = {}
self._raw_headers = raw_headers
self._excluded = set(excluded)
self._current_source = ''
self._exported_functions = {}
self._structs = {}
self._anonymous = {}
self._exported_structs = {}
self._type_aliases = {}
@property
def exported_enums(self) -> Mapping[str, ast_nodes.EnumDecl]:
return self._exported_enums
@property
def exported_functions(self) -> Mapping[str, ast_nodes.FunctionDecl]:
return self._exported_functions
@property
def exported_structs(self) -> Mapping[str, ast_nodes.StructDecl]:
return self._exported_structs
@property
def type_aliases(self) -> Mapping[str, str]:
return self._type_aliases
def visit(self, node: ClangJsonNode) -> None:
"""Visits a JSON node and stores information about API elements."""
if 'loc' in node and 'file' in node['loc']:
self._current_source = os.path.basename(node['loc']['file'])
node_kind = node.get('kind')
node_name = node.get('name', '')
if (
node_kind == 'FunctionDecl'
and node_name.startswith('mj')
and node_name not in self._excluded
):
func_decl = self._make_function(node)
self._exported_functions[func_decl.name] = func_decl
elif node_kind == 'EnumDecl' and node_name.startswith('mj'):
enum_decl = self._make_enum(node)
self._enums[enum_decl.name] = enum_decl
elif node_kind == 'RecordDecl' and self._is_mujoco_type(node):
struct_decl = self._make_struct(node)
if hasattr(struct_decl, 'name'):
self._structs[struct_decl.name] = struct_decl
else:
anonymous_key = self._make_anonymous_key(node)
if anonymous_key in self._anonymous:
raise RuntimeError(
f'duplicate key for anonymous struct: {anonymous_key}')
self._anonymous[anonymous_key] = struct_decl
elif (node_kind == 'TypedefDecl' and
node['type']['qualType'].startswith('enum mj')):
enum = self._enums[node['type']['qualType']]
self._exported_enums[node_name] = ast_nodes.EnumDecl(
name=node_name, declname=enum.declname, values=dict(enum.values))
elif (node_kind == 'TypedefDecl' and
node['type']['qualType'].startswith('struct mj') and
node_name not in self._excluded):
declname = node['type']['qualType']
try:
struct = self._structs[declname]
except KeyError:
self._exported_structs[node_name] = ast_nodes.StructDecl(
name=node_name, declname=declname, fields=())
else:
self._exported_structs[node_name] = ast_nodes.StructDecl(
name=node_name, declname=struct.declname, fields=struct.fields)
elif (node_kind == 'TypedefDecl' and node_name.startswith('mj')):
qual_type = str(node['type']['qualType'])
self._type_aliases[node_name] = qual_type
def resolve(self) -> None:
"""Replaces anonymous struct placeholders with corresponding decl."""
for struct in itertools.chain(
self._structs.values(), self._exported_structs.values()
):
fields = []
for field in struct.fields:
if isinstance(field, _AnonymousTypePlaceholder):
fields.append(self._anonymous[field.name])
elif isinstance(field.type, _AnonymousTypePlaceholder):
fields.append(
ast_nodes.StructFieldDecl(
name=field.name,
type=self._anonymous[field.type.name],
doc=field.doc,
)
)
else:
fields.append(field)
struct.fields = tuple(fields)
def _make_enum(self, node: ClangJsonNode) -> ast_nodes.EnumDecl:
"""Makes a EnumDecl from a Clang AST EnumDecl node."""
name = f"enum {node['name']}"
values = []
for child in node['inner']:
child_kind = child.get('kind')
if child_kind == 'EnumConstantDecl':
next_idx = values[-1][1] + 1 if values else 0
if 'inner' in child:
value = int(child['inner'][0].get('value', next_idx))
else:
value = next_idx
values.append((child['name'], value))
return ast_nodes.EnumDecl(name=name, declname=name, values=dict(values))
def _make_function(self, node: ClangJsonNode) -> ast_nodes.FunctionDecl:
"""Makes a FunctionDecl from a Clang AST FunctionDecl node."""
name = node['name']
return_type = type_parsing.parse_function_return_type(
node['type']['qualType'])
parameters = []
comments = []
nullable_params = set()
for child in node['inner']:
child_kind = child.get('kind')
if child_kind == 'FullComment':
comments.append(self._make_function_comment(child))
nullable_params.update(self._find_nullable_params(child))
comment = ' '.join(comments).strip()
for child in node['inner']:
child_kind = child.get('kind')
if child_kind == 'ParmVarDecl':
parameters.append(self._make_parameter(child, nullable_params))
return ast_nodes.FunctionDecl(
name=name, return_type=return_type, parameters=parameters, doc=comment)
def _find_nullable_params(self, node: ClangJsonNode) -> set[str]:
"""Finds the names of parameters that are marked as nullable."""
nullable_params = set()
for child in node['inner']:
child_kind = child.get('kind')
if child_kind == 'ParagraphComment':
nullable_params.update(self._find_nullable_params(child))
if child_kind == 'TextComment':
if 'Nullable' in child['text']:
for param in child['text'].split(':')[1].split(','):
nullable_params.add(param.strip())
return nullable_params
def _make_parameter(
self, node: ClangJsonNode, nullable_params: set[str]
) -> ast_nodes.FunctionParameterDecl:
"""Makes a ParameterDecl from a Clang AST ParmVarDecl node."""
name = node['name']
type_name = node['type']['qualType']
nullable = name in nullable_params
# For a pointer parameters, look up in the original header to see if
# n array extent was declared there.
if type_name.endswith('*'):
decl_begin = node['range']['begin']['offset']
decl_end = node['range']['end']['offset'] + node['range']['end']['tokLen']
decl = self._raw_headers[self._current_source][decl_begin:decl_end]
name_begin = node['loc']['offset'] - decl_begin
name_end = name_begin + node['loc']['tokLen']
type_name = decl[:name_begin] + decl[name_end:]
return ast_nodes.FunctionParameterDecl(
nullable=nullable,
name=name,
type=type_parsing.parse_type(type_name),
)
def _make_function_comment(self, node: ClangJsonNode) -> str:
"""Makes a comment string from a Clang AST FullComment node."""
kind = node.get('kind')
if kind == 'TextComment':
return node['text'].replace('\N{NO-BREAK SPACE}', '&nbsp;')
else:
strings = []
for child in node['inner']:
comment = self._make_function_comment(child)
nullable_index = comment.find('Nullable:')
if nullable_index != -1:
comment = comment[:nullable_index]
strings.append(comment)
return ''.join(strings)
def _make_struct_comment(
self, node: ClangJsonNode, strip: bool = True
) -> str:
"""Makes a comment string from a Clang AST FullComment node."""
kind = node.get('kind')
if kind == 'TextComment':
retval = node['text'].replace('\N{NO-BREAK SPACE}', '&nbsp;')
else:
strings = []
for child in node['inner']:
strings.append(self._make_struct_comment(child, strip=False))
retval = ''.join(strings)
if strip:
retval = retval.strip()
return retval
def _normalize_type(
self, declname: str
) -> Union[
ast_nodes.ValueType, ast_nodes.PointerType, ast_nodes.ArrayType]:
"""Resolves anonymous structs/unions and looks up existing typedefs."""
# Check for anonymous struct/union.
if '(unnamed ' in declname:
m = _ANONYMOUS_KEY_PATTERN.search(declname)
if not m:
raise RuntimeError('cannot parse anonymous key from {m!r}')
return _AnonymousTypePlaceholder(m.group(0))
# Lookup typedef name and use it instead if one exists.
for k, v in self._exported_structs.items():
if declname == v.declname:
return type_parsing.parse_type(k)
# No valid normalization, just parse the declname.
return type_parsing.parse_type(declname)
def _make_field(
self, node: ClangJsonNode
) -> Union[ast_nodes.StructFieldDecl, _AnonymousTypePlaceholder]:
"""Makes a StructFieldDecl object from a Clang AST FieldDecl node."""
doc = ''
for child in node.get('inner', ()):
if child['kind'] == 'FullComment':
doc = self._make_struct_comment(child)
if 'name' in node:
field_type = self._normalize_type(node['type']['qualType'])
m = _ARRAY_COMMENT_PATTERN.match(doc)
if m is None:
array_extent = None
else:
doc = m.group(1)
array_extent_0 = m.group(2)
array_extent_1 = m.group(3)
try:
array_extent_1 = int(array_extent_1)
except ValueError:
pass
if array_extent_1 == 1:
array_extent = (array_extent_0,)
else:
array_extent = (array_extent_0, array_extent_1)
return ast_nodes.StructFieldDecl(
name=node['name'], type=field_type, doc=doc,
array_extent=array_extent)
else:
return _AnonymousTypePlaceholder(self._make_anonymous_key(node))
def _make_struct(
self, node: ClangJsonNode
) -> Union[ast_nodes.AnonymousStructDecl, ast_nodes.StructDecl]:
"""Makes a Decl object from a Clang AST RecordDecl node."""
name = f"{node['tagUsed']} {node['name']}" if 'name' in node else ''
fields = []
for child in node.get('inner', ()):
child_kind = child.get('kind')
if child_kind == 'FieldDecl':
fields.append(self._make_field(child))
if name:
return ast_nodes.StructDecl(name=name, declname=name, fields=fields)
elif node['tagUsed'] == 'union':
return ast_nodes.AnonymousUnionDecl(fields=fields)
else:
return ast_nodes.AnonymousStructDecl(fields=fields)
def _is_mujoco_type(self, node: ClangJsonNode) -> bool:
node_name = node.get('name', '')
included_from = os.path.basename(
node['loc'].get('includedFrom', {}).get('file', '')
)
return node_name not in self._excluded and (
node_name.startswith('mj')
or included_from == 'mujoco.h'
or included_from.startswith('mj')
)
def _make_anonymous_key(self, node: ClangJsonNode) -> str:
loc = node['loc']
if 'line' not in loc:
# Clang elides 'line' when unchanged from the previous node. This only
# occurs for system-header structs never referenced by MuJoCo types, so
# the key merely needs to be unique.
return f"offset{loc.get('offset')}:{loc.get('col')}"
return f"{loc['line']}:{loc['col']}"
def _traverse(node, visitor):
visitor.visit(node)
children = node.get('inner', [])
for child in children:
_traverse(child, visitor)
def process(
input_json: str,
headers: list[str] | None = None,
excluded: list[str] | None = None,
) -> AstProcessor:
"""Parses a Clang AST JSON file into individual AST types."""
with open(input_json, 'r', encoding='utf-8') as f:
root = json.load(f)
raw_headers = {}
for p in headers or []:
with open(p, 'r') as f:
raw_headers[os.path.basename(p)] = f.read()
visitor = AstProcessor(raw_headers, excluded or [])
_traverse(root, visitor)
visitor.resolve()
return visitor