From 1a33ca4ae558d329e2c2e652c5391b9ebccd9f9e Mon Sep 17 00:00:00 2001 From: Yuval Tassa Date: Mon, 20 Jul 2026 04:11:23 -0700 Subject: [PATCH] Release docs auto-generation scripts and test PiperOrigin-RevId: 950744854 Change-Id: Ibcd9d6bd3e6ec50d5d6753b8c9516f7d31f19e5b --- doc/generate/generate_api_header.py | 104 ++++++++++++ doc/generate/generate_functions.py | 170 +++++++++++++++++++ doc/generate/generate_linenumbers.py | 112 +++++++++++++ doc/generate/generate_schema.py | 238 +++++++++++++++++++++++++++ test/CMakeLists.txt | 2 +- test/doc/CMakeLists.txt | 20 +++ test/doc/doc_test.py | 156 ++++++++++++++++++ 7 files changed, 801 insertions(+), 1 deletion(-) create mode 100755 doc/generate/generate_api_header.py create mode 100644 doc/generate/generate_functions.py create mode 100755 doc/generate/generate_linenumbers.py create mode 100644 doc/generate/generate_schema.py create mode 100644 test/doc/CMakeLists.txt create mode 100644 test/doc/doc_test.py diff --git a/doc/generate/generate_api_header.py b/doc/generate/generate_api_header.py new file mode 100755 index 00000000..fd0acc42 --- /dev/null +++ b/doc/generate/generate_api_header.py @@ -0,0 +1,104 @@ +# 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. +# ============================================================================== +"""Generates API for APIReference.rst.""" + +import sys +from typing import Dict + +import os +import sys +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR)) +sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'ext')) +import header_reader + +_HEADER_FILES = [ + 'include/mujoco/mjassert.h', + 'include/mujoco/mjdata.h', + 'include/mujoco/mjexport.h', + 'include/mujoco/mjmacro.h', + 'include/mujoco/mjmodel.h', + 'include/mujoco/mjplugin.h', + 'include/mujoco/mjrender.h', + 'include/mujoco/mjrfilament.h', + 'include/mujoco/mjspec.h', + 'include/mujoco/mjspecmacro.h', + 'include/mujoco/mjtype.h', + 'include/mujoco/mjui.h', + 'include/mujoco/mjvisualize.h', + 'include/mujoco/mjxmacro.h', + 'include/mujoco/mujoco.h', +] + + +def generate_reference_header( + api: Dict[str, header_reader.ApiDefinition]) -> str: + """Generates the reference header file used by APIRererence.rst.""" + + source = """ + // Copyright 2022 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. + +// DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED. +// Error: C reference not found +// NOLINTBEGIN\n\n""".lstrip() + + for value in api.values(): + if value.c_type != 'FUNCTION': + source = f'{source}{value.code}' + + source = f"""{source} +//----------------------------- MJAPI FUNCTIONS -------------------------------- +""" + + for value in api.values(): + if value.c_type == 'FUNCTION': + source = f'{source}{value.code}' + source = f'{source}// NOLINTEND\n' + return source + + +def read_headers() -> Dict[str, header_reader.ApiDefinition]: + """Reads API header files and generates a mapping between C tokens and C header definitions.""" + + api = {} + + for header in _HEADER_FILES: + filepath = os.path.join(_REPO_ROOT, header) + with open(filepath, 'r', encoding='utf-8') as file: + api.update(header_reader.read(file.readlines())) + return api + + +def main() -> None: + if len(sys.argv) > 1: + sys.exit('Too many command-line arguments.') + + sys.stdout.buffer.write(generate_reference_header(read_headers()).encode('utf-8')) + + +if __name__ == '__main__': + main() diff --git a/doc/generate/generate_functions.py b/doc/generate/generate_functions.py new file mode 100644 index 00000000..abdb4c21 --- /dev/null +++ b/doc/generate/generate_functions.py @@ -0,0 +1,170 @@ +# 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. +# ============================================================================== +"""Generates functions API for APIreference.rst.""" + +import re +import sys + +import os +import sys +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR)) +sys.path.insert(0, _SCRIPT_DIR) +import generate_api_header +_FUNCTIONS_OVERRIDE = 'doc/APIreference/functions_override.rst' + +_RST_SECTION_REGEX = re.compile(r'^\.\. (?P
_[A-Z].+):') +_RST_FUNCTION_REGEX = re.compile(r'^\.\. _(?Pmj.+):') + +rst_to_section = { + '_Parseandcompile': ['Parse and compile', ''], + '_Mainsimulation': ['Main simulation', ''], + '_Support': ['Support', ''], + '_Components': ['Components', ''], + '_Subcomponents': ['Sub components', ''], + '_Raycollisions': ['Ray casting', ''], + '_Printing': ['Printing', ''], + '_Virtualfilesystem': ['Virtual file system', ''], + '_Assetcache': ['Asset cache', ''], + '_Resources': ['Resources', ''], + '_Initialization': ['Initialization', ''], + '_Errorandmemory': ['Error and memory', ''], + '_Miscellaneous': ['Miscellaneous', ''], + '_Interaction': ['Interaction', ''], + '_Visualization-api': ['Visualization', ''], + '_OpenGLrendering': ['OpenGL rendering', ''], + '_FilamentRenderingApi': ['Filament rendering', ''], + '_UIframework': ['UI framework', ''], + '_Derivatives-api': ['Derivatives', ''], + '_Signeddistancefunction': ['Signed Distance Functions', ''], + '_Plugins-api': ['Plugins', ''], + '_Thread': ['Threads', ''], + '_Standardmath': ['Standard math', ''], + '_Vectormath': ['Vector math', ''], + '_Sparsemath': ['Sparse math', ''], + '_Quaternions': ['Quaternions', ''], + '_Poses': ['Poses', ''], + '_Decompositions': ['Decompositions / Solvers', ''], + '_Attachment': ['Attachment', ''], + '_AddTreeElements': ['Tree elements', ''], + '_AddNonTreeElements': ['Non-tree elements', ''], + '_Setactuatorparameters': ['Set actuator parameters', ''], + '_AddAssets': ['Assets', ''], + '_FindAndGetUtilities': ['Find and get utilities', ''], + '_AttributeSetters': ['Attribute setters', ''], + '_AttributeGetters': ['Attribute getters', ''], + '_SpecUtilities': ['Spec utilities', ''], + '_ElementInitialization': ['Element initialization', ''], + '_ElementCasting': ['Element casting', ''], +} + + +def generate() -> str: + """Generates functions.rst.""" + rst_str = '' + api = generate_api_header.read_headers() + + filepath = os.path.join(_REPO_ROOT, _FUNCTIONS_OVERRIDE) + with open(filepath, 'r', encoding='utf-8') as file: + current_section = None + current_function = None + for line in file: + + # Try matching a section. + match = _RST_SECTION_REGEX.search(line) + if match is not None: + current_section = match.group('section') + if current_section in rst_to_section: + current_function = None + else: + current_section = None + continue + + # Try matching a function + match = _RST_FUNCTION_REGEX.search(line) + if match is not None: + current_function = match.group('function') + if current_function in api: + api[current_function].doc = '' + current_section = None + else: + current_function = None + continue + + # Update section doc. + if current_section is not None: + rst_to_section[current_section][1] += line + + # Override doc. + if current_function is not None: + api[current_function].doc += line + + # Check that all function sections from the headers are mapped. + mapped_sections = {v[0] for v in rst_to_section.values()} + for token, defn in api.items(): + if defn.c_type == 'FUNCTION' and defn.section not in mapped_sections: + raise ValueError( + f'Function {token!r} is in unmapped section {defn.section!r}. ' + f'Add an entry to rst_to_section in generate_functions.py.' + ) + + # Write out RST file. + rst_str += (""" +.. + AUTOGENERATED: DO NOT EDIT MANUALLY + + +""".lstrip()) + + for section in rst_to_section: + section_title = rst_to_section[section][0] + rst_str += ( + f""" +.. {section}: + +{section_title} +{'^'*len(section_title)} +{rst_to_section[section][1]}""".lstrip()) + + for token in api: + if api[token].c_type != 'FUNCTION' or api[token].section != section_title: + continue + + doc = api[token].doc.strip() + doc = doc.replace(' ', '`` ``') + + rst_str += (f""" +.. _{token}: + +`{token} <#{token}>`__ +{'~'*(2*len(token)+8)} + +.. mujoco-include:: {token} + +{doc} + +""".lstrip()) + return rst_str + + +def main() -> None: + if len(sys.argv) > 1: + sys.exit('Too many command-line arguments.') + + sys.stdout.buffer.write(generate().encode('utf-8')) + + +if __name__ == '__main__': + main() diff --git a/doc/generate/generate_linenumbers.py b/doc/generate/generate_linenumbers.py new file mode 100755 index 00000000..c2a772e9 --- /dev/null +++ b/doc/generate/generate_linenumbers.py @@ -0,0 +1,112 @@ +# 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. +# ============================================================================== +"""Script to automatically extract and insert MuJoCo source file paths.""" + +import argparse +import os +import re +import sys + + +def main(): + """Extracts MuJoCo source file paths and updates linenumbers.js.""" + parser = argparse.ArgumentParser(description='Update SRCS in linenumbers.js') + parser.add_argument( + '--src_dir', required=True, help='Path to mujoco src directory') + parser.add_argument( + '--js_file', required=True, help='Path to linenumbers.js file') + parser.add_argument( + '--ref_file', required=True, help='Path to references.h') + parser.add_argument( + '--check', action='store_true', + help='Check if SRCS matches, without updating') + args = parser.parse_args() + + with open(args.ref_file, 'r', encoding='utf-8') as f: + ref_content = f.read() + + # Extract all mj_... symbols that might be function names + valid_funcs = set(re.findall(r'\bmj[a-zA-Z0-9_]*\b', ref_content)) + + srcs = [] + pattern = re.compile(r'^(const )?[a-zA-Z0-9_*]+\s+(.+)\(.+[{,]$') + + for root, _, files in os.walk(args.src_dir): + for f in files: + if f.endswith('.c') or f.endswith('.cc'): + path = os.path.relpath(os.path.join(root, f), args.src_dir) + # Ensure we use forward slashes for Javascript array + path = path.replace('\\', '/') + + # Check if it has any function defined in references.h + filepath = os.path.join(root, f) + found = False + with open(filepath, 'r', encoding='utf-8') as cf: + for line in cf: + line = line.strip('\n') + match = pattern.match(line) + key = None + if match: + key = match.group(2).strip() + + # edge cases + if 'user_api.cc' in filepath and line.startswith( + '[[nodiscard]] int mj_recompile('): + key = 'mj_recompile' + elif 'engine_io.c' in filepath: + if line.startswith('void mj_freeStack('): + key = 'mj_freeStack' + elif line.startswith('void mj_markStack('): + key = 'mj_markStack' + + if key and key in valid_funcs: + found = True + break + + if found: + srcs.append(path) + + srcs = sorted(srcs) + + with open(args.js_file, 'r', encoding='utf-8') as f: + content = f.read() + + # Find the const SRCS = [ ... ]; block + js_pattern = re.compile(r'const SRCS = \[\n(.*?)\n\];', re.DOTALL) + match = js_pattern.search(content) + if not match: + sys.exit('Could not find const SRCS = [ in linenumbers.js') + + current_srcs_str = match.group(1) + + # Generate the new string + new_srcs_str = '\n'.join([f" '{s}'," for s in srcs]) + + if current_srcs_str == new_srcs_str: + print('SRCS is up to date.') + sys.exit(0) + + if args.check: + print('SRCS is not up to date. Please run update_docs to update it.') + sys.exit(1) + + # Otherwise, update the file + new_content = content[:match.start(1)] + new_srcs_str + content[match.end(1):] + with open(args.js_file, 'w', encoding='utf-8') as f: + f.write(new_content) + print(f'Updated SRCS in {args.js_file} with {len(srcs)} files.') + +if __name__ == '__main__': + main() diff --git a/doc/generate/generate_schema.py b/doc/generate/generate_schema.py new file mode 100644 index 00000000..71a1e8c1 --- /dev/null +++ b/doc/generate/generate_schema.py @@ -0,0 +1,238 @@ +# 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. +# ============================================================================== +"""Generates MJCF schema using dropdown directives for XMLreference.rst. + +Generates XMLschema.rst by reading xml_native_reader.cc and producing +nested dropdown directives with list-table for attributes. +""" + +import copy +import sys + +import os + +# Map symbols to icons: +# ! = required element, can appear only once -> star (prominent, required) +# ? = optional element, can appear only once -> dot (minimal, single) +# * = optional element, can appear many times -> None (no icon, most common) +# R = optional element, can appear many times recursively -> sync (recursive) +SYMBOL_TO_ICON = { + '!': 'star', + '?': 'dot', + '*': None, + 'R': 'sync', +} + +ELEMENT_ORDER = [ + 'mujoco', + 'option', + 'compiler', + 'size', + 'statistic', + 'asset', + 'body', + 'deformable', + 'contact', + 'equality', + 'tendon', + 'actuator', + 'sensor', + 'keyframe', + 'visual', + 'default', + 'custom', + 'extension', +] + +# Special display names for elements (when different from the element name) +ELEMENT_DISPLAY_NAME = { + 'body': '(world)body', +} + + +def generate_dropdown( + element_name: str, + symbol: str, + link_name: str, + attributes: list[str], + links: list[str], + level: int, + is_top_level: bool = False, +) -> str: + """Generate a dropdown directive for an element with its attributes.""" + indent = ' ' * level + output = '' + + display_name = ELEMENT_DISPLAY_NAME.get(element_name, element_name) + icon = SYMBOL_TO_ICON.get(symbol) + # Element name is a :ref: link. Icon/macro goes on the right side. + # Use |*| for * elements (even though it's empty) for future flexibility. + element_link = f':ref:`{display_name}<{link_name}>`' + if icon: + title = f'{element_link} :octicon:`{icon}`' + else: + title = f'{element_link} |*|' + output += f'{indent}.. dropdown:: {title}\n' + if is_top_level: + output += f'{indent} :open:\n' + output += '\n' + + content_indent = indent + ' ' + + # Responsive grid for attributes (2-3-4-4: mobile-tablet-desktop-large) + if attributes: + output += f'{content_indent}.. grid:: 2 3 4 4\n' + output += f'{content_indent} :gutter: 0\n' + output += '\n' + + for attr in attributes: + att_link_name = f'{link_name}-{attr}' + if att_link_name not in links: + raise ValueError( + f'Link for attribute {att_link_name} not found, update' + ' XMLreference.rst' + ) + output += f'{content_indent} .. grid-item::\n' + output += f'{content_indent} :ref:`{attr}<{att_link_name}>`\n' + output += '\n' + + output += '\n' + return output + + +def generate() -> str: + """Generates XMLschema.rst by parsing xml_native_reader.cc. + + The schema is defined in xml_native_reader.cc as a nested structure called + MJCF[nMJCF]. This function parses that structure and generates nested + dropdown directives with list-tables for attributes. + + Returns: + RST content with nested dropdown directives for the MJCF schema. + """ + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.dirname(os.path.dirname(script_dir)) + filepath = os.path.join(repo_root, 'src', 'xml', 'xml_native_reader.cc') + xmlfile = os.path.join(repo_root, 'doc', 'XMLreference.rst') + + # Collect all link targets from XMLreference.rst for validation. + links = [] + with open(xmlfile, 'r', encoding='utf-8') as file: + for line in file: + if line.startswith('.. _'): + links.append(line.strip()[4:-1]) + + output = """.. + DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED. + +.. raw:: html + +
+ + +
+ +""" + + with open(filepath, 'r', encoding='utf-8') as file: + to_strip = {' ', '{', '}', '"'} + + # Process each top-level element in a specific order for consistent output. + for top_element in ELEMENT_ORDER: + level = 0 + element: list[str] = [] + parent = ['', '', '', '', ''] # Track parent elements for link names. + found_element = False + file.seek(0) + + # Skip to the MJCF schema definition in the C++ source. + for line in file: + if 'std::vector MJCF[nMJCF] = {' in line.strip(): + break + + # Parse the schema structure. + for line in file: + if line.strip().endswith('};'): + break # End of schema definition. + + # Track nesting level using angle brackets in the C++ source. + if '<' in line: + level += 1 + continue + if '>' in line: + level -= 1 + continue + + # Parse element definition from the line. + line_is_done = 1 if '}' in line else 0 + line_copy = copy.copy(line) + for item in to_strip: + line_copy = line_copy.replace(item, '') + element += [item for item in line_copy.split(',') if item != '\n'] + + # Determine if this element belongs to the current top-level element. + outer_level = level == 0 and top_element == 'mujoco' + top_level = level == 1 and element and top_element == element[0] + sub_level = level > 1 and top_element == parent[2] + level_is_correct = outer_level or top_level or sub_level + + if found_element and not level_is_correct: + break # Done with this top-level element. + found_element = level_is_correct + + # Generate dropdown for completed element definition. + if line_is_done and level_is_correct: + # Link name: top-level uses element name, nested uses parent-child. + link_name = element[0] + if level > 1: + link_name = parent[level] + '-' + element[0] + if link_name not in links: + raise ValueError( + f'Link for element {link_name} not found, update' + ' XMLreference.rst' + ) + attributes = element[2:] # First two items are name and symbol. + + # Adjust indentation level for RST output. + dropdown_level = 0 if level == 0 else (1 if level == 1 else level) + is_top_level = level == 0 + output += generate_dropdown( + element[0], + element[1], + link_name, + attributes, + links, + dropdown_level, + is_top_level, + ) + + # Track parent elements for building nested link names. + if line_is_done: + if level < 4: + parent[level + 1] = element[0] + element = [] + + return output + + +def main() -> int: + if len(sys.argv) > 1: + sys.exit('Too many command-line arguments.') + sys.stdout.buffer.write(generate().encode('utf-8')) + return 0 + + +if __name__ == '__main__': + main() diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 13ba059c..f1bbba61 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -126,4 +126,4 @@ add_subdirectory(xml) add_subdirectory(plugin/elasticity) add_subdirectory(plugin/actuator) add_subdirectory(experimental) - +add_subdirectory(doc) diff --git a/test/doc/CMakeLists.txt b/test/doc/CMakeLists.txt new file mode 100644 index 00000000..be3d7ed4 --- /dev/null +++ b/test/doc/CMakeLists.txt @@ -0,0 +1,20 @@ +# 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 +# +# https://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. + +find_package(Python3 COMPONENTS Interpreter) +if(Python3_FOUND) + add_test(NAME doc_test + COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/doc_test.py + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) +endif() diff --git a/test/doc/doc_test.py b/test/doc/doc_test.py new file mode 100644 index 00000000..3e5888a1 --- /dev/null +++ b/test/doc/doc_test.py @@ -0,0 +1,156 @@ +# 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. +# ============================================================================== +"""Tests that the API reference documentation is complete and up to date.""" + +import re + +import os +import sys +import unittest as googletest +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR)) +sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'generate')) +import generate_api_header +import generate_functions +import generate_schema + +# Functions in headers that are intentionally not in functions.rst. +_FUNCTIONS_TO_SKIP = set() + +# Types (STRUCT/ENUM) in headers that are intentionally not in APItypes.rst. +_TYPES_TO_SKIP = set() + +# Type names documented in APItypes.rst that aren't STRUCT/ENUM in headers. +# These are typedefs, callbacks, and scalar types. +_EXTRA_DOCUMENTED_TYPES = { + # scalar typedefs + 'mjtByte', + 'mjtBool', + 'mjtNum', + 'mjtSize', + # C++ type aliases + 'mjByteVec', + 'mjDoubleVec', + 'mjFloatVec', + 'mjFloatVecVec', + 'mjIntVec', + 'mjIntVecVec', + 'mjString', + 'mjStringVec', + # function pointer typedefs (callbacks) + 'mjfAct', + 'mjfCanDecode', + 'mjfCloseResource', + 'mjfCollision', + 'mjfConFilt', + 'mjfDecode', + 'mjfEncode', + 'mjfGeneric', + 'mjfGetResourceDir', + 'mjfItemEnable', + 'mjfLogHandler', + 'mjfOpenResource', + 'mjfReadResource', + 'mjfResourceModified', + 'mjfSensor', + 'mjfTime', +} + + +class DocTest(googletest.TestCase): + + def test_api_header(self): + """Checks that references.h matches the generated output.""" + header_file = os.path.join(_REPO_ROOT, 'doc', 'includes', 'references.h') + source = generate_api_header.generate_reference_header( + generate_api_header.read_headers() + ) + with open(header_file, 'r', encoding='utf-8') as file: + if source != file.read(): + self.fail("The file 'references.h' needs to be updated.") + + def test_schema(self): + """Checks that XMLschema.rst matches the generated output.""" + schema_file = os.path.join(_REPO_ROOT, 'doc', 'XMLschema.rst') + source = generate_schema.generate() + with open(schema_file, 'r', encoding='utf-8') as file: + if source != file.read(): + self.fail("The file 'XMLschema.rst' needs to be updated.") + + def test_functions(self): + """Checks that functions.rst matches the generated output.""" + functions_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'functions.rst') + source = generate_functions.generate() + with open(functions_file, 'r', encoding='utf-8') as file: + if source != file.read(): + self.fail("The file 'functions.rst' needs to be updated.") + + def test_all_functions_included(self): + """Checks that every public C function has an entry in functions.rst.""" + + functions_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'functions.rst') + with open(functions_file, 'r', encoding='utf-8') as file: + content = file.read() + + documented = set( + re.findall(r'^\.\. _(mj[a-zA-Z0-9_]+):', content, flags=re.MULTILINE) + ) + + api = generate_api_header.read_headers() + header_funcs = {token for token, d in api.items() if d.c_type == 'FUNCTION'} + + errors = [] + for token in sorted(header_funcs - documented - _FUNCTIONS_TO_SKIP): + d = api[token] + errors.append(f' undocumented: {token} (section: {d.section!r})') + for token in sorted(documented - header_funcs): + errors.append(f' stale: {token} (in functions.rst but not in headers)') + + if errors: + msg = 'functions.rst mismatches:\n' + '\n'.join(errors) + self.fail(msg) + + def test_all_types_included(self): + """Checks that every public struct and enum has an entry in APItypes.rst.""" + + types_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'APItypes.rst') + with open(types_file, 'r', encoding='utf-8') as file: + content = file.read() + + documented = set( + re.findall(r'^\.\. _(mj[a-zA-Z0-9_]+):', content, flags=re.MULTILINE) + ) + + api = generate_api_header.read_headers() + header_types = { + token for token, d in api.items() if d.c_type in ('STRUCT', 'ENUM') + } + + errors = [] + for token in sorted(header_types - documented - _TYPES_TO_SKIP): + d = api[token] + errors.append( + f' undocumented: {token} ({d.c_type}, section: {d.section!r})' + ) + for token in sorted(documented - header_types - _EXTRA_DOCUMENTED_TYPES): + errors.append(f' stale: {token} (in APItypes.rst but not in headers)') + + if errors: + msg = 'APItypes.rst mismatches:\n' + '\n'.join(errors) + self.fail(msg) + + +if __name__ == '__main__': + googletest.main()