diff --git a/scripts/cxx-api/parser/__main__.py b/scripts/cxx-api/parser/__main__.py index 4e4e88089d38..9b963f3881e6 100644 --- a/scripts/cxx-api/parser/__main__.py +++ b/scripts/cxx-api/parser/__main__.py @@ -13,67 +13,39 @@ import argparse import os +import shutil import subprocess import sys import tempfile -from .config import parse_config_file +from .config import ApiViewSnapshotConfig, parse_config_file +from .doxygen import get_doxygen_bin, run_doxygen from .main import build_snapshot from .path_utils import get_react_native_dir from .snapshot_diff import check_snapshots -DOXYGEN_CONFIG_FILE = ".doxygen.config.generated" - -def build_doxygen_config( - directory: str, - include_directories: list[str] = None, - exclude_patterns: list[str] = None, - definitions: dict[str, str | int] = None, - input_filter: str = 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}"' if isinstance(value, str) else f"{key}={value}" - for key, value in definitions.items() - ] - ) - - input_filter_str = input_filter if input_filter else "" - - # read the template file - with open(os.path.join(directory, ".doxygen.config.template")) as f: - template = f.read() - - # replace the placeholders with the actual values - config = ( - template.replace("${INPUTS}", include_directories_str) - .replace("${EXCLUDE_PATTERNS}", exclude_patterns_str) - .replace("${PREDEFINED}", definitions_str) - .replace("${DOXYGEN_INPUT_FILTER}", input_filter_str) - ) - - # write the config file - with open(os.path.join(directory, DOXYGEN_CONFIG_FILE), "w") as f: - f.write(config) +def run_command( + cmd: list[str], + label: str, + verbose: bool = False, + **kwargs, +) -> subprocess.CompletedProcess: + """Run a subprocess command with consistent error handling.""" + result = subprocess.run(cmd, **kwargs) + if result.returncode != 0: + if verbose: + print(f"{label} finished with error: {result.stderr}") + sys.exit(1) + elif verbose: + print(f"{label} finished successfully") + return result def build_codegen(platform: str, verbose: bool = False) -> str: react_native_dir = os.path.join(get_react_native_dir(), "packages", "react-native") - result = subprocess.run( + run_command( [ "node", "./scripts/generate-codegen-artifacts.js", @@ -85,17 +57,11 @@ def build_codegen(platform: str, verbose: bool = False) -> str: platform, "--forceOutputPath", ], + label="Codegen", + verbose=verbose, cwd=react_native_dir, ) - if result.returncode != 0: - if verbose: - print(f"Codegen finished with error: {result.stderr}") - sys.exit(1) - else: - if verbose: - print("Codegen finished successfully") - return os.path.join(react_native_dir, "api", "codegen") @@ -109,64 +75,35 @@ def build_snapshot_for_view( codegen_platform: str | None = None, verbose: bool = True, input_filter: str = None, -) -> None: - # If there is already an output directory, delete it - if os.path.exists(os.path.join(react_native_dir, "api")): - if verbose: - print("Deleting existing output directory") - subprocess.run(["rm", "-rf", os.path.join(react_native_dir, "api")]) - +) -> str: if verbose: print(f"Generating API view: {api_view}") + api_dir = os.path.join(react_native_dir, "api") + if os.path.exists(api_dir): + if verbose: + print(" Deleting existing output directory") + shutil.rmtree(api_dir) + if codegen_platform is not None: codegen_dir = build_codegen(codegen_platform, verbose=verbose) include_directories.append(codegen_dir) elif verbose: - print("Skipping codegen") - - if verbose: - print("Generating Doxygen config file") + print(" Skipping codegen") - build_doxygen_config( - react_native_dir, + run_doxygen( + working_dir=react_native_dir, include_directories=include_directories, exclude_patterns=exclude_patterns, definitions=definitions, input_filter=input_filter, + verbose=verbose, ) if verbose: - print("Running Doxygen") - if input_filter: - print(f" Using input filter: {input_filter}") - - # Run doxygen with the config file - doxygen_bin = os.environ.get("DOXYGEN_BIN", "doxygen") - - result = subprocess.run( - [doxygen_bin, DOXYGEN_CONFIG_FILE], - cwd=react_native_dir, - capture_output=True, - text=True, - ) - - # Check the result - if result.returncode != 0: - if verbose: - print(f"Doxygen finished with error: {result.stderr}") - sys.exit(1) - else: - if verbose: - print("Doxygen finished successfully") - - # Delete the Doxygen config file - if verbose: - print("Deleting Doxygen config file") - subprocess.run(["rm", DOXYGEN_CONFIG_FILE], cwd=react_native_dir) + print(" Building snapshot") - # build snapshot, convert to string, and save to file - snapshot = build_snapshot(os.path.join(react_native_dir, "api", "xml")) + snapshot = build_snapshot(os.path.join(api_dir, "xml")) snapshot_string = snapshot.to_string() output_file = os.path.join(output_dir, f"{api_view}Cxx.api") @@ -179,6 +116,52 @@ def build_snapshot_for_view( return snapshot_string +def build_snapshots( + snapshot_configs: list[ApiViewSnapshotConfig], + react_native_dir: str, + output_dir: str, + input_filter: str | None, + verbose: bool, + view_filter: str | None = None, + is_test: bool = False, +) -> None: + if not is_test: + for config in snapshot_configs: + if view_filter and config.snapshot_name != view_filter: + continue + + build_snapshot_for_view( + api_view=config.snapshot_name, + react_native_dir=react_native_dir, + include_directories=config.inputs, + exclude_patterns=config.exclude_patterns, + definitions=config.definitions, + output_dir=output_dir, + codegen_platform=config.codegen_platform, + verbose=verbose, + input_filter=input_filter, + ) + else: + snapshot = build_snapshot_for_view( + api_view="Test", + react_native_dir=react_native_dir, + include_directories=[], + exclude_patterns=[], + definitions={}, + output_dir=output_dir, + codegen_platform=None, + verbose=verbose, + input_filter=input_filter, + ) + + if verbose: + print(snapshot) + + +def get_default_snapshot_dir() -> str: + return os.path.join(get_react_native_dir(), "scripts", "cxx-api", "api-snapshots") + + def main(): parser = argparse.ArgumentParser( description="Generate API snapshots from C++ headers" @@ -198,6 +181,11 @@ def main(): type=str, help="Directory containing committed snapshots for comparison (used with --check)", ) + parser.add_argument( + "--view", + type=str, + help="Name of the API view to generate", + ) parser.add_argument( "--test", action="store_true", @@ -207,7 +195,7 @@ def main(): verbose = not args.check - doxygen_bin = os.environ.get("DOXYGEN_BIN", "doxygen") + doxygen_bin = get_doxygen_bin() version_result = subprocess.run( [doxygen_bin, "--version"], capture_output=True, @@ -216,7 +204,6 @@ def main(): if verbose: print(f"Using Doxygen {version_result.stdout.strip()} ({doxygen_bin})") - # Define the path to the react-native directory react_native_package_dir = ( os.path.join(get_react_native_dir(), "packages", "react-native") if not args.test @@ -238,7 +225,6 @@ def main(): if os.path.exists(input_filter_path): input_filter = f"python3 {input_filter_path}" - # Parse config file config_path = os.path.join( get_react_native_dir(), "scripts", "cxx-api", "config.yml" ) @@ -247,57 +233,28 @@ def main(): get_react_native_dir(), ) - def build_snapshots(output_dir: str, verbose: bool) -> None: - if not args.test: - for config in snapshot_configs: - build_snapshot_for_view( - api_view=config.snapshot_name, - react_native_dir=react_native_package_dir, - include_directories=config.inputs, - exclude_patterns=config.exclude_patterns, - definitions=config.definitions, - output_dir=output_dir, - codegen_platform=config.codegen_platform, - verbose=verbose, - input_filter=input_filter, - ) - else: - snapshot = build_snapshot_for_view( - api_view="Test", - react_native_dir=react_native_package_dir, - include_directories=[], - exclude_patterns=[], - definitions={}, - output_dir=output_dir, - codegen_platform=None, - verbose=verbose, - input_filter=input_filter, - ) - - if verbose: - print(snapshot) + with tempfile.TemporaryDirectory() as tmpdir: + snapshot_output_dir = ( + tmpdir if args.check else args.output_dir or get_default_snapshot_dir() + ) - if args.check: - with tempfile.TemporaryDirectory() as tmpdir: - build_snapshots(tmpdir, verbose=False) + build_snapshots( + output_dir=snapshot_output_dir, + verbose=not args.check, + snapshot_configs=snapshot_configs, + react_native_dir=react_native_package_dir, + input_filter=input_filter, + view_filter=args.view, + is_test=args.test, + ) - snapshot_dir = args.snapshot_dir or os.path.join( - get_react_native_dir(), "scripts", "cxx-api", "api-snapshots" - ) + if args.check: + snapshot_dir = args.snapshot_dir or get_default_snapshot_dir() - if not check_snapshots(tmpdir, snapshot_dir): + if not check_snapshots(snapshot_output_dir, snapshot_dir): sys.exit(1) print("All snapshot checks passed") - else: - output_dir = ( - args.output_dir - if args.output_dir - else os.path.join( - get_react_native_dir(), "scripts", "cxx-api", "api-snapshots" - ) - ) - build_snapshots(output_dir, verbose=True) if __name__ == "__main__": diff --git a/scripts/cxx-api/parser/builders.py b/scripts/cxx-api/parser/builders.py index 9beb9a93880d..c595183d86c1 100644 --- a/scripts/cxx-api/parser/builders.py +++ b/scripts/cxx-api/parser/builders.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +from dataclasses import dataclass from doxmlparser import compound @@ -38,10 +39,29 @@ normalize_pointer_spacing, parse_qualified_path, resolve_linked_text_name, + split_specialization, ) from .utils.argument_parsing import _find_matching_angle, _split_arguments +@dataclass +class ParsedSectionKind: + """Parsed representation of a Doxygen section kind string (e.g. 'public-static-func').""" + + visibility: str + is_static: bool + member_type: str + + @classmethod + def parse(cls, kind: str) -> ParsedSectionKind: + parts = kind.split("-") + return cls( + visibility=parts[0], + is_static="static" in parts, + member_type=parts[-1], + ) + + ###################### # Base class fixups ###################### @@ -124,8 +144,7 @@ def _fix_inherited_constructor_name( return class_unqualified_name = parse_qualified_path(compound_name)[-1] - # Strip template args for comparison - class_base_name = class_unqualified_name.split("<")[0] + class_base_name, _ = split_specialization(class_unqualified_name) if func_member.name != class_base_name: func_member.name = class_unqualified_name @@ -249,7 +268,7 @@ def get_variable_member( if initializer_type == InitializerType.BRACE: is_brace_initializer = True - return VariableMember( + member = VariableMember( variable_name, variable_type, visibility, @@ -263,6 +282,10 @@ def get_variable_member( is_brace_initializer, ) + member.add_template(get_template_params(member_def)) + + return member + def get_doxygen_params( function_def: compound.MemberdefType, @@ -313,6 +336,21 @@ def get_doxygen_params( else: param_type += param_array + # Handle pointer-to-member-function types where the name must be + # embedded inside the declarator group. Doxygen gives: + # type = "void(ns::*)() const", name = "asFoo" + # We need to produce: + # "void(ns::*asFoo)() const" + if param_name: + m = re.search(r"\([^)]*::\*\)", param_type) + if m: + # Insert name before the closing ')' of the ptr-to-member group + insert_pos = m.end() - 1 + param_type = ( + param_type[:insert_pos] + param_name + param_type[insert_pos:] + ) + param_name = None + qualifiers, core_type = extract_qualifiers(param_type) arguments.append((qualifiers, core_type, param_name, param_default)) @@ -517,11 +555,10 @@ def _process_objc_sections( members into the base interface XML output. """ for section_def in section_defs: - kind = section_def.kind - parts = kind.split("-") - visibility = parts[0] - is_static = "static" in parts - member_type = parts[-1] + section = ParsedSectionKind.parse(section_def.kind) + visibility = section.visibility + is_static = section.is_static + member_type = section.member_type if visibility == "private": if member_type == "type": @@ -558,7 +595,9 @@ def _process_objc_sections( f"Unknown section member kind: {member_def.kind} in {location_file}" ) else: - print(f"Unknown {scope_type} section kind: {kind} in {location_file}") + print( + f"Unknown {scope_type} section kind: {section_def.kind} in {location_file}" + ) elif visibility == "property": for member_def in section_def.memberdef: if member_def.kind == "property": @@ -674,11 +713,10 @@ def create_class_scope( class_scope.location = compound_object.location.file for section_def in compound_object.sectiondef: - kind = section_def.kind - parts = kind.split("-") - visibility = parts[0] - is_static = "static" in parts - member_type = parts[-1] + section = ParsedSectionKind.parse(section_def.kind) + visibility = section.visibility + is_static = section.is_static + member_type = section.member_type if visibility == "private": if member_type == "type": @@ -727,7 +765,7 @@ def create_class_scope( ) else: print( - f"Unknown class section kind: {kind} in {compound_object.location.file}" + f"Unknown class section kind: {section_def.kind} in {compound_object.location.file}" ) elif visibility == "friend": pass diff --git a/scripts/cxx-api/parser/doxygen.py b/scripts/cxx-api/parser/doxygen.py new file mode 100644 index 000000000000..bb0f1914060b --- /dev/null +++ b/scripts/cxx-api/parser/doxygen.py @@ -0,0 +1,106 @@ +# 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 configuration and execution utilities. +""" + +import os +import subprocess +import sys + +_DOXYGEN_CONFIG_FILE = ".doxygen.config.generated" + + +def get_doxygen_bin() -> str: + return os.environ.get("DOXYGEN_BIN", "doxygen") + + +def build_doxygen_config( + directory: str, + include_directories: list[str] = None, + exclude_patterns: list[str] = None, + definitions: dict[str, str | int] = None, + input_filter: str = 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}"' if isinstance(value, str) else f"{key}={value}" + for key, value in definitions.items() + ] + ) + + input_filter_str = input_filter if input_filter else "" + + with open(os.path.join(directory, ".doxygen.config.template")) as f: + template = f.read() + + config = ( + template.replace("${INPUTS}", include_directories_str) + .replace("${EXCLUDE_PATTERNS}", exclude_patterns_str) + .replace("${PREDEFINED}", definitions_str) + .replace("${DOXYGEN_INPUT_FILTER}", input_filter_str) + ) + + with open(os.path.join(directory, _DOXYGEN_CONFIG_FILE), "w") as f: + f.write(config) + + +def run_doxygen( + working_dir: str, + include_directories: list[str], + exclude_patterns: list[str], + definitions: dict[str, str | int], + input_filter: str = None, + verbose: bool = True, +) -> None: + """Generate Doxygen config, run Doxygen, and clean up the config file.""" + if verbose: + print(" Generating Doxygen config file") + + build_doxygen_config( + working_dir, + include_directories=include_directories, + exclude_patterns=exclude_patterns, + definitions=definitions, + input_filter=input_filter, + ) + + if verbose: + print(" Running Doxygen") + if input_filter: + print(f" Using input filter: {input_filter}") + + doxygen_bin = get_doxygen_bin() + + result = subprocess.run( + [doxygen_bin, _DOXYGEN_CONFIG_FILE], + cwd=working_dir, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + if verbose: + print(f" Doxygen finished with error: {result.stderr}") + sys.exit(1) + elif verbose: + print(" Doxygen finished successfully") + + if verbose: + print(" Deleting Doxygen config file") + os.remove(os.path.join(working_dir, _DOXYGEN_CONFIG_FILE)) diff --git a/scripts/cxx-api/parser/main.py b/scripts/cxx-api/parser/main.py index c1f8b6ad1f09..082162b84b8d 100644 --- a/scripts/cxx-api/parser/main.py +++ b/scripts/cxx-api/parser/main.py @@ -25,7 +25,119 @@ get_variable_member, ) from .snapshot import Snapshot -from .utils import parse_qualified_path +from .utils import has_scope_resolution_outside_angles, parse_qualified_path + + +def _process_namespace_sections(snapshot, namespace_scope, compound_object): + """ + Process all section definitions inside a namespace compound. + """ + for section_def in compound_object.sectiondef: + if section_def.kind == "var": + for variable_def in section_def.memberdef: + # Skip out-of-class definitions (e.g. "Strct::VALUE") + if has_scope_resolution_outside_angles(variable_def.get_name()): + continue + is_static = variable_def.static == "yes" + namespace_scope.add_member( + get_variable_member(variable_def, "public", is_static) + ) + elif section_def.kind == "func": + for function_def in section_def.memberdef: + # Skip out-of-class definitions (e.g. "Strct::convert") + if has_scope_resolution_outside_angles(function_def.get_name()): + continue + function_static = function_def.static == "yes" + + if not function_static: + namespace_scope.add_member( + get_function_member(function_def, "public") + ) + elif section_def.kind == "typedef": + for typedef_def in section_def.memberdef: + namespace_scope.add_member(get_typedef_member(typedef_def, "public")) + elif section_def.kind == "enum": + for enum_def in section_def.memberdef: + create_enum_scope(snapshot, enum_def) + else: + print( + f"Unknown section kind: {section_def.kind} in {compound_object.location.file}" + ) + + +def _handle_namespace_compound(snapshot, compound_object): + """ + Handle a namespace compound definition. + """ + # Skip anonymous namespaces (internal linkage, not public API). + # Doxygen encodes them with a '@' prefix in the compound name. + if "@" in compound_object.compoundname: + return + + namespace_scope = snapshot.create_or_get_namespace(compound_object.compoundname) + + namespace_scope.location = compound_object.location.file + + _process_namespace_sections(snapshot, namespace_scope, compound_object) + + +def _handle_concept_compound(snapshot, compound_object): + """ + Handle a concept compound definition. + """ + # Concepts belong to a namespace, so we need to find or create the parent namespace + concept_name = compound_object.compoundname + concept_path = parse_qualified_path(concept_name) + namespace_path = "::".join(concept_path[:-1]) if concept_path else "" + + if namespace_path: + namespace_scope = snapshot.create_or_get_namespace(namespace_path) + else: + namespace_scope = snapshot.root_scope + + namespace_scope.add_member(get_concept_member(compound_object)) + + +def _handle_class_compound(snapshot, compound_object): + """ + Handle class, struct, and union compound definitions. + """ + # Check if this is an Objective-C interface by looking at the compound id + # Doxygen reports ObjC interfaces as kind="class" but with id starting with "interface" + is_objc_interface = ( + compound_object.kind == "class" and compound_object.id.startswith("interface") + ) + + # Handle Objective-C interfaces separately + if is_objc_interface: + create_interface_scope(snapshot, compound_object) + return + + # classes and structs are represented by the same scope with a different kind + create_class_scope(snapshot, compound_object) + + +# Dispatch table for compound kinds that map directly to a single builder call. +_COMPOUND_HANDLERS = { + "class": _handle_class_compound, + "struct": _handle_class_compound, + "union": _handle_class_compound, + "namespace": _handle_namespace_compound, + "concept": _handle_concept_compound, + "category": create_category_scope, + "protocol": create_protocol_scope, + "interface": create_interface_scope, +} + +# Compound kinds that are intentionally ignored. +_IGNORED_COMPOUNDS = frozenset( + { + "file", + "dir", + # Contains deprecation info + "page", + } +) def build_snapshot(xml_dir: str) -> Snapshot: @@ -51,90 +163,14 @@ def build_snapshot(xml_dir: str) -> Snapshot: if compound_object.prot == "private": continue - # Check if this is an Objective-C interface by looking at the compound id - # Doxygen reports ObjC interfaces as kind="class" but with id starting with "interface" - is_objc_interface = ( - compound_object.kind == "class" - and compound_object.id.startswith("interface") - ) - - # classes and structs are represented by the same scope with a different kind - if ( - compound_object.kind == "class" - or compound_object.kind == "struct" - or compound_object.kind == "union" - ): - # Handle Objective-C interfaces separately - if is_objc_interface: - create_interface_scope(snapshot, compound_object) - continue - create_class_scope(snapshot, compound_object) - elif compound_object.kind == "namespace": - # Skip anonymous namespaces (internal linkage, not public API). - # Doxygen encodes them with a '@' prefix in the compound name. - if "@" in compound_object.compoundname: - continue + kind = compound_object.kind - namespace_scope = snapshot.create_or_get_namespace( - compound_object.compoundname - ) - - namespace_scope.location = compound_object.location.file - - for section_def in compound_object.sectiondef: - if section_def.kind == "var": - for variable_def in section_def.memberdef: - is_static = variable_def.static == "yes" - namespace_scope.add_member( - get_variable_member(variable_def, "public", is_static) - ) - elif section_def.kind == "func": - for function_def in section_def.memberdef: - function_static = function_def.static == "yes" - - if not function_static: - namespace_scope.add_member( - get_function_member(function_def, "public") - ) - elif section_def.kind == "typedef": - for typedef_def in section_def.memberdef: - namespace_scope.add_member( - get_typedef_member(typedef_def, "public") - ) - elif section_def.kind == "enum": - for enum_def in section_def.memberdef: - create_enum_scope(snapshot, enum_def) - else: - print( - f"Unknown section kind: {section_def.kind} in {compound_object.location.file}" - ) - elif compound_object.kind == "concept": - # Concepts belong to a namespace, so we need to find or create the parent namespace - concept_name = compound_object.compoundname - concept_path = parse_qualified_path(concept_name) - namespace_path = "::".join(concept_path[:-1]) if concept_path else "" - - if namespace_path: - namespace_scope = snapshot.create_or_get_namespace(namespace_path) - else: - namespace_scope = snapshot.root_scope - - namespace_scope.add_member(get_concept_member(compound_object)) - elif compound_object.kind == "file": - pass - elif compound_object.kind == "dir": - pass - elif compound_object.kind == "category": - create_category_scope(snapshot, compound_object) - elif compound_object.kind == "page": - # Contains deprecation info + if kind in _IGNORED_COMPOUNDS: pass - elif compound_object.kind == "protocol": - create_protocol_scope(snapshot, compound_object) - elif compound_object.kind == "interface": - create_interface_scope(snapshot, compound_object) + elif kind in _COMPOUND_HANDLERS: + _COMPOUND_HANDLERS[kind](snapshot, compound_object) else: - print(f"Unknown compound kind: {compound_object.kind}") + print(f"Unknown compound kind: {kind}") snapshot.finish() return snapshot diff --git a/scripts/cxx-api/parser/member.py b/scripts/cxx-api/parser/member.py deleted file mode 100644 index 7681b42dc1df..000000000000 --- a/scripts/cxx-api/parser/member.py +++ /dev/null @@ -1,546 +0,0 @@ -# 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 - -from abc import ABC, abstractmethod -from enum import IntEnum -from typing import TYPE_CHECKING - -from .template import Template, TemplateList -from .utils import ( - Argument, - format_arguments, - format_parsed_type, - parse_arg_string, - parse_function_pointer_argstring, - parse_type_with_argstrings, - qualify_arguments, - qualify_parsed_type, - qualify_template_args_only, - qualify_type_str, -) - -if TYPE_CHECKING: - from .scope import Scope - -STORE_INITIALIZERS_IN_SNAPSHOT = False - - -class MemberKind(IntEnum): - """ - Classification of member kinds for grouping in output. - The order here determines the output order within namespace scopes. - """ - - CONSTANT = 0 - TYPE_ALIAS = 1 - CONCEPT = 2 - FUNCTION = 3 - OPERATOR = 4 - VARIABLE = 5 - FRIEND = 6 - - -class Member(ABC): - def __init__(self, name: str, visibility: str) -> None: - self.name: str = name - self.visibility: str = visibility - self.template_list: TemplateList | None = None - - @property - @abstractmethod - def member_kind(self) -> MemberKind: - pass - - @abstractmethod - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - pass - - def close(self, scope: Scope): - pass - - def _get_qualified_name(self, qualification: str | None): - return f"{qualification}::{self.name}" if qualification else self.name - - def add_template(self, template: Template | [Template]) -> None: - if template and self.template_list is None: - self.template_list = TemplateList() - - if isinstance(template, list): - for t in template: - self.template_list.add(t) - else: - self.template_list.add(template) - - -class EnumMember(Member): - def __init__(self, name: str, value: str | None) -> None: - super().__init__(name, "public") - self.value: str | None = value - - @property - def member_kind(self) -> MemberKind: - return MemberKind.CONSTANT - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - - if not STORE_INITIALIZERS_IN_SNAPSHOT or self.value is None: - return " " * indent + f"{name}" - - return " " * indent + f"{name} = {self.value}" - - -class VariableMember(Member): - def __init__( - self, - name: str, - type: str, - visibility: str, - is_const: bool, - is_static: bool, - is_constexpr: bool, - is_mutable: bool, - value: str | None, - definition: str, - argstring: str | None = None, - is_brace_initializer: bool = False, - ) -> None: - super().__init__(name, visibility) - self.type: str = type - self.value: str | None = value - self.is_const: bool = is_const - self.is_static: bool = is_static - self.is_constexpr: bool = is_constexpr - self.is_mutable: bool = is_mutable - self.is_brace_initializer: bool = is_brace_initializer - self.definition: str = definition - self.argstring: str | None = argstring - self._fp_arguments: list[Argument] = ( - parse_function_pointer_argstring(argstring) if argstring else [] - ) - self._parsed_type: list[str | list[Argument]] = parse_type_with_argstrings(type) - - @property - def member_kind(self) -> MemberKind: - if self.is_const or self.is_constexpr: - return MemberKind.CONSTANT - return MemberKind.VARIABLE - - def close(self, scope: Scope): - self._fp_arguments = qualify_arguments(self._fp_arguments, scope) - self._parsed_type = qualify_parsed_type(self._parsed_type, scope) - # Qualify template arguments in variable name for explicit specializations - # e.g., "default_value" -> "default_value" - if "<" in self.name: - self.name = qualify_template_args_only(self.name, scope) - - def _is_function_pointer(self) -> bool: - """Check if this variable is a function pointer type.""" - return self.argstring is not None and self.argstring.startswith(")(") - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - - result = " " * indent - - if not hide_visibility: - result += self.visibility + " " - - if self.is_static: - result += "static " - - if self.is_constexpr: - result += "constexpr " - - if self.is_mutable: - result += "mutable " - - if self.is_const and not self.is_constexpr: - result += "const " - - if self._is_function_pointer(): - formatted_args = format_arguments(self._fp_arguments) - qualified_type = format_parsed_type(self._parsed_type) - # Function pointer types: argstring is ")(args...)" - # If type already contains "(*", e.g. "void *(*" or "void(*", use directly - # Otherwise add "(*" to form proper function pointer syntax - if "(*" in qualified_type: - result += f"{qualified_type}{name})({formatted_args})" - else: - result += f"{qualified_type} (*{name})({formatted_args})" - else: - result += f"{format_parsed_type(self._parsed_type)} {name}" - - if STORE_INITIALIZERS_IN_SNAPSHOT and self.value is not None: - if self.is_brace_initializer: - result += f"{{{self.value}}}" - else: - result += f" = {self.value}" - - result += ";" - - return result - - -class FunctionMember(Member): - def __init__( - self, - name: str, - type: str, - visibility: str, - arg_string: str, - is_virtual: bool, - is_pure_virtual: bool, - is_static: bool, - doxygen_params: list[Argument] | None = None, - is_constexpr: bool = False, - ) -> None: - super().__init__(name, visibility) - self.type: str = type - self.is_virtual: bool = is_virtual - self.is_static: bool = is_static - self.is_constexpr: bool = is_constexpr - parsed_arguments, self.modifiers = parse_arg_string(arg_string) - self.arguments = ( - doxygen_params if doxygen_params is not None else parsed_arguments - ) - - # Doxygen signals pure-virtual via the virt attribute, but the arg string - # may not contain "= 0" (e.g. trailing return type syntax), so the - # modifiers parsed from the arg string may miss it. Propagate the flag. - if is_pure_virtual: - self.modifiers.is_pure_virtual = True - - self.is_const = self.modifiers.is_const - self.is_override = self.modifiers.is_override - - @property - def member_kind(self) -> MemberKind: - if self.name.startswith("operator"): - return MemberKind.OPERATOR - return MemberKind.FUNCTION - - def close(self, scope: Scope): - self.type = qualify_type_str(self.type, scope) - self.arguments = qualify_arguments(self.arguments, scope) - # Qualify template arguments in function name for explicit specializations - # e.g., "convert" -> "convert" - if "<" in self.name: - self.name = qualify_template_args_only(self.name, scope) - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - result = "" - - if self.template_list is not None: - result += " " * indent + self.template_list.to_string() + "\n" - - result += " " * indent - - if not hide_visibility: - result += self.visibility + " " - - if self.is_virtual: - result += "virtual " - - if self.is_static: - result += "static " - - if self.is_constexpr: - result += "constexpr " - - if self.type: - result += f"{self.type} " - - result += f"{name}({format_arguments(self.arguments)})" - - if self.modifiers.is_const: - result += " const" - - if self.modifiers.is_noexcept: - if self.modifiers.noexcept_expr: - result += f" noexcept({self.modifiers.noexcept_expr})" - else: - result += " noexcept" - - if self.modifiers.is_override: - result += " override" - - if self.modifiers.is_final: - result += " final" - - if self.modifiers.is_pure_virtual: - result += " = 0" - elif self.modifiers.is_default: - result += " = default" - elif self.modifiers.is_delete: - result += " = delete" - - result += ";" - return result - - -class TypedefMember(Member): - def __init__( - self, name: str, type: str, argstring: str | None, visibility: str, keyword: str - ) -> None: - super().__init__(name, visibility) - self.keyword: str = keyword - self.argstring: str | None = argstring - - # Parse function pointer argstrings (e.g. ")(int x, float y)") - self._fp_arguments: list[Argument] = ( - parse_function_pointer_argstring(argstring) if argstring else [] - ) - - # Parse inline function signatures in the type so that argument - # lists are stored as structured data, not raw strings. - self._parsed_type: list[str | list[Argument]] = parse_type_with_argstrings(type) - self.type: str = type - - @property - def member_kind(self) -> MemberKind: - return MemberKind.TYPE_ALIAS - - def close(self, scope: Scope): - self._fp_arguments = qualify_arguments(self._fp_arguments, scope) - self._parsed_type = qualify_parsed_type(self._parsed_type, scope) - - def _is_function_pointer(self) -> bool: - """Check if this typedef is a function pointer type.""" - return self.argstring is not None and self.argstring.startswith(")(") - - def get_value(self) -> str: - if self.keyword == "using": - return format_parsed_type(self._parsed_type) - elif self._is_function_pointer(): - formatted_args = format_arguments(self._fp_arguments) - qualified_type = format_parsed_type(self._parsed_type) - if "(*" in qualified_type: - return f"{qualified_type})({formatted_args})" - else: - return f"{qualified_type}(*)({formatted_args})" - else: - return self.type - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - result = " " * indent - - if self.keyword == "using" and self.template_list is not None: - result += self.template_list.to_string() + "\n" + " " * indent - - if not hide_visibility: - result += self.visibility + " " - - result += self.keyword - - if self.keyword == "using": - result += f" {name} = {format_parsed_type(self._parsed_type)};" - elif self._is_function_pointer(): - formatted_args = format_arguments(self._fp_arguments) - qualified_type = format_parsed_type(self._parsed_type) - # Function pointer typedef: "typedef return_type (*name)(args);" - # type is e.g. "void(*", argstring is ")(args...)" - if "(*" in qualified_type: - result += f" {qualified_type}{name})({formatted_args});" - else: - result += f" {qualified_type}(*{name})({formatted_args});" - else: - result += f" {self.type} {name};" - - return result - - -class PropertyMember(Member): - def __init__( - self, - name: str, - type: str, - visibility: str, - is_static: bool, - accessor: str | None, - is_readable: bool, - is_writable: bool, - ) -> None: - super().__init__(name, visibility) - self.type: str = type - self.is_static: bool = is_static - self.accessor: str | None = accessor - self.is_readable: bool = is_readable - self.is_writable: bool = is_writable - - @property - def member_kind(self) -> MemberKind: - return MemberKind.VARIABLE - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - result = " " * indent - - if not hide_visibility: - result += self.visibility + " " - - attributes = [] - if self.accessor: - attributes.append(self.accessor) - if not self.is_writable and self.is_readable: - attributes.append("readonly") - - attrs_str = f"({', '.join(attributes)}) " if attributes else "" - - if self.is_static: - result += "static " - - # For block properties, name is embedded in the type (e.g., "void(^eventInterceptor)(args)") - if name: - result += f"@property {attrs_str}{self.type} {name};" - else: - result += f"@property {attrs_str}{self.type};" - - return result - - -class FriendMember(Member): - def __init__(self, name: str, visibility: str = "public") -> None: - super().__init__(name, visibility) - - @property - def member_kind(self) -> MemberKind: - return MemberKind.FRIEND - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - result = " " * indent - if not hide_visibility: - result += self.visibility + " " - result += f"friend {name};" - return result - - -class ConceptMember(Member): - def __init__( - self, - name: str, - constraint: str, - ) -> None: - super().__init__(name, "public") - self.constraint: str = self._normalize_constraint(constraint) - - @property - def member_kind(self) -> MemberKind: - return MemberKind.CONCEPT - - @staticmethod - def _normalize_constraint(constraint: str) -> str: - """ - Normalize the whitespace in a concept constraint expression. - - Doxygen preserves original source indentation, which becomes - inconsistent when we flatten namespaces and use qualified names. - This method normalizes the indentation by dedenting all lines - to the minimum non-empty indentation level. - """ - if not constraint: - return constraint - - lines = constraint.split("\n") - if len(lines) <= 1: - return constraint.strip() - - # Find minimum indentation (excluding the first line and empty lines) - min_indent = float("inf") - for line in lines[1:]: - stripped = line.lstrip() - if stripped: # Skip empty lines - indent = len(line) - len(stripped) - min_indent = min(min_indent, indent) - - if min_indent == float("inf"): - min_indent = 0 - - # Dedent all lines by the minimum indentation - result_lines = [lines[0].strip()] - for line in lines[1:]: - if line.strip(): # Non-empty line - # Remove the minimum indentation to normalize - dedented = ( - line[int(min_indent) :] - if len(line) >= min_indent - else line.lstrip() - ) - result_lines.append(dedented.rstrip()) - else: - result_lines.append("") - - # Check if no line is indented - if all(not line.startswith(" ") for line in result_lines): - # Re-indent all lines but the first by 2 spaces - not_indented = result_lines - result_lines = [not_indented[0]] - for line in not_indented[1:]: - if line.strip(): # Non-empty line - result_lines.append(" " + line) - else: - result_lines.append("") - - return "\n".join(result_lines) - - def close(self, scope: Scope): - # TODO: handle unqualified references - pass - - def to_string( - self, - indent: int = 0, - qualification: str | None = None, - hide_visibility: bool = False, - ) -> str: - name = self._get_qualified_name(qualification) - result = "" - - if self.template_list is not None: - result += " " * indent + self.template_list.to_string() + "\n" - - result += " " * indent + f"concept {name} = {self.constraint};" - - return result diff --git a/scripts/cxx-api/parser/member/__init__.py b/scripts/cxx-api/parser/member/__init__.py new file mode 100644 index 000000000000..7cf0b49ba053 --- /dev/null +++ b/scripts/cxx-api/parser/member/__init__.py @@ -0,0 +1,26 @@ +# 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 .base import Member, MemberKind, STORE_INITIALIZERS_IN_SNAPSHOT +from .concept_member import ConceptMember +from .enum_member import EnumMember +from .friend_member import FriendMember +from .function_member import FunctionMember +from .property_member import PropertyMember +from .typedef_member import TypedefMember +from .variable_member import VariableMember + +__all__ = [ + "ConceptMember", + "EnumMember", + "FriendMember", + "FunctionMember", + "Member", + "MemberKind", + "PropertyMember", + "STORE_INITIALIZERS_IN_SNAPSHOT", + "TypedefMember", + "VariableMember", +] diff --git a/scripts/cxx-api/parser/member/base.py b/scripts/cxx-api/parser/member/base.py new file mode 100644 index 000000000000..4edc05684b35 --- /dev/null +++ b/scripts/cxx-api/parser/member/base.py @@ -0,0 +1,86 @@ +# 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 + +from abc import ABC, abstractmethod +from enum import IntEnum +from typing import TYPE_CHECKING + +from ..template import Template, TemplateList + +if TYPE_CHECKING: + from ..scope import Scope + +STORE_INITIALIZERS_IN_SNAPSHOT = False + + +class MemberKind(IntEnum): + """ + Classification of member kinds for grouping in output. + The order here determines the output order within namespace scopes. + """ + + CONSTANT = 0 + TYPE_ALIAS = 1 + CONCEPT = 2 + FUNCTION = 3 + OPERATOR = 4 + VARIABLE = 5 + FRIEND = 6 + + +def is_function_pointer_argstring(argstring: str | None) -> bool: + """Check if an argstring indicates a function pointer type.""" + return argstring is not None and argstring.startswith(")(") + + +class Member(ABC): + def __init__(self, name: str, visibility: str) -> None: + self.name: str = name + self.visibility: str = visibility + self.template_list: TemplateList | None = None + self.specialization_args: list[str] | None = None + + @property + @abstractmethod + def member_kind(self) -> MemberKind: + pass + + @abstractmethod + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + pass + + def close(self, scope: Scope): + pass + + def _get_qualified_name(self, qualification: str | None) -> str: + name = self.name + if self.specialization_args is not None: + name = f"{name}<{', '.join(self.specialization_args)}>" + return f"{qualification}::{name}" if qualification else name + + def _qualify_specialization_args(self, scope: Scope) -> None: + if self.specialization_args is not None: + from ..utils import qualify_type_str + + self.specialization_args = [ + qualify_type_str(arg, scope) for arg in self.specialization_args + ] + + def add_template(self, template: Template | [Template]) -> None: + if template and self.template_list is None: + self.template_list = TemplateList() + + if isinstance(template, list): + for t in template: + self.template_list.add(t) + else: + self.template_list.add(template) diff --git a/scripts/cxx-api/parser/member/concept_member.py b/scripts/cxx-api/parser/member/concept_member.py new file mode 100644 index 000000000000..3e6c07a6e34f --- /dev/null +++ b/scripts/cxx-api/parser/member/concept_member.py @@ -0,0 +1,102 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base import Member, MemberKind + +if TYPE_CHECKING: + from ..scope import Scope + + +class ConceptMember(Member): + def __init__( + self, + name: str, + constraint: str, + ) -> None: + super().__init__(name, "public") + self.constraint: str = self._normalize_constraint(constraint) + + @property + def member_kind(self) -> MemberKind: + return MemberKind.CONCEPT + + @staticmethod + def _normalize_constraint(constraint: str) -> str: + """ + Normalize the whitespace in a concept constraint expression. + + Doxygen preserves original source indentation, which becomes + inconsistent when we flatten namespaces and use qualified names. + This method normalizes the indentation by dedenting all lines + to the minimum non-empty indentation level. + """ + if not constraint: + return constraint + + lines = constraint.split("\n") + if len(lines) <= 1: + return constraint.strip() + + # Find minimum indentation (excluding the first line and empty lines) + min_indent = float("inf") + for line in lines[1:]: + stripped = line.lstrip() + if stripped: # Skip empty lines + indent = len(line) - len(stripped) + min_indent = min(min_indent, indent) + + if min_indent == float("inf"): + min_indent = 0 + + # Dedent all lines by the minimum indentation + result_lines = [lines[0].strip()] + for line in lines[1:]: + if line.strip(): # Non-empty line + # Remove the minimum indentation to normalize + dedented = ( + line[int(min_indent) :] + if len(line) >= min_indent + else line.lstrip() + ) + result_lines.append(dedented.rstrip()) + else: + result_lines.append("") + + # Check if no line is indented + if all(not line.startswith(" ") for line in result_lines): + # Re-indent all lines but the first by 2 spaces + not_indented = result_lines + result_lines = [not_indented[0]] + for line in not_indented[1:]: + if line.strip(): # Non-empty line + result_lines.append(" " + line) + else: + result_lines.append("") + + return "\n".join(result_lines) + + def close(self, scope: Scope): + # TODO: handle unqualified references + pass + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + result = "" + + if self.template_list is not None: + result += " " * indent + self.template_list.to_string() + "\n" + + result += " " * indent + f"concept {name} = {self.constraint};" + + return result diff --git a/scripts/cxx-api/parser/member/enum_member.py b/scripts/cxx-api/parser/member/enum_member.py new file mode 100644 index 000000000000..a838b4221f75 --- /dev/null +++ b/scripts/cxx-api/parser/member/enum_member.py @@ -0,0 +1,31 @@ +# 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 + +from .base import Member, MemberKind, STORE_INITIALIZERS_IN_SNAPSHOT + + +class EnumMember(Member): + def __init__(self, name: str, value: str | None) -> None: + super().__init__(name, "public") + self.value: str | None = value + + @property + def member_kind(self) -> MemberKind: + return MemberKind.CONSTANT + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + + if not STORE_INITIALIZERS_IN_SNAPSHOT or self.value is None: + return " " * indent + f"{name}" + + return " " * indent + f"{name} = {self.value}" diff --git a/scripts/cxx-api/parser/member/friend_member.py b/scripts/cxx-api/parser/member/friend_member.py new file mode 100644 index 000000000000..0a00c03d7b7e --- /dev/null +++ b/scripts/cxx-api/parser/member/friend_member.py @@ -0,0 +1,30 @@ +# 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 + +from .base import Member, MemberKind + + +class FriendMember(Member): + def __init__(self, name: str, visibility: str = "public") -> None: + super().__init__(name, visibility) + + @property + def member_kind(self) -> MemberKind: + return MemberKind.FRIEND + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + result = " " * indent + if not hide_visibility: + result += self.visibility + " " + result += f"friend {name};" + return result diff --git a/scripts/cxx-api/parser/member/function_member.py b/scripts/cxx-api/parser/member/function_member.py new file mode 100644 index 000000000000..52929e427714 --- /dev/null +++ b/scripts/cxx-api/parser/member/function_member.py @@ -0,0 +1,123 @@ +# 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 + +from typing import TYPE_CHECKING + +from ..utils import ( + Argument, + format_arguments, + parse_arg_string, + qualify_arguments, + qualify_type_str, + split_specialization, +) +from .base import Member, MemberKind + +if TYPE_CHECKING: + from ..scope import Scope + + +class FunctionMember(Member): + def __init__( + self, + name: str, + type: str, + visibility: str, + arg_string: str, + is_virtual: bool, + is_pure_virtual: bool, + is_static: bool, + doxygen_params: list[Argument] | None = None, + is_constexpr: bool = False, + ) -> None: + base_name, specialization_args = split_specialization(name) + super().__init__(base_name, visibility) + self.specialization_args: list[str] | None = specialization_args + self.type: str = type + self.is_virtual: bool = is_virtual + self.is_static: bool = is_static + self.is_constexpr: bool = is_constexpr + parsed_arguments, self.modifiers = parse_arg_string(arg_string) + self.arguments = ( + doxygen_params if doxygen_params is not None else parsed_arguments + ) + + # Doxygen signals pure-virtual via the virt attribute, but the arg string + # may not contain "= 0" (e.g. trailing return type syntax), so the + # modifiers parsed from the arg string may miss it. Propagate the flag. + if is_pure_virtual: + self.modifiers.is_pure_virtual = True + + self.is_const = self.modifiers.is_const + self.is_override = self.modifiers.is_override + + @property + def member_kind(self) -> MemberKind: + if self.name.startswith("operator"): + return MemberKind.OPERATOR + return MemberKind.FUNCTION + + def close(self, scope: Scope): + self.type = qualify_type_str(self.type, scope) + self.arguments = qualify_arguments(self.arguments, scope) + self._qualify_specialization_args(scope) + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + result = "" + + if self.template_list is not None: + result += " " * indent + self.template_list.to_string() + "\n" + + result += " " * indent + + if not hide_visibility: + result += self.visibility + " " + + if self.is_virtual: + result += "virtual " + + if self.is_static: + result += "static " + + if self.is_constexpr: + result += "constexpr " + + if self.type: + result += f"{self.type} " + + result += f"{name}({format_arguments(self.arguments)})" + + if self.modifiers.is_const: + result += " const" + + if self.modifiers.is_noexcept: + if self.modifiers.noexcept_expr: + result += f" noexcept({self.modifiers.noexcept_expr})" + else: + result += " noexcept" + + if self.modifiers.is_override: + result += " override" + + if self.modifiers.is_final: + result += " final" + + if self.modifiers.is_pure_virtual: + result += " = 0" + elif self.modifiers.is_default: + result += " = default" + elif self.modifiers.is_delete: + result += " = delete" + + result += ";" + return result diff --git a/scripts/cxx-api/parser/member/property_member.py b/scripts/cxx-api/parser/member/property_member.py new file mode 100644 index 000000000000..46c90e97af98 --- /dev/null +++ b/scripts/cxx-api/parser/member/property_member.py @@ -0,0 +1,62 @@ +# 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 + +from .base import Member, MemberKind + + +class PropertyMember(Member): + def __init__( + self, + name: str, + type: str, + visibility: str, + is_static: bool, + accessor: str | None, + is_readable: bool, + is_writable: bool, + ) -> None: + super().__init__(name, visibility) + self.type: str = type + self.is_static: bool = is_static + self.accessor: str | None = accessor + self.is_readable: bool = is_readable + self.is_writable: bool = is_writable + + @property + def member_kind(self) -> MemberKind: + return MemberKind.VARIABLE + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + result = " " * indent + + if not hide_visibility: + result += self.visibility + " " + + attributes = [] + if self.accessor: + attributes.append(self.accessor) + if not self.is_writable and self.is_readable: + attributes.append("readonly") + + attrs_str = f"({', '.join(attributes)}) " if attributes else "" + + if self.is_static: + result += "static " + + # For block properties, name is embedded in the type (e.g., "void(^eventInterceptor)(args)") + if name: + result += f"@property {attrs_str}{self.type} {name};" + else: + result += f"@property {attrs_str}{self.type};" + + return result diff --git a/scripts/cxx-api/parser/member/typedef_member.py b/scripts/cxx-api/parser/member/typedef_member.py new file mode 100644 index 000000000000..8b6e12f28060 --- /dev/null +++ b/scripts/cxx-api/parser/member/typedef_member.py @@ -0,0 +1,95 @@ +# 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 + +from typing import TYPE_CHECKING + +from ..utils import ( + Argument, + format_arguments, + format_parsed_type, + parse_function_pointer_argstring, + parse_type_with_argstrings, + qualify_arguments, + qualify_parsed_type, +) +from .base import is_function_pointer_argstring, Member, MemberKind + +if TYPE_CHECKING: + from ..scope import Scope + + +class TypedefMember(Member): + def __init__( + self, name: str, type: str, argstring: str | None, visibility: str, keyword: str + ) -> None: + super().__init__(name, visibility) + self.keyword: str = keyword + self.argstring: str | None = argstring + + # Parse function pointer argstrings (e.g. ")(int x, float y)") + self._fp_arguments: list[Argument] = ( + parse_function_pointer_argstring(argstring) if argstring else [] + ) + + # Parse inline function signatures in the type so that argument + # lists are stored as structured data, not raw strings. + self._parsed_type: list[str | list[Argument]] = parse_type_with_argstrings(type) + self.type: str = type + + @property + def member_kind(self) -> MemberKind: + return MemberKind.TYPE_ALIAS + + def close(self, scope: Scope): + self._fp_arguments = qualify_arguments(self._fp_arguments, scope) + self._parsed_type = qualify_parsed_type(self._parsed_type, scope) + + def get_value(self) -> str: + if self.keyword == "using": + return format_parsed_type(self._parsed_type) + elif is_function_pointer_argstring(self.argstring): + formatted_args = format_arguments(self._fp_arguments) + qualified_type = format_parsed_type(self._parsed_type) + if "(*" in qualified_type: + return f"{qualified_type})({formatted_args})" + else: + return f"{qualified_type}(*)({formatted_args})" + else: + return self.type + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + result = " " * indent + + if self.keyword == "using" and self.template_list is not None: + result += self.template_list.to_string() + "\n" + " " * indent + + if not hide_visibility: + result += self.visibility + " " + + result += self.keyword + + if self.keyword == "using": + result += f" {name} = {format_parsed_type(self._parsed_type)};" + elif is_function_pointer_argstring(self.argstring): + formatted_args = format_arguments(self._fp_arguments) + qualified_type = format_parsed_type(self._parsed_type) + # Function pointer typedef: "typedef return_type (*name)(args);" + # type is e.g. "void(*", argstring is ")(args...)" + if "(*" in qualified_type: + result += f" {qualified_type}{name})({formatted_args});" + else: + result += f" {qualified_type}(*{name})({formatted_args});" + else: + result += f" {self.type} {name};" + + return result diff --git a/scripts/cxx-api/parser/member/variable_member.py b/scripts/cxx-api/parser/member/variable_member.py new file mode 100644 index 000000000000..8c456f6c1e17 --- /dev/null +++ b/scripts/cxx-api/parser/member/variable_member.py @@ -0,0 +1,123 @@ +# 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 + +from typing import TYPE_CHECKING + +from ..utils import ( + Argument, + format_arguments, + format_parsed_type, + parse_function_pointer_argstring, + parse_type_with_argstrings, + qualify_arguments, + qualify_parsed_type, + split_specialization, +) +from .base import ( + is_function_pointer_argstring, + Member, + MemberKind, + STORE_INITIALIZERS_IN_SNAPSHOT, +) + +if TYPE_CHECKING: + from ..scope import Scope + + +class VariableMember(Member): + def __init__( + self, + name: str, + type: str, + visibility: str, + is_const: bool, + is_static: bool, + is_constexpr: bool, + is_mutable: bool, + value: str | None, + definition: str, + argstring: str | None = None, + is_brace_initializer: bool = False, + ) -> None: + base_name, specialization_args = split_specialization(name) + super().__init__(base_name, visibility) + self.specialization_args: list[str] | None = specialization_args + self.type: str = type + self.value: str | None = value + self.is_const: bool = is_const + self.is_static: bool = is_static + self.is_constexpr: bool = is_constexpr + self.is_mutable: bool = is_mutable + self.is_brace_initializer: bool = is_brace_initializer + self.definition: str = definition + self.argstring: str | None = argstring + self._fp_arguments: list[Argument] = ( + parse_function_pointer_argstring(argstring) if argstring else [] + ) + self._parsed_type: list[str | list[Argument]] = parse_type_with_argstrings(type) + + @property + def member_kind(self) -> MemberKind: + if self.is_const or self.is_constexpr: + return MemberKind.CONSTANT + return MemberKind.VARIABLE + + def close(self, scope: Scope): + self._fp_arguments = qualify_arguments(self._fp_arguments, scope) + self._parsed_type = qualify_parsed_type(self._parsed_type, scope) + self._qualify_specialization_args(scope) + + def to_string( + self, + indent: int = 0, + qualification: str | None = None, + hide_visibility: bool = False, + ) -> str: + name = self._get_qualified_name(qualification) + + result = " " * indent + + if self.template_list is not None: + result += self.template_list.to_string() + "\n" + " " * indent + + if not hide_visibility: + result += self.visibility + " " + + if self.is_static: + result += "static " + + if self.is_constexpr: + result += "constexpr " + + if self.is_mutable: + result += "mutable " + + if self.is_const and not self.is_constexpr: + result += "const " + + if is_function_pointer_argstring(self.argstring): + formatted_args = format_arguments(self._fp_arguments) + qualified_type = format_parsed_type(self._parsed_type) + # Function pointer types: argstring is ")(args...)" + # If type already contains "(*", e.g. "void *(*" or "void(*", use directly + # Otherwise add "(*" to form proper function pointer syntax + if "(*" in qualified_type: + result += f"{qualified_type}{name})({formatted_args})" + else: + result += f"{qualified_type} (*{name})({formatted_args})" + else: + result += f"{format_parsed_type(self._parsed_type)} {name}" + + if STORE_INITIALIZERS_IN_SNAPSHOT and self.value is not None: + if self.is_brace_initializer: + result += f"{{{self.value}}}" + else: + result += f" = {self.value}" + + result += ";" + + return result diff --git a/scripts/cxx-api/parser/scope.py b/scripts/cxx-api/parser/scope.py deleted file mode 100644 index b4cc776cd055..000000000000 --- a/scripts/cxx-api/parser/scope.py +++ /dev/null @@ -1,530 +0,0 @@ -# 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 - -from abc import ABC, abstractmethod -from enum import Enum -from typing import Generic, TypeVar - -from natsort import natsort_keygen, natsorted - -from .member import FriendMember, Member, MemberKind, TypedefMember -from .template import Template, TemplateList -from .utils import parse_qualified_path, qualify_template_args_only, qualify_type_str - - -# Pre-create natsort key function for efficiency -_natsort_key = natsort_keygen() - - -class ScopeKind(ABC): - def __init__(self, name) -> None: - self.name: str = name - - @abstractmethod - def to_string(self, scope: Scope) -> str: - pass - - def close(self, scope: Scope) -> None: - """Called when the scope is closed. Override to perform cleanup.""" - pass - - def print_scope(self, scope: Scope) -> None: - print(self.to_string(scope)) - - -class StructLikeScopeKind(ScopeKind): - class Base: - def __init__( - self, name: str, protection: str, virtual: bool, refid: str - ) -> None: - self.name: str = name - self.protection: str = protection - self.virtual: bool = virtual - self.refid: str = refid - - class Type(Enum): - CLASS = "class" - STRUCT = "struct" - UNION = "union" - - def __init__(self, type: Type) -> None: - super().__init__(type.value) - - self.base_classes: [StructLikeScopeKind.Base] = [] - self.template_list: TemplateList | None = None - - def add_base( - self, base: StructLikeScopeKind.Base | [StructLikeScopeKind.Base] - ) -> None: - if isinstance(base, list): - for b in base: - self.base_classes.append(b) - else: - self.base_classes.append(base) - - def add_template(self, template: Template | [Template]) -> None: - if template and self.template_list is None: - self.template_list = TemplateList() - - if isinstance(template, list): - for t in template: - self.template_list.add(t) - else: - self.template_list.add(template) - - def close(self, scope: Scope) -> None: - """Qualify base class names and their template arguments.""" - for base in self.base_classes: - base.name = qualify_type_str(base.name, scope) - - def to_string(self, scope: Scope) -> str: - result = "" - - bases = [] - for base in self.base_classes: - base_text = [base.protection] - if base.virtual: - base_text.append("virtual") - base_text.append(base.name) - bases.append(" ".join(base_text)) - - inheritance_string = " : " + ", ".join(bases) if bases else "" - - if self.template_list is not None: - result += "\n" + self.template_list.to_string() + "\n" - result += f"{self.name} {scope.get_qualified_name()}{inheritance_string} {{" - - stringified_members = [] - for member in scope.get_members(): - stringified_members.append(member.to_string(2)) - stringified_members = natsorted(stringified_members) - result += ("\n" if len(stringified_members) > 0 else "") + "\n".join( - stringified_members - ) - - result += "\n}" - - return result - - -class NamespaceScopeKind(ScopeKind): - def __init__(self) -> None: - super().__init__("namespace") - - def to_string(self, scope: Scope) -> str: - qualification = scope.get_qualified_name() - - # Group members by kind - groups: dict[MemberKind, list[str]] = {kind: [] for kind in MemberKind} - - for member in scope.get_members(): - kind = member.member_kind - stringified = member.to_string(0, qualification, hide_visibility=True) - groups[kind].append(stringified) - - # Sort within each group and combine in kind order - result = [] - for kind in MemberKind: - sorted_group = natsorted(groups[kind]) - result.extend(sorted_group) - - return "\n".join(result) - - -class EnumScopeKind(ScopeKind): - def __init__(self) -> None: - super().__init__("enum") - self.type: str | None = None - - def to_string(self, scope: Scope) -> str: - result = "" - inheritance_string = f" : {self.type}" if self.type else "" - - result += ( - "\n" + f"{self.name} {scope.get_qualified_name()}{inheritance_string} {{" - ) - - stringified_members = [] - for member in scope.get_members(): - stringified_members.append(member.to_string(2) + ",") - - stringified_members = natsorted(stringified_members) - result += ("\n" if len(stringified_members) > 0 else "") + "\n".join( - stringified_members - ) - - result += "\n}" - - return result - - -class ProtocolScopeKind(ScopeKind): - class Base: - def __init__( - self, name: str, protection: str, virtual: bool, refid: str - ) -> None: - self.name: str = name - self.protection: str = protection - self.virtual: bool = virtual - self.refid: str = refid - - def __init__(self) -> None: - super().__init__("protocol") - self.base_classes: [ProtocolScopeKind.Base] = [] - - def add_base(self, base: ProtocolScopeKind.Base | [ProtocolScopeKind.Base]) -> None: - if isinstance(base, list): - for b in base: - self.base_classes.append(b) - else: - self.base_classes.append(base) - - def close(self, scope: Scope) -> None: - """Qualify base class names and their template arguments.""" - for base in self.base_classes: - base.name = qualify_type_str(base.name, scope) - - def to_string(self, scope: Scope) -> str: - result = "" - - bases = [] - for base in self.base_classes: - base_text = [base.protection] - if base.virtual: - base_text.append("virtual") - base_text.append(base.name) - bases.append(" ".join(base_text)) - - inheritance_string = " : " + ", ".join(bases) if bases else "" - - result += f"{self.name} {scope.get_qualified_name()}{inheritance_string} {{" - - stringified_members = [] - for member in scope.get_members(): - stringified_members.append(member.to_string(2)) - stringified_members = natsorted(stringified_members) - result += ("\n" if len(stringified_members) > 0 else "") + "\n".join( - stringified_members - ) - - result += "\n}" - - return result - - -class InterfaceScopeKind(ScopeKind): - class Base: - def __init__( - self, name: str, protection: str, virtual: bool, refid: str - ) -> None: - self.name: str = name - self.protection: str = protection - self.virtual: bool = virtual - self.refid: str = refid - - def __init__(self) -> None: - super().__init__("interface") - self.base_classes: [InterfaceScopeKind.Base] = [] - - def add_base( - self, base: InterfaceScopeKind.Base | [InterfaceScopeKind.Base] - ) -> None: - if isinstance(base, list): - for b in base: - self.base_classes.append(b) - else: - self.base_classes.append(base) - - def close(self, scope: Scope) -> None: - """Qualify base class names and their template arguments.""" - for base in self.base_classes: - base.name = qualify_type_str(base.name, scope) - - def to_string(self, scope: Scope) -> str: - result = "" - - bases = [] - for base in self.base_classes: - base_text = [base.protection] - if base.virtual: - base_text.append("virtual") - base_text.append(base.name) - bases.append(" ".join(base_text)) - - inheritance_string = " : " + ", ".join(bases) if bases else "" - - result += f"{self.name} {scope.get_qualified_name()}{inheritance_string} {{" - - stringified_members = [] - for member in scope.get_members(): - stringified_members.append(member.to_string(2)) - stringified_members = natsorted(stringified_members) - result += ("\n" if len(stringified_members) > 0 else "") + "\n".join( - stringified_members - ) - - result += "\n}" - - return result - - -class CategoryScopeKind(ScopeKind): - def __init__(self, class_name: str, category_name: str) -> None: - super().__init__("category") - self.class_name: str = class_name - self.category_name: str = category_name - - def to_string(self, scope: Scope) -> str: - result = f"{self.name} {self.class_name}({self.category_name}) {{" - - stringified_members = [] - for member in scope.get_members(): - stringified_members.append(member.to_string(2)) - stringified_members = natsorted(stringified_members) - result += ("\n" if len(stringified_members) > 0 else "") + "\n".join( - stringified_members - ) - - result += "\n}" - - return result - - -class TemporaryScopeKind(ScopeKind): - def __init__(self) -> None: - super().__init__("temporary") - - def to_string(self, scope: Scope) -> str: - raise RuntimeError("Temporary scope should not be printed") - - -ScopeKindT = TypeVar("ScopeKindT", bound=ScopeKind) - - -class Scope(Generic[ScopeKindT]): - def __init__(self, kind: ScopeKindT, name: str | None = None) -> None: - self.name: str | None = name - self.kind: ScopeKindT = kind - self.parent_scope: Scope | None = None - self.inner_scopes: dict[str, Scope] = {} - self.location: str | None = None - self._members: list[Member] = [] - self._private_typedefs: dict[str, TypedefMember] = {} - - def get_qualified_name(self) -> str: - """ - Get the qualified name of the scope, with template arguments qualified. - """ - path = [] - current_scope = self - while current_scope is not None: - if current_scope.name is not None: - # Qualify template arguments in the scope name if it has any - name = current_scope.name - if "<" in name and current_scope.parent_scope is not None: - name = qualify_template_args_only(name, current_scope.parent_scope) - path.append(name) - current_scope = current_scope.parent_scope - path.reverse() - return "::".join(path) - - def _get_base_name(self, name: str) -> str: - """Strip template arguments from a name for scope lookup.""" - angle_idx = name.find("<") - return name[:angle_idx] if angle_idx != -1 else name - - def qualify_name(self, name: str | None) -> str | None: - """ - Qualify a name with the relevant scope if possible. - Handles template arguments by stripping them for lookup but preserving - them in the output. - """ - if not name: - return None - - path = parse_qualified_path(name) - if not path: - return None - - current_scope = self - # Walk up to find a scope that contains the first path segment - # Check both inner_scopes AND members (for type aliases, etc.) - base_first = self._get_base_name(path[0]) - while current_scope is not None: - # Check if it's an inner scope - if base_first in current_scope.inner_scopes: - break - - # Skip self-qualification if name matches current scope's name - if ( - current_scope.name - and self._get_base_name(current_scope.name) == base_first - ): - current_scope = current_scope.parent_scope - continue - - # Check if it's a member (type alias, variable, etc.) - for m in current_scope._members: - if m.name == base_first and not isinstance(m, FriendMember): - prefix = current_scope.get_qualified_name() - return f"{prefix}::{name}" if prefix else name - - # Check private typedefs: substitute with the expanded definition - if len(path) == 1 and base_first in current_scope._private_typedefs: - return current_scope._private_typedefs[base_first].get_value() - - current_scope = current_scope.parent_scope - - if current_scope is None: - return None - - # Remember the scope where we found the first segment — its qualified - # name is the prefix that must precede the matched path segments. - anchor_scope = current_scope - - # Walk down through the path, tracking matched segments with original template args - matched_segments: list[str] = [] - for i, path_segment in enumerate(path): - base_name = self._get_base_name(path_segment) - if base_name in current_scope.inner_scopes: - matched_segments.append(path_segment) - current_scope = current_scope.inner_scopes[base_name] - elif any( - m.name == base_name and not isinstance(m, FriendMember) - for m in current_scope._members - ): - # Found as a member, assume following segments exist in the scope - prefix = "::".join(matched_segments) - suffix = "::".join(path[i:]) - anchor_prefix = anchor_scope.get_qualified_name() - if prefix: - if anchor_prefix: - return f"{anchor_prefix}::{prefix}::{suffix}" - return f"{prefix}::{suffix}" - else: - if anchor_prefix: - return f"{anchor_prefix}::{suffix}" - return suffix - else: - # Segment not found as an inner scope or a real member of - # the current scope. When inside a struct-like scope this - # typically means Doxygen's refid-based qualification - # incorrectly placed a type under a compound that does not - # actually contain it — for example a friend declaration or - # an inherited constructor reported as a member ref. Try - # to re-qualify from the remaining unmatched segments so the - # type resolves against the broader scope hierarchy. - if isinstance(current_scope.kind, StructLikeScopeKind): - remaining = "::".join(path[i:]) - return self.qualify_name(remaining) - return None - - # Return qualified name with preserved template arguments - prefix = anchor_scope.get_qualified_name() - if prefix: - return f"{prefix}::{'::'.join(matched_segments)}" - else: - return "::".join(matched_segments) - - def add_private_typedef(self, member: TypedefMember) -> None: - """ - Store a private typedef for use during type resolution. - - Private typedefs are not included in the snapshot output, but their - definitions are substituted for references to them in public members. - """ - self._private_typedefs[member.name] = member - - def add_member(self, member: Member | None) -> None: - """ - Add a member to the scope. - """ - if member is None: - return - self._members.append(member) - - def get_members(self) -> list[Member]: - """ - Get all members of the scope. - """ - return self._members - - def close(self) -> None: - """ - Close the scope by setting the kind of all temporary scopes. - """ - for typedef in self._private_typedefs.values(): - typedef.close(self) - - for member in self.get_members(): - member.close(self) - - self.kind.close(self) - - for _, inner_scope in self.inner_scopes.items(): - inner_scope.close() - - def to_string(self) -> str: - """ - Get the string representation of the scope. - """ - # Get this scope's content (e.g., class members, free functions, ...) - this_content = self.kind.to_string(self) - - # Separate inner scopes into namespaces and non-namespaces - # Keep (scope, string) tuples to sort by scope properties - namespace_scope_items: list[tuple[Scope, str]] = [] - non_namespace_scope_items: list[tuple[Scope, str]] = [] - - for _, inner_scope in self.inner_scopes.items(): - if inner_scope.name is None: - continue - inner_str = inner_scope.to_string() - if not inner_str.strip(): - continue - - if isinstance(inner_scope.kind, NamespaceScopeKind): - namespace_scope_items.append((inner_scope, inner_str)) - else: - non_namespace_scope_items.append((inner_scope, inner_str)) - - # Sort non-namespace scopes by depth (fewer :: first) then by string - def scope_sort_key(item: tuple[Scope, str]) -> tuple: - scope, string = item - depth = scope.get_qualified_name().count("::") - return (depth, _natsort_key(string)) - - non_namespace_scope_items.sort(key=scope_sort_key) - non_namespace_scope_strings = [s for _, s in non_namespace_scope_items] - namespace_scope_strings = [s for _, s in namespace_scope_items] - - # Build result: - # 1. Free members / this scope's content first - # 2. Non-namespace inner scopes (classes, structs, enums), sorted by depth - # 3. Namespace inner scopes, each separated by "\n\n\n" (two blank lines) - - local_parts = [] - if this_content.strip(): - local_parts.append(this_content) - local_parts.extend(non_namespace_scope_strings) - - # NOTE: Don't sort local_parts together - free members should come first - local_block = "\n\n".join(local_parts) - - # Combine with namespace scopes using one more blank line for clearer separation - all_blocks = [] - if local_block.strip(): - all_blocks.append(local_block) - all_blocks.extend(natsorted(namespace_scope_strings)) - - return "\n\n\n".join(all_blocks).strip() - - def print(self): - """ - Print a scope and its contents. - """ - print(self.to_string()) diff --git a/scripts/cxx-api/parser/scope/__init__.py b/scripts/cxx-api/parser/scope/__init__.py new file mode 100644 index 000000000000..350bad569ba4 --- /dev/null +++ b/scripts/cxx-api/parser/scope/__init__.py @@ -0,0 +1,27 @@ +# 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 .base_scope_kind import ScopeKind, ScopeKindT +from .category_scope_kind import CategoryScopeKind +from .enum_scope_kind import EnumScopeKind +from .interface_scope_kind import InterfaceScopeKind +from .namespace_scope_kind import NamespaceScopeKind +from .protocol_scope_kind import ProtocolScopeKind +from .scope import Scope +from .struct_like_scope_kind import StructLikeScopeKind +from .temporary_scope_kind import TemporaryScopeKind + +__all__ = [ + "CategoryScopeKind", + "EnumScopeKind", + "InterfaceScopeKind", + "NamespaceScopeKind", + "ProtocolScopeKind", + "Scope", + "ScopeKind", + "ScopeKindT", + "StructLikeScopeKind", + "TemporaryScopeKind", +] diff --git a/scripts/cxx-api/parser/scope/base_scope_kind.py b/scripts/cxx-api/parser/scope/base_scope_kind.py new file mode 100644 index 000000000000..02aa9720687b --- /dev/null +++ b/scripts/cxx-api/parser/scope/base_scope_kind.py @@ -0,0 +1,48 @@ +# 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 + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, TypeVar + +from natsort import natsort_keygen, natsorted + +if TYPE_CHECKING: + from .scope import Scope + +# Pre-create natsort key function for efficiency +_natsort_key = natsort_keygen() + + +class ScopeKind(ABC): + def __init__(self, name) -> None: + self.name: str = name + + @abstractmethod + def to_string(self, scope: Scope) -> str: + pass + + def close(self, scope: Scope) -> None: + """Called when the scope is closed. Override to perform cleanup.""" + pass + + def _format_scope_body(self, scope: Scope, member_suffix: str = "") -> str: + """Format the members list inside a scope's braces.""" + stringified_members = [ + member.to_string(2) + member_suffix for member in scope.get_members() + ] + stringified_members = natsorted(stringified_members) + result = "{" + if stringified_members: + result += "\n" + "\n".join(stringified_members) + result += "\n}" + return result + + def print_scope(self, scope: Scope) -> None: + print(self.to_string(scope)) + + +ScopeKindT = TypeVar("ScopeKindT", bound=ScopeKind) diff --git a/scripts/cxx-api/parser/scope/category_scope_kind.py b/scripts/cxx-api/parser/scope/category_scope_kind.py new file mode 100644 index 000000000000..f497525dc932 --- /dev/null +++ b/scripts/cxx-api/parser/scope/category_scope_kind.py @@ -0,0 +1,24 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base_scope_kind import ScopeKind + +if TYPE_CHECKING: + from .scope import Scope + + +class CategoryScopeKind(ScopeKind): + def __init__(self, class_name: str, category_name: str) -> None: + super().__init__("category") + self.class_name: str = class_name + self.category_name: str = category_name + + def to_string(self, scope: Scope) -> str: + header = f"{self.name} {self.class_name}({self.category_name}) " + return header + self._format_scope_body(scope) diff --git a/scripts/cxx-api/parser/scope/enum_scope_kind.py b/scripts/cxx-api/parser/scope/enum_scope_kind.py new file mode 100644 index 000000000000..9cab7a96d5e5 --- /dev/null +++ b/scripts/cxx-api/parser/scope/enum_scope_kind.py @@ -0,0 +1,24 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base_scope_kind import ScopeKind + +if TYPE_CHECKING: + from .scope import Scope + + +class EnumScopeKind(ScopeKind): + def __init__(self) -> None: + super().__init__("enum") + self.type: str | None = None + + def to_string(self, scope: Scope) -> str: + inheritance_string = f" : {self.type}" if self.type else "" + header = f"\n{self.name} {scope.get_qualified_name()}{inheritance_string} " + return header + self._format_scope_body(scope, member_suffix=",") diff --git a/scripts/cxx-api/parser/scope/extendable.py b/scripts/cxx-api/parser/scope/extendable.py new file mode 100644 index 000000000000..523b55357e79 --- /dev/null +++ b/scripts/cxx-api/parser/scope/extendable.py @@ -0,0 +1,45 @@ +# 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 + + +class Extendable: + class Base: + def __init__( + self, name: str, protection: str, virtual: bool, refid: str + ) -> None: + self.name: str = name + self.protection: str = protection + self.virtual: bool = virtual + self.refid: str = refid + + def __init__(self) -> None: + self.base_classes = [] + + def add_base(self, base: Base | list[Base]) -> None: + if isinstance(base, list): + for b in base: + self.base_classes.append(b) + else: + self.base_classes.append(base) + + def qualify_base_classes(self, scope) -> None: + """Qualify base class names and their template arguments.""" + from ..utils import qualify_type_str + + for base in self.base_classes: + base.name = qualify_type_str(base.name, scope) + + def get_inheritance_string(self) -> str: + bases = [] + for base in self.base_classes: + base_text = [base.protection] + if base.virtual: + base_text.append("virtual") + base_text.append(base.name) + bases.append(" ".join(base_text)) + + return (" : " + ", ".join(bases)) if bases else "" diff --git a/scripts/cxx-api/parser/scope/interface_scope_kind.py b/scripts/cxx-api/parser/scope/interface_scope_kind.py new file mode 100644 index 000000000000..00da13f33b38 --- /dev/null +++ b/scripts/cxx-api/parser/scope/interface_scope_kind.py @@ -0,0 +1,28 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base_scope_kind import ScopeKind +from .extendable import Extendable + +if TYPE_CHECKING: + from .scope import Scope + + +class InterfaceScopeKind(ScopeKind, Extendable): + def __init__(self) -> None: + ScopeKind.__init__(self, "interface") + Extendable.__init__(self) + + def close(self, scope: Scope) -> None: + self.qualify_base_classes(scope) + + def to_string(self, scope: Scope) -> str: + inheritance = self.get_inheritance_string() + header = f"{self.name} {scope.get_qualified_name()}{inheritance} " + return header + self._format_scope_body(scope) diff --git a/scripts/cxx-api/parser/scope/namespace_scope_kind.py b/scripts/cxx-api/parser/scope/namespace_scope_kind.py new file mode 100644 index 000000000000..0d7444be5473 --- /dev/null +++ b/scripts/cxx-api/parser/scope/namespace_scope_kind.py @@ -0,0 +1,40 @@ +# 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 + +from typing import TYPE_CHECKING + +from natsort import natsorted + +from ..member import MemberKind +from .base_scope_kind import ScopeKind + +if TYPE_CHECKING: + from .scope import Scope + + +class NamespaceScopeKind(ScopeKind): + def __init__(self) -> None: + super().__init__("namespace") + + def to_string(self, scope: Scope) -> str: + qualification = scope.get_qualified_name() + + # Group members by kind + groups: dict[MemberKind, list[str]] = {kind: [] for kind in MemberKind} + + for member in scope.get_members(): + kind = member.member_kind + stringified = member.to_string(0, qualification, hide_visibility=True) + groups[kind].append(stringified) + + # Sort within each group and combine in kind order + result = [] + for kind in MemberKind: + sorted_group = natsorted(groups[kind]) + result.extend(sorted_group) + + return "\n".join(result) diff --git a/scripts/cxx-api/parser/scope/protocol_scope_kind.py b/scripts/cxx-api/parser/scope/protocol_scope_kind.py new file mode 100644 index 000000000000..46a24984fbcd --- /dev/null +++ b/scripts/cxx-api/parser/scope/protocol_scope_kind.py @@ -0,0 +1,28 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base_scope_kind import ScopeKind +from .extendable import Extendable + +if TYPE_CHECKING: + from .scope import Scope + + +class ProtocolScopeKind(ScopeKind, Extendable): + def __init__(self) -> None: + ScopeKind.__init__(self, "protocol") + Extendable.__init__(self) + + def close(self, scope: Scope) -> None: + self.qualify_base_classes(scope) + + def to_string(self, scope: Scope) -> str: + inheritance = self.get_inheritance_string() + header = f"{self.name} {scope.get_qualified_name()}{inheritance} " + return header + self._format_scope_body(scope) diff --git a/scripts/cxx-api/parser/scope/scope.py b/scripts/cxx-api/parser/scope/scope.py new file mode 100644 index 000000000000..b7703683da48 --- /dev/null +++ b/scripts/cxx-api/parser/scope/scope.py @@ -0,0 +1,263 @@ +# 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 + +from typing import Generic + +from natsort import natsorted + +from ..member import FriendMember, Member, TypedefMember +from ..utils import parse_qualified_path, qualify_type_str +from .base_scope_kind import _natsort_key, ScopeKindT +from .enum_scope_kind import EnumScopeKind +from .namespace_scope_kind import NamespaceScopeKind +from .struct_like_scope_kind import StructLikeScopeKind + + +class Scope(Generic[ScopeKindT]): + def __init__(self, kind: ScopeKindT, name: str | None = None) -> None: + self.name: str | None = name + self.kind: ScopeKindT = kind + self.parent_scope: Scope | None = None + self.inner_scopes: dict[str, Scope] = {} + self.location: str | None = None + self._members: list[Member] = [] + self._private_typedefs: dict[str, TypedefMember] = {} + + def get_qualified_name(self) -> str: + """ + Get the qualified name of the scope, with template arguments qualified. + """ + path = [] + current_scope = self + while current_scope is not None: + if current_scope.name is not None: + name = current_scope.name + if ( + isinstance(current_scope.kind, StructLikeScopeKind) + and current_scope.kind.specialization_args is not None + ): + name = ( + f"{name}<{', '.join(current_scope.kind.specialization_args)}>" + ) + path.append(name) + current_scope = current_scope.parent_scope + path.reverse() + return "::".join(path) + + def _get_base_name(self, name: str) -> str: + """Strip template arguments from a name for scope lookup.""" + angle_idx = name.find("<") + return name[:angle_idx] if angle_idx != -1 else name + + def qualify_name(self, name: str | None) -> str | None: + """ + Qualify a name with the relevant scope if possible. + Handles template arguments by stripping them for lookup but preserving + them in the output. + """ + if not name: + return None + + path = parse_qualified_path(name) + if not path: + return None + + current_scope = self + # Walk up to find a scope that contains the first path segment + # Check both inner_scopes AND members (for type aliases, etc.) + base_first = self._get_base_name(path[0]) + while current_scope is not None: + # Check if it's an inner scope + if base_first in current_scope.inner_scopes: + break + + # Skip self-qualification if name matches current scope's name + if ( + current_scope.name + and self._get_base_name(current_scope.name) == base_first + ): + current_scope = current_scope.parent_scope + continue + + # Check if it's a member (type alias, variable, etc.) + for m in current_scope._members: + if m.name == base_first and not isinstance(m, FriendMember): + prefix = current_scope.get_qualified_name() + return f"{prefix}::{name}" if prefix else name + + # Check private typedefs: substitute with the expanded definition + if len(path) == 1 and base_first in current_scope._private_typedefs: + return current_scope._private_typedefs[base_first].get_value() + + current_scope = current_scope.parent_scope + + if current_scope is None: + return None + + # Remember the scope where we found the first segment — its qualified + # name is the prefix that must precede the matched path segments. + anchor_scope = current_scope + + # Walk down through the path, tracking matched segments with original template args + matched_segments: list[str] = [] + for i, path_segment in enumerate(path): + base_name = self._get_base_name(path_segment) + if base_name in current_scope.inner_scopes: + matched_segments.append(path_segment) + current_scope = current_scope.inner_scopes[base_name] + elif any( + m.name == base_name and not isinstance(m, FriendMember) + for m in current_scope._members + ) or any( + any(m.name == base_name for m in inner._members) + for inner in current_scope.inner_scopes.values() + if isinstance(inner.kind, EnumScopeKind) + ): + # Found as a member (or as an unscoped enum value accessible + # from the parent scope), assume following segments exist + prefix = "::".join(matched_segments) + suffix = "::".join(path[i:]) + anchor_prefix = anchor_scope.get_qualified_name() + if prefix: + if anchor_prefix: + return f"{anchor_prefix}::{prefix}::{suffix}" + return f"{prefix}::{suffix}" + else: + if anchor_prefix: + return f"{anchor_prefix}::{suffix}" + return suffix + else: + # Segment not found as an inner scope or a real member of + # the current scope. When inside a struct-like scope this + # typically means Doxygen's refid-based qualification + # incorrectly placed a type under a compound that does not + # actually contain it — for example a friend declaration or + # an inherited constructor reported as a member ref. Try + # to re-qualify from the remaining unmatched segments so the + # type resolves against the broader scope hierarchy. + if isinstance(current_scope.kind, StructLikeScopeKind): + remaining = "::".join(path[i:]) + return self.qualify_name(remaining) + return None + + # Return qualified name with preserved template arguments + prefix = anchor_scope.get_qualified_name() + if prefix: + return f"{prefix}::{'::'.join(matched_segments)}" + else: + return "::".join(matched_segments) + + def add_private_typedef(self, member: TypedefMember) -> None: + """ + Store a private typedef for use during type resolution. + + Private typedefs are not included in the snapshot output, but their + definitions are substituted for references to them in public members. + """ + self._private_typedefs[member.name] = member + + def add_member(self, member: Member | None) -> None: + """ + Add a member to the scope. + """ + if member is None: + return + self._members.append(member) + + def get_members(self) -> list[Member]: + """ + Get all members of the scope. + """ + return self._members + + def close(self) -> None: + """ + Close the scope by setting the kind of all temporary scopes. + """ + # Qualify specialization args early so that members and inner scopes + # see the fully-qualified name when they call get_qualified_name(). + if ( + isinstance(self.kind, StructLikeScopeKind) + and self.kind.specialization_args is not None + and self.parent_scope is not None + ): + self.kind.specialization_args = [ + qualify_type_str(arg, self.parent_scope) + for arg in self.kind.specialization_args + ] + + for typedef in self._private_typedefs.values(): + typedef.close(self) + + for member in self.get_members(): + member.close(self) + + self.kind.close(self) + + for _, inner_scope in self.inner_scopes.items(): + inner_scope.close() + + def to_string(self) -> str: + """ + Get the string representation of the scope. + """ + # Get this scope's content (e.g., class members, free functions, ...) + this_content = self.kind.to_string(self) + + # Separate inner scopes into namespaces and non-namespaces + # Keep (scope, string) tuples to sort by scope properties + namespace_scope_items: list[tuple[Scope, str]] = [] + non_namespace_scope_items: list[tuple[Scope, str]] = [] + + for _, inner_scope in self.inner_scopes.items(): + if inner_scope.name is None: + continue + inner_str = inner_scope.to_string() + if not inner_str.strip(): + continue + + if isinstance(inner_scope.kind, NamespaceScopeKind): + namespace_scope_items.append((inner_scope, inner_str)) + else: + non_namespace_scope_items.append((inner_scope, inner_str)) + + # Sort non-namespace scopes by depth (fewer :: first) then by string + def scope_sort_key(item: tuple[Scope, str]) -> tuple: + scope, string = item + depth = scope.get_qualified_name().count("::") + return (depth, _natsort_key(string)) + + non_namespace_scope_items.sort(key=scope_sort_key) + non_namespace_scope_strings = [s for _, s in non_namespace_scope_items] + namespace_scope_strings = [s for _, s in namespace_scope_items] + + # Build result: + # 1. Free members / this scope's content first + # 2. Non-namespace inner scopes (classes, structs, enums), sorted by depth + # 3. Namespace inner scopes, each separated by "\n\n\n" (two blank lines) + + local_parts = [] + if this_content.strip(): + local_parts.append(this_content) + local_parts.extend(non_namespace_scope_strings) + + # NOTE: Don't sort local_parts together - free members should come first + local_block = "\n\n".join(local_parts) + + # Combine with namespace scopes using one more blank line for clearer separation + all_blocks = [] + if local_block.strip(): + all_blocks.append(local_block) + all_blocks.extend(natsorted(namespace_scope_strings)) + + return "\n\n\n".join(all_blocks).strip() + + def print(self): + """ + Print a scope and its contents. + """ + print(self.to_string()) diff --git a/scripts/cxx-api/parser/scope/struct_like_scope_kind.py b/scripts/cxx-api/parser/scope/struct_like_scope_kind.py new file mode 100644 index 000000000000..3050cf4414b5 --- /dev/null +++ b/scripts/cxx-api/parser/scope/struct_like_scope_kind.py @@ -0,0 +1,56 @@ +# 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 + +from enum import Enum +from typing import TYPE_CHECKING + +from ..template import Template, TemplateList +from .base_scope_kind import ScopeKind +from .extendable import Extendable + +if TYPE_CHECKING: + from .scope import Scope + + +class StructLikeScopeKind(ScopeKind, Extendable): + class Type(Enum): + CLASS = "class" + STRUCT = "struct" + UNION = "union" + + def __init__( + self, type: Type, specialization_args: list[str] | None = None + ) -> None: + ScopeKind.__init__(self, type.value) + Extendable.__init__(self) + + self.template_list: TemplateList | None = None + self.specialization_args = specialization_args + + def add_template(self, template: Template | [Template]) -> None: + if template and self.template_list is None: + self.template_list = TemplateList() + + if isinstance(template, list): + for t in template: + self.template_list.add(t) + else: + self.template_list.add(template) + + def close(self, scope: Scope) -> None: + self.qualify_base_classes(scope) + + def to_string(self, scope: Scope) -> str: + result = "" + + if self.template_list is not None: + result += "\n" + self.template_list.to_string() + "\n" + + inheritance = self.get_inheritance_string() + result += f"{self.name} {scope.get_qualified_name()}{inheritance} " + result += self._format_scope_body(scope) + return result diff --git a/scripts/cxx-api/parser/scope/temporary_scope_kind.py b/scripts/cxx-api/parser/scope/temporary_scope_kind.py new file mode 100644 index 000000000000..04e3c9953a9c --- /dev/null +++ b/scripts/cxx-api/parser/scope/temporary_scope_kind.py @@ -0,0 +1,21 @@ +# 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 + +from typing import TYPE_CHECKING + +from .base_scope_kind import ScopeKind + +if TYPE_CHECKING: + from .scope import Scope + + +class TemporaryScopeKind(ScopeKind): + def __init__(self) -> None: + super().__init__("temporary") + + def to_string(self, scope: Scope) -> str: + raise RuntimeError("Temporary scope should not be printed") diff --git a/scripts/cxx-api/parser/snapshot.py b/scripts/cxx-api/parser/snapshot.py index 0ffb11df1ede..eca55a0bcb29 100644 --- a/scripts/cxx-api/parser/snapshot.py +++ b/scripts/cxx-api/parser/snapshot.py @@ -15,7 +15,7 @@ StructLikeScopeKind, TemporaryScopeKind, ) -from .utils import parse_qualified_path +from .utils import parse_qualified_path, split_specialization class Snapshot: @@ -46,19 +46,26 @@ def create_struct_like( scope_name = path[-1] current_scope = self.ensure_scope(scope_path) - if scope_name in current_scope.inner_scopes: - scope = current_scope.inner_scopes[scope_name] + base_name, specialization_args = split_specialization(scope_name) + + # Use the full name (including specialization) as the dict key so that + # base templates and their specializations are distinct entries. + scope_key = scope_name + + if scope_key in current_scope.inner_scopes: + scope = current_scope.inner_scopes[scope_key] if scope.kind.name == "temporary": - scope.kind = StructLikeScopeKind(type) + scope.kind = StructLikeScopeKind(type, specialization_args) + scope.name = base_name else: raise RuntimeError( - f"Identifier {scope_name} already exists in scope {current_scope.name}" + f"Identifier {scope_key} already exists in scope {current_scope.name}" ) return scope else: - new_scope = Scope(StructLikeScopeKind(type), scope_name) + new_scope = Scope(StructLikeScopeKind(type, specialization_args), base_name) new_scope.parent_scope = current_scope - current_scope.inner_scopes[scope_name] = new_scope + current_scope.inner_scopes[scope_key] = new_scope return new_scope def create_or_get_namespace(self, qualified_name: str) -> Scope[NamespaceScopeKind]: diff --git a/scripts/cxx-api/parser/utils/__init__.py b/scripts/cxx-api/parser/utils/__init__.py index 2a9b7536adf6..bf2ce030127e 100644 --- a/scripts/cxx-api/parser/utils/__init__.py +++ b/scripts/cxx-api/parser/utils/__init__.py @@ -9,9 +9,11 @@ format_arguments, format_parsed_type, FunctionModifiers, + has_scope_resolution_outside_angles, parse_arg_string, parse_function_pointer_argstring, parse_type_with_argstrings, + split_specialization, ) from .qualified_path import parse_qualified_path from .text_resolution import ( @@ -21,12 +23,7 @@ normalize_pointer_spacing, resolve_linked_text_name, ) -from .type_qualification import ( - qualify_arguments, - qualify_parsed_type, - qualify_template_args_only, - qualify_type_str, -) +from .type_qualification import qualify_arguments, qualify_parsed_type, qualify_type_str __all__ = [ "Argument", @@ -35,15 +32,17 @@ "format_arguments", "format_parsed_type", "FunctionModifiers", + "has_scope_resolution_outside_angles", "InitializerType", "normalize_angle_brackets", + "normalize_pointer_spacing", "parse_arg_string", "parse_function_pointer_argstring", "parse_qualified_path", "parse_type_with_argstrings", "qualify_arguments", "qualify_parsed_type", - "qualify_template_args_only", "qualify_type_str", "resolve_linked_text_name", + "split_specialization", ] diff --git a/scripts/cxx-api/parser/utils/argument_parsing.py b/scripts/cxx-api/parser/utils/argument_parsing.py index 5a540d3974b2..e9bcfb010eeb 100644 --- a/scripts/cxx-api/parser/utils/argument_parsing.py +++ b/scripts/cxx-api/parser/utils/argument_parsing.py @@ -116,6 +116,27 @@ def _find_matching_angle(s: str, start: int = 0) -> int: return _find_matching_bracket(s, start, "<", ">", ignore_inside="(") +def has_scope_resolution_outside_angles(name: str) -> bool: + """Check if '::' appears outside angle brackets in a name. + + Returns True for class-prefixed out-of-class definitions + (e.g. 'Strct< T >::VALUE') but False when '::' only appears inside + template arguments (e.g. 'func'). + """ + depth = 0 + i = 0 + while i < len(name): + ch = name[i] + if ch == "<": + depth += 1 + elif ch == ">": + depth -= 1 + elif ch == ":" and depth == 0 and i + 1 < len(name) and name[i + 1] == ":": + return True + i += 1 + return False + + def _iter_at_depth_zero(s: str): """Iterate over string, yielding (index, char, at_depth_zero) tuples. @@ -166,6 +187,22 @@ def _split_arguments(args_str: str) -> list[str]: return [arg for arg in result if arg] +def split_specialization(name: str) -> tuple[str, list[str] | None]: + """Split a potentially specialized name into base name and specialization args. + + Examples: + "Foo" -> ("Foo", None) + "Foo" -> ("Foo", ["int"]) + "Foo" -> ("Foo", ["int", "float"]) + "Foo>" -> ("Foo", ["Bar"]) + """ + angle_start = name.find("<") + if angle_start == -1 or not name.endswith(">"): + return (name, None) + args = _split_arguments(name[angle_start + 1 : -1]) + return (name[:angle_start], args if args else None) + + def _prefix_is_all_qualifiers(prefix: str) -> bool: """Check if all tokens in the prefix are type qualifiers/specifiers. diff --git a/scripts/cxx-api/parser/utils/text_resolution.py b/scripts/cxx-api/parser/utils/text_resolution.py index e9b67f64203e..89b8d1bcbe6b 100644 --- a/scripts/cxx-api/parser/utils/text_resolution.py +++ b/scripts/cxx-api/parser/utils/text_resolution.py @@ -15,41 +15,31 @@ from doxmlparser import compound +# Doxygen's encoding for special characters in refids, ordered longest-first +# to avoid partial matches during replacement. +_DOXYGEN_TEMPLATE_ENCODINGS = ( + ("_8_8_8", "..."), # Variadic ellipsis + ("_00", ", "), # Comma (with space for readability) + ("_01", " "), # Space + ("_02", "*"), # Pointer + ("_05", "="), # Equals + ("_06", "&"), # Reference + ("_07", "("), # Open paren + ("_08", ")"), # Close paren + ("_3", "<"), # Template open + ("_4", ">"), # Template close +) + + def decode_doxygen_template_encoding(encoded: str) -> str: """Decode Doxygen's encoding for template specializations in refids. - Doxygen encodes special characters in refids using underscore-prefixed codes: - - '_3' = '<' (template open) - - '_4' = '>' (template close) - - '_01' = ' ' (space) - - '_07' = '(' (open paren) - - '_08' = ')' (close paren) - - '_8_8_8' = '...' (variadic ellipsis) - - '_00' = ',' (comma) - - '_02' = '*' (pointer) - - '_05' = '=' (equals) - - '_06' = '&' (reference) - + Doxygen encodes special characters in refids using underscore-prefixed codes. e.g. 'SyncCallback_3_01R_07Args_8_8_8_08_4' -> 'SyncCallback< R(Args...)>' """ result = encoded - - # Process longer patterns first to avoid partial matches - result = result.replace("_8_8_8", "...") # Variadic ellipsis - - # Process two-char patterns (_0X codes) - result = result.replace("_00", ", ") # Comma (with space for readability) - result = result.replace("_01", " ") # Space - result = result.replace("_02", "*") # Pointer - result = result.replace("_05", "=") # Equals - result = result.replace("_06", "&") # Reference - result = result.replace("_07", "(") # Open paren - result = result.replace("_08", ")") # Close paren - - # Process single-char patterns last - result = result.replace("_3", "<") # Template open - result = result.replace("_4", ">") # Template close - + for pattern, replacement in _DOXYGEN_TEMPLATE_ENCODINGS: + result = result.replace(pattern, replacement) return result diff --git a/scripts/cxx-api/parser/utils/type_qualification.py b/scripts/cxx-api/parser/utils/type_qualification.py index 24b166050755..ded0978d56df 100644 --- a/scripts/cxx-api/parser/utils/type_qualification.py +++ b/scripts/cxx-api/parser/utils/type_qualification.py @@ -31,16 +31,6 @@ def qualify_type_str(type_str: str, scope: Scope) -> str: return _qualify_type_str_impl(type_str, scope, qualify_base=True) -def qualify_template_args_only(type_str: str, scope: Scope) -> str: - """Qualify only template arguments in a type string, leaving the base type unchanged. - - This is useful for class names in template specializations where the base type - is already positioned in the correct scope but the template arguments need - qualification (e.g., "MyVector< Test >" -> "MyVector< ns::Test >"). - """ - return _qualify_type_str_impl(type_str, scope, qualify_base=False) - - def _qualify_prefix_with_decorators(prefix: str, scope: Scope) -> str: """Qualify a template prefix that may have leading const/volatile qualifiers.""" stripped = prefix.lstrip() diff --git a/scripts/cxx-api/tests/snapshots/should_handle_free_variable/snapshot.api b/scripts/cxx-api/tests/snapshots/should_handle_free_variable/snapshot.api new file mode 100644 index 000000000000..98c7eb48b9e5 --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_free_variable/snapshot.api @@ -0,0 +1 @@ +int test::helloWorld; diff --git a/scripts/cxx-api/tests/snapshots/should_handle_free_variable/test.h b/scripts/cxx-api/tests/snapshots/should_handle_free_variable/test.h new file mode 100644 index 000000000000..1e0f3698376d --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_free_variable/test.h @@ -0,0 +1,14 @@ +/* + * 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. + */ + +#pragma once + +namespace test { + +extern int helloWorld; + +} // namespace test diff --git a/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/snapshot.api b/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/snapshot.api new file mode 100644 index 000000000000..3df3cf570cee --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/snapshot.api @@ -0,0 +1,6 @@ +struct folly::dynamic { +} + + +template +R test::jsArg(const folly::dynamic& arg, R(folly::dynamic::*asFoo)() const, const T &... desc); diff --git a/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/test.h b/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/test.h new file mode 100644 index 000000000000..8aaea82b5a17 --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_pointer_to_member_function_param/test.h @@ -0,0 +1,21 @@ +/* + * 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. + */ + +#pragma once + +namespace folly { + +struct dynamic {}; + +} // namespace folly + +namespace test { + +template +R jsArg(const folly::dynamic &arg, R (folly::dynamic::*asFoo)() const, const T &...desc); + +} // namespace test diff --git a/scripts/cxx-api/tests/snapshots/should_handle_template_variable/snapshot.api b/scripts/cxx-api/tests/snapshots/should_handle_template_variable/snapshot.api new file mode 100644 index 000000000000..a24a25f6de4a --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_template_variable/snapshot.api @@ -0,0 +1,4 @@ +template +struct test::Strct { + public static const test::Strct VALUE; +} diff --git a/scripts/cxx-api/tests/snapshots/should_handle_template_variable/test.h b/scripts/cxx-api/tests/snapshots/should_handle_template_variable/test.h new file mode 100644 index 000000000000..c1caf34d39df --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_handle_template_variable/test.h @@ -0,0 +1,20 @@ +/* + * 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. + */ + +#pragma once + +namespace test { + +template +struct Strct { + static const Strct VALUE; +}; + +template +const Strct Strct::VALUE = {}; + +} // namespace test diff --git a/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/snapshot.api b/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/snapshot.api new file mode 100644 index 000000000000..690ad506546f --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/snapshot.api @@ -0,0 +1,19 @@ +struct test::Event { +} + +enum test::Event::Type { + NodeAllocation, + NodeDeallocation, +} + +template +struct test::Event::TypedData { +} + +struct test::Event::TypedData { + public int config; +} + +struct test::Event::TypedData { + public int config; +} diff --git a/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/test.h b/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/test.h new file mode 100644 index 000000000000..b1c42974c792 --- /dev/null +++ b/scripts/cxx-api/tests/snapshots/should_qualify_enum_value_in_template_specialization/test.h @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#pragma once + +namespace test { + +struct Event { + enum Type { + NodeAllocation, + NodeDeallocation, + }; + + template + struct TypedData {}; +}; + +template <> +struct Event::TypedData { + int config; +}; + +template <> +struct Event::TypedData { + int config; +}; + +} // namespace test diff --git a/scripts/cxx-api/tests/snapshots/should_qualify_variable_template_specialization/snapshot.api b/scripts/cxx-api/tests/snapshots/should_qualify_variable_template_specialization/snapshot.api index 2d598603d29e..6248acdb5c77 100644 --- a/scripts/cxx-api/tests/snapshots/should_qualify_variable_template_specialization/snapshot.api +++ b/scripts/cxx-api/tests/snapshots/should_qualify_variable_template_specialization/snapshot.api @@ -1,5 +1,7 @@ -constexpr T test::default_value; constexpr test::MyType test::default_value; +template +constexpr T test::default_value; +template T* test::null_ptr; test::MyType* test::null_ptr;