Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,7 @@ fix_*.patch

# [Experimental] Generated TS type definitions
/packages/**/types_generated/

# Doxygen XML output used for C++ API tracking
/packages/react-native/**/api/xml
/packages/react-native/**/.doxygen.config.generated
2,902 changes: 2,902 additions & 0 deletions packages/react-native/.doxygen.config.template

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions scripts/cxx-api/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

import os
import subprocess

from doxmlparser import index

DOXYGEN_CONFIG_FILE = ".doxygen.config.generated"


def _get_repo_root() -> str:
"""Get the repository root via git."""
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"Could not determine repo root via 'git rev-parse': {result.stderr}"
)
return result.stdout.strip()


def get_react_native_dir() -> str:
"""
Get the path to the react-native package directory.

This function handles two execution contexts:
1. Buck execution: Uses REACT_NATIVE_DIR env var + git repo root
2. Direct Python invocation: Uses __file__ relative path
"""
react_native_dir = os.environ.get("REACT_NATIVE_DIR")
if react_native_dir:
return os.path.join(_get_repo_root(), react_native_dir)

# Use realpath to resolve symlinks — in Buck's link-tree, __file__
# is a symlink back to the source tree, so this gives the correct
# source location for relative path resolution.
script_dir = os.path.dirname(os.path.realpath(__file__))
return os.path.normpath(
os.path.join(
script_dir, # scripts/cxx-api/
"..", # scripts/
"..", # react-native-github/
"packages",
"react-native",
)
)


def build_doxygen_config(
directory: str,
include_directories: list[str] | None = None,
exclude_patterns: list[str] | None = None,
definitions: dict[str, str | int] | None = None,
) -> None:
if include_directories is None:
include_directories = []
if exclude_patterns is None:
exclude_patterns = []
if definitions is None:
definitions = {}

include_directories_str = " ".join(include_directories)
exclude_patterns_str = "\\\n".join(exclude_patterns)
if len(exclude_patterns) > 0:
exclude_patterns_str = f"\\\n{exclude_patterns_str}"

definitions_str = " ".join([f"{key}={value}" for key, value in definitions.items()])

# read the template file
with open(os.path.join(directory, ".doxygen.config.template")) as f:
template = f.read()

# replace the placeholder with the actual path
config = (
template.replace("${ADDITIONAL_INPUTS}", include_directories_str)
.replace("${ADDITIONAL_EXCLUDE_PATTERNS}", exclude_patterns_str)
.replace("${PREDEFINED}", definitions_str)
)

# write the config file
with open(os.path.join(directory, DOXYGEN_CONFIG_FILE), "w") as f:
f.write(config)


def build_snapshot(xml_dir) -> str:
"""
TODO
"""
index_path = os.path.join(xml_dir, "index.xml")
if not os.path.exists(index_path):
print(f"Doxygen entry point not found at {index_path}")

root = index.parse(index_path)

for comp in root.compound:
print(comp.kind)

return ""


if __name__ == "__main__":
# Define the path to the ReactCommon directory
react_native_dir = get_react_native_dir()

# If there is already an output directory, delete it
if os.path.exists(os.path.join(react_native_dir, "api")):
print("Deleting existing output directory")
subprocess.run(["rm", "-rf", os.path.join(react_native_dir, "api")])

# Generate the Doxygen config file
print("Generating Doxygen config file")
build_doxygen_config(
react_native_dir,
include_directories=["ReactAndroid"],
exclude_patterns=["*/ios/*"],
definitions={"RN_SERIALIZABLE_STATE": 1},
)

print("Running Doxygen")

# Run doxygen with the config file
result = subprocess.run(
["doxygen", DOXYGEN_CONFIG_FILE],
cwd=react_native_dir,
capture_output=True,
text=True,
)

# Check the result
if result.returncode != 0:
print(f"Error: {result.stderr}")
else:
print("Success")
build_snapshot(os.path.join(react_native_dir, "api", "xml"))
127 changes: 127 additions & 0 deletions scripts/cxx-api/test_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from __future__ import annotations

import difflib
import importlib.resources as ir
import os
import subprocess
import unittest
from importlib.abc import Traversable
from pathlib import Path
from typing import Iterable

from .parser import build_snapshot


def _resource_root() -> Traversable:
"""Get the root directory containing test cases.

Resources are included directly via glob(["tests/**/*"]) in BUCK.
Files are mapped at their original paths (e.g., tests/test1/...).
"""
pkg_root = ir.files(__package__ if __package__ else "__main__")
return pkg_root / "tests"


def _iter_case_dirs(root: Traversable) -> Iterable[Traversable]:
"""Iterate over test case directories."""
return sorted([p for p in root.iterdir() if p.is_dir()], key=lambda p: p.name)


def _assert_text_equal_with_diff(
tc: unittest.TestCase, expected: str, got: str, *, case: str
) -> None:
if expected == got:
return
diff = "\n".join(
difflib.unified_diff(
expected.splitlines(),
got.splitlines(),
fromfile=f"{case}/snapshot.api (expected)",
tofile=f"{case}/generated (got)",
lineterm="",
)
)
tc.fail(diff)


def _generate_doxygen_api(case_dir_path: str, doxygen_config_path: str) -> None:
"""Run doxygen to generate XML API documentation."""
result = subprocess.run(
["doxygen", doxygen_config_path],
cwd=case_dir_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Doxygen failed: {result.stderr}")


def _get_source_tests_dir() -> Path:
"""Get the actual source directory for tests (for writing snapshots).

This finds the original source directory, not the packaged resources.
Used when UPDATE_SNAPSHOT=1 to write new snapshots.
"""
source_tests_path = os.environ.get("SOURCE_TESTS_PATH")
if not source_tests_path:
raise RuntimeError("SOURCE_TESTS_PATH environment variable is not set")

from .parser import _get_repo_root

return Path(_get_repo_root()) / source_tests_path


def _make_case_test(case_dir: Traversable, tests_root: Traversable):
"""Create a test method for a specific test case directory."""

def _test(self: unittest.TestCase) -> None:
update = os.environ.get("UPDATE_SNAPSHOT") == "1"

# Use as_file() on the entire tests directory to get real filesystem paths
# This ensures the doxygen config and case directories are accessible
with ir.as_file(tests_root) as tests_root_path:
case_dir_path = tests_root_path / case_dir.name
doxygen_config_path = tests_root_path / ".doxygen.config.template"

# Run doxygen to generate the XML
_generate_doxygen_api(str(case_dir_path), str(doxygen_config_path))

# Parse the generated XML
xml_dir = case_dir_path / "api" / "xml"
snapshot = build_snapshot(str(xml_dir))
got_snapshot = snapshot.rstrip() + "\n"

expected_snapshot_path = case_dir_path / "snapshot.api"

# For writing snapshots, use the actual source directory
# when updating, otherwise use the packaged resource
if update or not expected_snapshot_path.exists():
# Write to actual source directory
source_tests_dir = _get_source_tests_dir()
source_snapshot_path = source_tests_dir / case_dir.name / "snapshot.api"
source_snapshot_path.write_text(got_snapshot)
print(f"Updated snapshot: {source_snapshot_path}")
return

expected_snapshot = expected_snapshot_path.read_text()
_assert_text_equal_with_diff(
self, expected_snapshot, got_snapshot, case=case_dir.name
)

return _test


class TestApiSnapshots(unittest.TestCase):
pass


# Dynamically generate test methods for each case directory
_root = _resource_root()
for _case_dir in _iter_case_dirs(_root):
_test_name = f"test_{_case_dir.name}"
setattr(TestApiSnapshots, _test_name, _make_case_test(_case_dir, _root))
Loading
Loading