Skip to content

Commit a1cb055

Browse files
Dawid Małeckimeta-codesync[bot]
authored andcommitted
Add doxygen input filter to strip block comments
Summary: Adds a doxygen input filter (`input_filters/doxygen_strip_comments.py`) that strips block comments from source files before doxygen parses them. This prevents doxygen from incorrectly parsing Objective-C code examples (like `interface`, `protocol`) within documentation comments as actual code declarations. For example, a doc comment containing: ``` /** * Example: * interface RCT_EXTERN_MODULE(MyModule, NSObject) * end */ ``` Was being parsed by doxygen as an actual interface declaration, resulting in malformed output like `interface RCT_EXTERN_MODULE {}` in the API snapshot. ## Changes 1. **Input Filter**: Added `doxygen_strip_comments.py` that replaces block comments with equivalent newlines to preserve line numbers. 2. **Fixed Empty Snapshots**: Fixed the `__main__.py` to properly pass the `${DOXYGEN_INPUT_FILTER}` placeholder value to doxygen config, which was causing empty snapshot generation. 3. **Normalized Pointer Spacing**: Added `normalize_pointer_spacing()` function to normalize doxygen's output: - `NSString *` → `NSString*` - `int &` → `int&` - `T &&` → `T&&` 4. **Added Snapshot Test**: Created `should_not_parse_interface_from_doc_comment` test case demonstrating the issue. Differential Revision: D94538938
1 parent c48de2a commit a1cb055

File tree

9 files changed

+197
-14
lines changed

9 files changed

+197
-14
lines changed

packages/react-native/.doxygen.config.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,7 @@ IMAGE_PATH =
11231123
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
11241124
# properly processed by Doxygen.
11251125

1126-
INPUT_FILTER =
1126+
INPUT_FILTER = ${DOXYGEN_INPUT_FILTER}
11271127

11281128
# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern
11291129
# basis. Doxygen will compare the file name with each pattern and apply the
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env fbpython
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
#
4+
# This source code is licensed under the MIT license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""
8+
Doxygen input filter to strip block comments from source files.
9+
10+
This prevents Doxygen from incorrectly parsing code examples within
11+
documentation comments as actual code declarations (e.g., @interface,
12+
@protocol examples in doc comments being parsed as real interfaces).
13+
14+
Usage in doxygen config:
15+
INPUT_FILTER = "python3 /path/to/doxygen_strip_comments.py"
16+
"""
17+
18+
import re
19+
import sys
20+
21+
22+
def strip_block_comments(content: str) -> str:
23+
"""
24+
Remove all block comments (/* ... */ and /** ... */) from content.
25+
Preserves line count by replacing comment content with newlines.
26+
"""
27+
28+
def replace_with_newlines(match: re.Match) -> str:
29+
# Count newlines in original comment to preserve line numbers
30+
newline_count = match.group().count("\n")
31+
return "\n" * newline_count
32+
33+
# Pattern to match block comments (non-greedy)
34+
comment_pattern = re.compile(r"/\*[\s\S]*?\*/")
35+
36+
return comment_pattern.sub(replace_with_newlines, content)
37+
38+
39+
def main():
40+
if len(sys.argv) < 2:
41+
print("Usage: doxygen_strip_comments.py <filename>", file=sys.stderr)
42+
sys.exit(1)
43+
44+
filename = sys.argv[1]
45+
46+
try:
47+
with open(filename, "r", encoding="utf-8", errors="replace") as f:
48+
content = f.read()
49+
50+
filtered = strip_block_comments(content)
51+
print(filtered, end="")
52+
except Exception as e:
53+
# On error, output original content to not break the build
54+
print(f"Warning: Filter error for {filename}: {e}", file=sys.stderr)
55+
with open(filename, "r", encoding="utf-8", errors="replace") as f:
56+
print(f.read(), end="")
57+
58+
59+
if __name__ == "__main__":
60+
main()

scripts/cxx-api/manual_test/.doxygen.config.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@ IMAGE_PATH =
11221122
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
11231123
# properly processed by Doxygen.
11241124

1125-
INPUT_FILTER =
1125+
INPUT_FILTER = ${DOXYGEN_INPUT_FILTER}
11261126

11271127
# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern
11281128
# basis. Doxygen will compare the file name with each pattern and apply the

scripts/cxx-api/parser/__main__.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def build_doxygen_config(
3333
include_directories: list[str] = None,
3434
exclude_patterns: list[str] = None,
3535
definitions: dict[str, str | int] = None,
36+
input_filter: str = None,
3637
) -> None:
3738
if include_directories is None:
3839
include_directories = []
@@ -53,6 +54,8 @@ def build_doxygen_config(
5354
]
5455
)
5556

57+
input_filter_str = input_filter if input_filter else ""
58+
5659
# read the template file
5760
with open(os.path.join(directory, ".doxygen.config.template")) as f:
5861
template = f.read()
@@ -62,6 +65,7 @@ def build_doxygen_config(
6265
template.replace("${INPUTS}", include_directories_str)
6366
.replace("${EXCLUDE_PATTERNS}", exclude_patterns_str)
6467
.replace("${PREDEFINED}", definitions_str)
68+
.replace("${DOXYGEN_INPUT_FILTER}", input_filter_str)
6569
)
6670

6771
# write the config file
@@ -77,6 +81,7 @@ def build_snapshot_for_view(
7781
definitions: dict[str, str | int],
7882
output_dir: str,
7983
verbose: bool = True,
84+
input_filter: str = None,
8085
) -> None:
8186
if verbose:
8287
print(f"Generating API view: {api_view}")
@@ -87,6 +92,7 @@ def build_snapshot_for_view(
8792
include_directories=include_directories,
8893
exclude_patterns=exclude_patterns,
8994
definitions=definitions,
95+
input_filter=input_filter,
9096
)
9197

9298
# If there is already a doxygen output directory, delete it
@@ -188,6 +194,17 @@ def main():
188194
if verbose and args.codegen_path:
189195
print(f"Codegen output path: {os.path.abspath(args.codegen_path)}")
190196

197+
input_filter_path = os.path.join(
198+
get_react_native_dir(),
199+
"scripts",
200+
"cxx-api",
201+
"input_filters",
202+
"doxygen_strip_comments.py",
203+
)
204+
input_filter = None
205+
if os.path.exists(input_filter_path):
206+
input_filter = f"python3 {input_filter_path}"
207+
191208
# Parse config file
192209
config_path = os.path.join(
193210
get_react_native_dir(), "scripts", "cxx-api", "config.yml"
@@ -219,6 +236,7 @@ def build_snapshots(output_dir: str, verbose: bool) -> None:
219236
definitions={},
220237
output_dir=output_dir,
221238
verbose=verbose,
239+
input_filter=input_filter,
222240
)
223241

224242
if verbose:
@@ -245,7 +263,3 @@ def build_snapshots(output_dir: str, verbose: bool) -> None:
245263
)
246264
)
247265
build_snapshots(output_dir, verbose=True)
248-
249-
250-
if __name__ == "__main__":
251-
main()

scripts/cxx-api/tests/snapshots/.doxygen.config.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@ IMAGE_PATH =
11221122
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
11231123
# properly processed by Doxygen.
11241124

1125-
INPUT_FILTER =
1125+
INPUT_FILTER = ${DOXYGEN_INPUT_FILTER}
11261126

11271127
# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern
11281128
# basis. Doxygen will compare the file name with each pattern and apply the
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
interface RCTRealInterface {
2+
public virtual void realMethod();
3+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
/**
9+
* Example of how to use this module:
10+
*
11+
* @interface RCT_EXTERN_MODULE(MyModule, NSObject)
12+
* @end
13+
*/
14+
@interface RCTRealInterface
15+
16+
- (void)realMethod;
17+
18+
@end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
from __future__ import annotations
7+
8+
import unittest
9+
10+
from ..input_filters.doxygen_strip_comments import strip_block_comments
11+
12+
13+
class TestDoxygenStripComments(unittest.TestCase):
14+
def test_strips_single_line_block_comment(self):
15+
content = "/* comment */ code"
16+
result = strip_block_comments(content)
17+
self.assertEqual(result, " code")
18+
19+
def test_strips_multiline_block_comment(self):
20+
content = """/**
21+
* Doc comment
22+
* with multiple lines
23+
*/
24+
@interface RealInterface
25+
@end"""
26+
result = strip_block_comments(content)
27+
# Should preserve 4 newlines (one for each line in the comment)
28+
self.assertEqual(
29+
result,
30+
"""\n\n\n
31+
@interface RealInterface
32+
@end""",
33+
)
34+
35+
def test_preserves_code_outside_comments(self):
36+
content = """@interface MyClass
37+
- (void)method;
38+
@end"""
39+
result = strip_block_comments(content)
40+
self.assertEqual(result, content)
41+
42+
def test_strips_comment_with_objc_keywords(self):
43+
"""This is the main use case - stripping comments that contain @interface etc."""
44+
content = """/**
45+
* Example:
46+
* @interface RCT_EXTERN_MODULE(MyModule, NSObject)
47+
* @end
48+
*/
49+
@interface RealInterface
50+
@end"""
51+
result = strip_block_comments(content)
52+
self.assertNotIn("RCT_EXTERN_MODULE", result)
53+
self.assertIn("@interface RealInterface", result)
54+
55+
def test_handles_multiple_comments(self):
56+
content = """/* first */ code /* second */ more"""
57+
result = strip_block_comments(content)
58+
self.assertEqual(result, " code more")
59+
60+
def test_handles_empty_content(self):
61+
result = strip_block_comments("")
62+
self.assertEqual(result, "")
63+
64+
def test_handles_no_comments(self):
65+
content = "just code without comments"
66+
result = strip_block_comments(content)
67+
self.assertEqual(result, content)
68+
69+
70+
if __name__ == "__main__":
71+
unittest.main()

scripts/cxx-api/tests/test_snapshots.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,21 @@ def _assert_text_equal_with_diff(
4949
tc.fail(diff)
5050

5151

52-
def _get_doxygen_bin() -> str:
53-
return os.environ.get("DOXYGEN_BIN", "doxygen")
54-
55-
56-
def _generate_doxygen_api(case_dir_path: str, doxygen_config_path: str) -> None:
52+
def _generate_doxygen_api(
53+
case_dir_path: str, doxygen_config_path: str, filter_script_path: str | None = None
54+
) -> None:
5755
"""Run doxygen to generate XML API documentation."""
58-
doxygen_bin = _get_doxygen_bin()
56+
env = os.environ.copy()
57+
if filter_script_path:
58+
env["DOXYGEN_INPUT_FILTER"] = f"python3 {filter_script_path}"
59+
60+
doxygen_bin = env.get("DOXYGEN_BIN", "doxygen")
5961
result = subprocess.run(
6062
[doxygen_bin, doxygen_config_path],
6163
cwd=case_dir_path,
6264
capture_output=True,
6365
text=True,
66+
env=env,
6467
)
6568
if result.returncode != 0:
6669
raise RuntimeError(f"Doxygen failed: {result.stderr}")
@@ -101,8 +104,22 @@ def _test(self: unittest.TestCase) -> None:
101104
case_dir_path = tests_root_path / case_dir.name
102105
doxygen_config_path = tests_root_path / ".doxygen.config.template"
103106

107+
# Find the filter script in the package resources
108+
pkg_root = ir.files(__package__ if __package__ else "__main__")
109+
filter_script = (
110+
pkg_root.parent / "input_filters" / "doxygen_strip_comments.py"
111+
)
112+
113+
# Get real filesystem path for filter script if it exists
114+
filter_script_path = None
115+
if filter_script.is_file():
116+
with ir.as_file(filter_script) as fs_path:
117+
filter_script_path = str(fs_path)
118+
104119
# Run doxygen to generate the XML
105-
_generate_doxygen_api(str(case_dir_path), str(doxygen_config_path))
120+
_generate_doxygen_api(
121+
str(case_dir_path), str(doxygen_config_path), filter_script_path
122+
)
106123

107124
# Parse the generated XML
108125
xml_dir = case_dir_path / "api" / "xml"

0 commit comments

Comments
 (0)