diff --git a/scripts/cxx-api/input_filters/doxygen_strip_comments.py b/scripts/cxx-api/input_filters/doxygen_strip_comments.py deleted file mode 100755 index 83a7c06d92e9..000000000000 --- a/scripts/cxx-api/input_filters/doxygen_strip_comments.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env fbpython -# 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. - -""" -Doxygen input filter to strip block comments from source files. - -This prevents Doxygen from incorrectly parsing code examples within -documentation comments as actual code declarations (e.g., @interface, -@protocol examples in doc comments being parsed as real interfaces). - -Usage in doxygen config: - INPUT_FILTER = "python3 /path/to/doxygen_strip_comments.py" -""" - -import re -import sys - - -def strip_block_comments(content: str) -> str: - """ - Remove all block comments (/* ... */ and /** ... */) from content. - Preserves line count by replacing comment content with newlines. - """ - - def replace_with_newlines(match: re.Match) -> str: - # Count newlines in original comment to preserve line numbers - newline_count = match.group().count("\n") - return "\n" * newline_count - - # Pattern to match block comments (non-greedy) - comment_pattern = re.compile(r"/\*[\s\S]*?\*/") - - return comment_pattern.sub(replace_with_newlines, content) - - -def strip_deprecated_msg(content: str) -> str: - """ - Remove __deprecated_msg(...) macros and standalone __deprecated annotations - from content. - - These macros cause Doxygen to produce malformed XML output when they appear - before @interface declarations, creating __pad0__ artifacts and missing - members. Standalone __deprecated on method declarations causes the annotation - to be parsed as a parameter name. Since the macros are stripped, deprecation - info won't appear in the API snapshot output. - """ - # Pattern to match __deprecated_msg("...") with any content inside quotes - pattern = re.compile(r'__deprecated_msg\s*\(\s*"[^"]*"\s*\)\s*') - content = pattern.sub("", content) - - # Pattern to match standalone __deprecated (not followed by _msg or other suffix) - standalone_pattern = re.compile(r"\b__deprecated\b(?!_)\s*") - content = standalone_pattern.sub("", content) - - return content - - -def main(): - if len(sys.argv) < 2: - print("Usage: doxygen_strip_comments.py ", file=sys.stderr) - sys.exit(1) - - filename = sys.argv[1] - - try: - with open(filename, "r", encoding="utf-8", errors="replace") as f: - content = f.read() - - filtered = strip_block_comments(content) - filtered = strip_deprecated_msg(filtered) - print(filtered, end="") - except Exception as e: - # On error, output original content to not break the build - print(f"Warning: Filter error for {filename}: {e}", file=sys.stderr) - with open(filename, "r", encoding="utf-8", errors="replace") as f: - print(f.read(), end="") - - -if __name__ == "__main__": - main() diff --git a/scripts/cxx-api/input_filters/main.py b/scripts/cxx-api/input_filters/main.py new file mode 100644 index 000000000000..102ba81b5fef --- /dev/null +++ b/scripts/cxx-api/input_filters/main.py @@ -0,0 +1,40 @@ +#!/usr/bin/env fbpython +# 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 sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from strip_block_comments import strip_block_comments +from strip_deprecated_msg import strip_deprecated_msg +from strip_ns_unavailable import strip_ns_unavailable + + +def main(): + if len(sys.argv) < 2: + print("Usage: main.py ", file=sys.stderr) + sys.exit(1) + + filename = sys.argv[1] + + try: + with open(filename, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + + filtered = strip_block_comments(content) + filtered = strip_deprecated_msg(filtered) + filtered = strip_ns_unavailable(filtered) + print(filtered, end="") + except Exception as e: + # On error, output original content to not break the build + print(f"Warning: Filter error for {filename}: {e}", file=sys.stderr) + with open(filename, "r", encoding="utf-8", errors="replace") as f: + print(f.read(), end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/cxx-api/input_filters/strip_block_comments.py b/scripts/cxx-api/input_filters/strip_block_comments.py new file mode 100755 index 000000000000..16dc79fa49a7 --- /dev/null +++ b/scripts/cxx-api/input_filters/strip_block_comments.py @@ -0,0 +1,24 @@ +#!/usr/bin/env fbpython +# 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 re + + +def strip_block_comments(content: str) -> str: + """ + Remove all block comments (/* ... */ and /** ... */) from content. + Preserves line count by replacing comment content with newlines. + """ + + def replace_with_newlines(match: re.Match) -> str: + # Count newlines in original comment to preserve line numbers + newline_count = match.group().count("\n") + return "\n" * newline_count + + # Pattern to match block comments (non-greedy) + comment_pattern = re.compile(r"/\*[\s\S]*?\*/") + + return comment_pattern.sub(replace_with_newlines, content) diff --git a/scripts/cxx-api/input_filters/strip_deprecated_msg.py b/scripts/cxx-api/input_filters/strip_deprecated_msg.py new file mode 100644 index 000000000000..890a1c7c2556 --- /dev/null +++ b/scripts/cxx-api/input_filters/strip_deprecated_msg.py @@ -0,0 +1,29 @@ +#!/usr/bin/env fbpython +# 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 re + + +def strip_deprecated_msg(content: str) -> str: + """ + Remove __deprecated_msg(...) macros and standalone __deprecated annotations + from content. + + These macros cause Doxygen to produce malformed XML output when they appear + before @interface declarations, creating __pad0__ artifacts and missing + members. Standalone __deprecated on method declarations causes the annotation + to be parsed as a parameter name. Since the macros are stripped, deprecation + info won't appear in the API snapshot output. + """ + # Pattern to match __deprecated_msg("...") with any content inside quotes + pattern = re.compile(r'__deprecated_msg\s*\(\s*"[^"]*"\s*\)\s*') + content = pattern.sub("", content) + + # Pattern to match standalone __deprecated (not followed by _msg or other suffix) + standalone_pattern = re.compile(r"\b__deprecated\b(?!_)\s*") + content = standalone_pattern.sub("", content) + + return content diff --git a/scripts/cxx-api/input_filters/strip_ns_unavailable.py b/scripts/cxx-api/input_filters/strip_ns_unavailable.py new file mode 100644 index 000000000000..66c1fb758dfe --- /dev/null +++ b/scripts/cxx-api/input_filters/strip_ns_unavailable.py @@ -0,0 +1,30 @@ +#!/usr/bin/env fbpython +# 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 re + + +def strip_ns_unavailable(content: str) -> str: + """ + Remove method and property declarations marked NS_UNAVAILABLE from content. + + NS_UNAVAILABLE marks methods/properties that are explicitly not part of the + public API — they exist only to produce compile errors when called. Including + them in the API snapshot is misleading, so we strip the entire declaration. + Preserves line count by replacing matched declarations with newlines. + """ + + def replace_with_newlines(match: re.Match) -> str: + return "\n" * match.group().count("\n") + + # Match ObjC method (-/+) or @property declarations ending with NS_UNAVAILABLE; + # [^;]*? is non-greedy and cannot cross past a prior declaration's semicolon, + # but [^;] *does* match newlines, so multi-line declarations are handled. + pattern = re.compile( + r"^[ \t]*(?:[-+]|@property\b)[^;]*?NS_UNAVAILABLE\s*;[ \t]*$", + re.MULTILINE, + ) + return pattern.sub(replace_with_newlines, content) diff --git a/scripts/cxx-api/parser/__main__.py b/scripts/cxx-api/parser/__main__.py index 63a05c5fa5b8..fb3ac56dc8d6 100644 --- a/scripts/cxx-api/parser/__main__.py +++ b/scripts/cxx-api/parser/__main__.py @@ -202,7 +202,7 @@ def main(): "scripts", "cxx-api", "input_filters", - "doxygen_strip_comments.py", + "main.py", ) input_filter = None diff --git a/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/snapshot.api b/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/snapshot.api index e42d0b90b9d2..c8263fa1e804 100644 --- a/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/snapshot.api +++ b/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/snapshot.api @@ -5,6 +5,7 @@ interface RCTTestMacros { public virtual instancetype initWithName:(NSString* name); public virtual static UIUserInterfaceStyle userInterfaceStyle(); public virtual void deprecatedMethod(); + public virtual void start(); } protocol RCTTestProtocol { diff --git a/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/test.h b/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/test.h index d03ef29db63b..fefceaf0d873 100644 --- a/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/test.h +++ b/scripts/cxx-api/tests/snapshots/should_strip_objc_macros/test.h @@ -11,14 +11,24 @@ - (instancetype)initWithDelegate:(id)delegate options:(NSDictionary *)options NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + ++ (instancetype)new NS_UNAVAILABLE; + +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + @property (nonatomic, strong, readonly) dispatch_queue_t methodQueue RCT_DEPRECATED; @property (nonatomic, weak, readonly) id bridge RCT_DEPRECATED; +@property (nonatomic, copy, nullable) NSString *text NS_UNAVAILABLE; + - (void)deprecatedMethod RCT_DEPRECATED; + (UIUserInterfaceStyle)userInterfaceStyle API_AVAILABLE(ios(12)); +- (void)start; + @end RCT_EXTERN void RCTExternFunction(const char *input, NSString **output); @@ -33,4 +43,6 @@ RCT_EXTERN NSString *RCTParseType(const char **input); @property (nonatomic, readonly) NSString *name RCT_DEPRECATED; +- (void)setSelectedTextRange:(nullable UITextRange *)selectedTextRange NS_UNAVAILABLE; + @end diff --git a/scripts/cxx-api/tests/test_input_filters.py b/scripts/cxx-api/tests/test_input_filters.py index 821e93e937a6..c0d6cc917821 100644 --- a/scripts/cxx-api/tests/test_input_filters.py +++ b/scripts/cxx-api/tests/test_input_filters.py @@ -7,10 +7,9 @@ import unittest -from ..input_filters.doxygen_strip_comments import ( - strip_block_comments, - strip_deprecated_msg, -) +from ..input_filters.strip_block_comments import strip_block_comments +from ..input_filters.strip_deprecated_msg import strip_deprecated_msg +from ..input_filters.strip_ns_unavailable import strip_ns_unavailable class TestDoxygenStripComments(unittest.TestCase): @@ -116,5 +115,98 @@ def test_strips_deprecated_before_interface(self): self.assertEqual(result, "@interface RCTSurface : NSObject") +class TestStripNSUnavailable(unittest.TestCase): + def test_strips_single_line_init(self): + content = "- (instancetype)init NS_UNAVAILABLE;" + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + def test_strips_single_line_new(self): + content = "+ (instancetype)new NS_UNAVAILABLE;" + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + def test_strips_init_with_frame(self): + content = "- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;" + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + def test_strips_property(self): + content = "@property (nonatomic, copy, nullable) NSString *text NS_UNAVAILABLE;" + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + def test_strips_method_with_params(self): + content = ( + "- (void)insertSubview:(UIView *)view atIndex:(NSInteger)index" + " NS_UNAVAILABLE;" + ) + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + def test_strips_multiline_declaration(self): + content = ( + "- (instancetype)initWithSurface:(id)surface\n" + " sizeMeasureMode:(RCTSurfaceSizeMeasureMode)" + "sizeMeasureMode NS_UNAVAILABLE;" + ) + result = strip_ns_unavailable(content) + # Should preserve line count (2 lines -> 1 newline) + self.assertEqual(result, "\n") + + def test_preserves_normal_methods(self): + content = "- (instancetype)initWithBridge:(RCTBridge *)bridge;" + result = strip_ns_unavailable(content) + self.assertEqual(result, content) + + def test_preserves_normal_properties(self): + content = "@property (nonatomic, strong) NSString *name;" + result = strip_ns_unavailable(content) + self.assertEqual(result, content) + + def test_preserves_designated_initializer(self): + content = ( + "- (instancetype)initWithName:(NSString *)name NS_DESIGNATED_INITIALIZER;" + ) + result = strip_ns_unavailable(content) + self.assertEqual(result, content) + + def test_does_not_strip_across_semicolons(self): + content = ( + "- (void)normalMethod;\n" + "- (instancetype)init NS_UNAVAILABLE;\n" + "- (void)anotherMethod;" + ) + result = strip_ns_unavailable(content) + self.assertEqual(result, "- (void)normalMethod;\n\n- (void)anotherMethod;") + + def test_strips_multiple_unavailable_methods(self): + content = ( + "- (instancetype)init NS_UNAVAILABLE;\n+ (instancetype)new NS_UNAVAILABLE;" + ) + result = strip_ns_unavailable(content) + self.assertEqual(result, "\n") + + def test_handles_empty_content(self): + result = strip_ns_unavailable("") + self.assertEqual(result, "") + + def test_preserves_line_count(self): + content = ( + "@interface RCTHost : NSObject\n" + "- (instancetype)init NS_UNAVAILABLE;\n" + "+ (instancetype)new NS_UNAVAILABLE;\n" + "- (void)start;\n" + "@end" + ) + result = strip_ns_unavailable(content) + self.assertEqual(result.count("\n"), content.count("\n")) + + def test_handles_leading_whitespace(self): + content = " - (instancetype)init NS_UNAVAILABLE;" + result = strip_ns_unavailable(content) + self.assertEqual(result, "") + + if __name__ == "__main__": unittest.main() diff --git a/scripts/cxx-api/tests/test_snapshots.py b/scripts/cxx-api/tests/test_snapshots.py index 91a7938e58b3..5c52d0fe5d3f 100644 --- a/scripts/cxx-api/tests/test_snapshots.py +++ b/scripts/cxx-api/tests/test_snapshots.py @@ -106,9 +106,7 @@ def _test(self: unittest.TestCase) -> None: # Find the filter script in the package resources pkg_root = ir.files(__package__ if __package__ else "__main__") - filter_script = ( - pkg_root.parent / "input_filters" / "doxygen_strip_comments.py" - ) + filter_script = pkg_root.parent / "input_filters" / "main.py" # Get real filesystem path for filter script if it exists # IMPORTANT: Keep the context manager active while Doxygen runs,