diff --git a/cxxheaderparser/parser.py b/cxxheaderparser/parser.py index 00fd9bf..6e15c6b 100644 --- a/cxxheaderparser/parser.py +++ b/cxxheaderparser/parser.py @@ -782,7 +782,10 @@ def _parse_template_specialization(self) -> TemplateSpecialization: if tmp_lex.has_tokens(): dtype = None - if self.lex.token_if("ELLIPSIS"): + ellipsis = self.lex.token_if("ELLIPSIS") + if ellipsis: + if not val.tokens: + raise self._parse_error(ellipsis, "template pack operand") param_pack = True if dtype: @@ -1824,6 +1827,52 @@ def _parse_pqname_name_operator(self) -> LexTokenList: self._consume_until(parts, "(") return parts + _standalone_operators = frozenset( + ( + "+ - * / % ^ & | ~ ! = < > += -= *= /= %= ^= &= |= << >> <<= >>= " + "== != <= >= <=> && || ++ -- , ->* -> () [] " + "new new[] delete delete[] co_await" + ).split() + ) + + def _parse_standalone_operator_name(self) -> NameSpecifier: + """Parse an operator-id without a following parameter list.""" + tok = self.lex.token() + if ( + tok.value in self._standalone_operators + or tok.value in ("(", "[") + or tok.type in {"STRING_LITERAL", "UD_STRING_LITERAL"} + ): + parts = [tok] + while not self.lex.token_peek_if(";"): + parts.append(self.lex.token()) + + # Separate the longest operator-id from any template arguments. + # In particular, '<' can belong to either part of the name. + for end in range(len(parts), 0, -1): + if end < len(parts) and parts[end].type != "<": + continue + op = "".join(part.value for part in parts[:end]) + if op not in self._standalone_operators and not re.fullmatch( + r'""[A-Za-z_][A-Za-z_0-9]*', op + ): + continue + self.lex.return_tokens(parts[end:]) + specialization = None + if self.lex.token_if("<"): + specialization = self._parse_template_specialization() + return NameSpecifier("operator" + op, specialization) + raise self._parse_error(tok, "operator name") + + # Conversion operators carry their target type separately in Method. + # Use the same type parser and canonical spelling for their name segment. + ctype, mods = self._parse_type(tok) + if ctype is None: + raise self._parse_error(tok, "conversion type") + mods.validate(var_ok=False, meth_ok=False, msg="parsing conversion operator") + target = self._parse_cv_ptr(ctype) + return NameSpecifier("operator " + target.format()) + _pqname_start_tokens = ( { "auto", @@ -1868,6 +1917,7 @@ def _parse_pqname( fn_ok: bool = False, compound_ok: bool = False, fund_ok: bool = False, + standalone: bool = False, ) -> typing.Tuple[PQName, typing.Optional[str]]: """ Parses a possibly qualified function name or a type name, returns when @@ -1876,6 +1926,7 @@ def _parse_pqname( :param fn_ok: Operator functions ok :param compound_ok: Compound types ok :param fund_ok: Fundamental types ok + :param standalone: Parse operators without a following parameter list qualified_id: ["::"] nested_name_specifier ["template"] unqualified_id | "::" IDENTIFIER @@ -1981,6 +2032,12 @@ def _parse_pqname( tok = self._next_token_must_be("NAME") tok_value = tok.value + if standalone and tok_value == "operator": + segments.append(self._parse_standalone_operator_name()) + break + if standalone and tok_value == "~": + tok_value += self._next_token_must_be("NAME").value + name, op = self._parse_pqname_name(tok_value) segments.append(name) if op: @@ -2220,6 +2277,20 @@ def _parse_type_id(self, tok: typing.Optional[LexToken], msg: str) -> TypeId: return dtype + def parse_name(self) -> PQName: + """Parse a standalone qualified declaration name.""" + try: + name, _ = self._parse_pqname(None, fn_ok=True, standalone=True) + if any(isinstance(segment, AutoSpecifier) for segment in name.segments): + raise self._parse_error(None, "declaration name") + self._next_token_must_be(";") + extra = self.lex.token_eof_ok() + if extra is not None: + raise self._parse_error(extra) + return name + except EOFError as exc: + raise CxxParseError("unexpected end of declaration name") from exc + def parse_typename(self) -> TypeId: """ Parse a single C++ type-id from the current token stream. diff --git a/cxxheaderparser/simple.py b/cxxheaderparser/simple.py index c8908ce..8d8ec1f 100644 --- a/cxxheaderparser/simple.py +++ b/cxxheaderparser/simple.py @@ -22,6 +22,41 @@ See below for the contents of the returned :class:`ParsedData`. +Looking up declarations +----------------------- + +Use :func:`get_declaration` to retrieve a parsed object by its qualified name: + +.. code-block:: python + + from cxxheaderparser.simple import get_declaration, parse_string + + data = parse_string(''' + namespace name { + /** A counter. */ + struct Bar { + /** Current count. */ + int count(); + }; + } + ''') + + print(get_declaration(data, "name::Bar::count").doxygen) + print(get_declaration(data, "name::Bar").class_decl.doxygen) + +Classes return :class:`ClassScope`, so their documentation is on +``class_decl.doxygen``. Invalid or missing names raise :class:`KeyError`; +multiple matches (including overloads) raise :class:`AmbiguousNameError`. +Lookup does not follow inheritance, aliases, or using declarations. + +Names are parsed with :func:`parse_name`, which returns a +:class:`~cxxheaderparser.types.PQName` and supports templates and operators +without parameter lists. Use :func:`parse_typename` for type-ids such as +``const C*`` instead. + +API reference +------------- + """ import os @@ -33,16 +68,21 @@ from dataclasses import dataclass, field from .types import ( + AnonymousName, ClassDecl, Concept, DeductionGuide, EnumDecl, + Enumerator, Field, ForwardDecl, FriendDecl, Function, Method, NamespaceAlias, + NameSpecifier, + PQName, + PQNameSegment, TemplateInst, TypeId, Typedef, @@ -57,6 +97,7 @@ ExternBlockState, NamespaceBlockState, ) +from .errors import CxxParseError from .parser import CxxParser from .options import ParserOptions from .visitor import null_visitor @@ -349,6 +390,27 @@ def parse_string( return visitor.data +def parse_name( + name: str, + *, + filename: str = "", + options: typing.Optional[ParserOptions] = None, +) -> PQName: + """Parse a qualified declaration name, returning a :class:`PQName`. + + Supports template arguments, constructors, destructors, and operator names + without parameter lists. Conversion operators use a final NameSpecifier + containing ``operator`` followed by a space and the formatted target type. + + Unlike :func:`parse_typename`, this parses names rather than type-ids: + ``C::operator bool`` is a name, whereas ``const C*`` is a type-id. + + :raises CxxParseError: The input is not a supported declaration name. + """ + parser = CxxParser(filename, f"{name};", null_visitor, options) + return parser.parse_name() + + def parse_typename( typename: str, *, @@ -386,3 +448,134 @@ def parse_file( parser.parse() return visitor.data + + +NamedDeclaration = typing.Union[ + NamespaceScope, + ClassScope, + EnumDecl, + Enumerator, + Function, + Field, + Typedef, + Variable, + ForwardDecl, + UsingAlias, + NamespaceAlias, + Concept, +] + + +class AmbiguousNameError(LookupError): + """Raised by :func:`get_declaration` when multiple declarations match a name.""" + + +def _qualified_name( + prefix: typing.List[PQNameSegment], name: typing.Union[str, PQName] +) -> typing.List[PQNameSegment]: + if isinstance(name, str): + if not name: + return [] + segments: typing.List[PQNameSegment] = [NameSpecifier(name)] + else: + segments = name.segments + if any(isinstance(segment, AnonymousName) for segment in segments): + return [] + if segments and segments[0] == NameSpecifier(""): + return segments[1:] + return prefix + segments + + +def _named_elements( + scope: Block, prefix: typing.List[PQNameSegment] +) -> typing.Iterator[typing.Tuple[typing.List[PQNameSegment], NamedDeclaration]]: + if isinstance(scope, NamespaceScope): + for name, ns in scope.namespaces.items(): + qualified = _qualified_name(prefix, name) + if qualified: + yield qualified, ns + yield from _named_elements(ns, qualified) + groups: typing.Iterable[typing.Iterable[NamedDeclaration]] = ( + scope.functions, + scope.method_impls, + scope.variables, + scope.ns_alias, + scope.concepts, + ) + else: + groups = (scope.methods, scope.fields) + + for group in (*groups, scope.typedefs, scope.using_alias, scope.forward_decls): + for element in group: + if isinstance(element, (UsingAlias, NamespaceAlias)): + element_name: typing.Union[str, PQName, None] = element.alias + elif isinstance(element, ForwardDecl): + element_name = element.typename + else: + # These groups contain only declarations with a name attribute. + element_name = typing.cast( + typing.Union[Function, Variable, Concept, Field, Typedef], element + ).name + if element_name is not None: + qualified = _qualified_name(prefix, element_name) + if qualified: + if ( + isinstance(element, Function) + and element.operator == "conversion" + ): + assert element.return_type is not None + qualified = qualified[:-1] + [ + NameSpecifier("operator " + element.return_type.format()) + ] + yield qualified, element + + for enum in scope.enums: + qualified = _qualified_name(prefix, enum.typename) + if qualified: + yield qualified, enum + for value in enum.values: + yield qualified + [NameSpecifier(value.name)], value + + for cls in scope.classes: + qualified = _qualified_name(prefix, cls.class_decl.typename) + if qualified: + yield qualified, cls + yield from _named_elements(cls, qualified) + + +def get_declaration(data: ParsedData, qualified_name: str) -> NamedDeclaration: + """Find a named declaration in parsed data. + + Names are relative to the global namespace; a leading ``::`` is optional. + Returns the original object, including :class:`ClassScope` for classes + and :class:`NamespaceScope` for namespaces. Enum values are addressed as + ``Enum::value``. Names are parsed with :func:`parse_name` and compared + structurally, allowing whitespace in qualified names and template arguments. + Operator names are supported, including conversions such as + ``C::operator bool`` or ``C::operator const int*``. + + This is declaration lookup, not C++ semantic name resolution: inheritance, + using-directives/declarations, alias targets, and inline/anonymous namespace + injection are not followed. Anonymous declarations, friends, deduction + guides, and explicit template instantiations are not searched. Qualified + declarations are matched even when stored outside their named scope. + + :raises KeyError: The name is invalid or no declaration matches. + :raises AmbiguousNameError: Multiple declarations match. Overloads, + repeated declarations, and forward declarations are not merged. + """ + try: + name = _qualified_name([], parse_name(qualified_name)) + except CxxParseError as exc: + raise KeyError(qualified_name) from exc + match: typing.Optional[NamedDeclaration] = None + for candidate, element in _named_elements(data.namespace, []): + if candidate == name: + if match is not None: + raise AmbiguousNameError( + f"Multiple declarations match {qualified_name!r}" + ) + match = element + if match is None: + raise KeyError(qualified_name) + return match diff --git a/tests/test_get_declaration.py b/tests/test_get_declaration.py new file mode 100644 index 0000000..eb418cb --- /dev/null +++ b/tests/test_get_declaration.py @@ -0,0 +1,227 @@ +import pytest + +from cxxheaderparser import simple +from cxxheaderparser.types import ( + FundamentalSpecifier, + Function, + Method, + NameSpecifier, + Parameter, + PQName, + Type, +) + + +def test_get_declaration_doxygen(): + data = simple.parse_string(""" + namespace name { + /** @brief My first function. */ + int foo(int a); + /** This is a class */ + class Bar { + public: + /** @brief Some counter */ + int count(); + }; + } + """) + assert simple.get_declaration(data, "name::foo") == Function( + return_type=Type(PQName([FundamentalSpecifier("int")])), + name=PQName([NameSpecifier("foo")]), + parameters=[ + Parameter(type=Type(PQName([FundamentalSpecifier("int")])), name="a") + ], + doxygen="/** @brief My first function. */", + ) + ns = data.namespace.namespaces["name"] + assert simple.get_declaration(data, "name::foo") is ns.functions[0] + assert simple.get_declaration(data, "name::Bar::count") is ns.classes[0].methods[0] + cls = simple.get_declaration(data, "name::Bar") + assert cls is data.namespace.namespaces["name"].classes[0] + assert cls.class_decl.doxygen == "/** This is a class */" + assert simple.get_declaration(data, "name::Bar::count") == Method( + return_type=Type(PQName([FundamentalSpecifier("int")])), + name=PQName([NameSpecifier("count")]), + parameters=[], + access="public", + doxygen="/** @brief Some counter */", + ) + + +@pytest.mark.parametrize("prefix", ["", "::"]) +def test_get_declaration_nested_scopes(prefix): + data = simple.parse_string( + "namespace a { namespace b { struct Outer { struct Inner { int x; }; }; } }" + ) + ns = data.namespace.namespaces["a"].namespaces["b"] + inner = ns.classes[0].classes[0] + assert simple.get_declaration(data, prefix + "a::b") is ns + assert simple.get_declaration(data, prefix + "a::b::Outer::Inner") is inner + assert ( + simple.get_declaration(data, prefix + "a::b::Outer::Inner::x") + is inner.fields[0] + ) + + +@pytest.mark.parametrize( + "name", ["missing", "n::missing", "x::member", "", "::", "n::"] +) +def test_get_declaration_missing(name): + data = simple.parse_string("int x; namespace n { int x; }") + with pytest.raises(KeyError) as exc: + simple.get_declaration(data, name) + assert name in str(exc.value) + + +@pytest.mark.parametrize( + "source, name", + [ + ("void f(); void f(int);", "f"), + ("void f(); void f();", "f"), + ("struct C { void f(); void f(int); };", "C::f"), + ("struct C; struct C {};", "C"), + ], +) +def test_get_declaration_ambiguous(source, name): + data = simple.parse_string(source) + with pytest.raises(LookupError, match=name) as exc: + simple.get_declaration(data, name) + assert isinstance(exc.value, simple.AmbiguousNameError) + + +def test_get_declaration_named_declarations(): + data = simple.parse_string(""" + namespace n { + int v; + typedef int T; + using A = int; + struct F; + enum E { one }; + namespace alias = n; + template concept C = true; + struct S { typedef int T; using A = int; struct F; enum E { one }; }; + } + """) + ns = data.namespace.namespaces["n"] + for scope, prefix in [(ns, "n"), (ns.classes[0], "n::S")]: + for name, expected in [ + ("T", scope.typedefs[0]), + ("A", scope.using_alias[0]), + ("F", scope.forward_decls[0]), + ("E", scope.enums[0]), + ("E::one", scope.enums[0].values[0]), + ]: + assert simple.get_declaration(data, prefix + "::" + name) is expected + assert simple.get_declaration(data, "n::v") is ns.variables[0] + assert simple.get_declaration(data, "n::alias") is ns.ns_alias[0] + assert simple.get_declaration(data, "n::C") is ns.concepts[0] + with pytest.raises(KeyError): + simple.get_declaration(data, "n::alias::v") + + +def test_get_declaration_qualified_declarations(): + data = simple.parse_string( + "namespace n {} struct n::C { void f(); }; void n::C::f() {}" + ) + assert simple.get_declaration(data, "n::C") is data.namespace.classes[0] + with pytest.raises(simple.AmbiguousNameError): + simple.get_declaration(data, "n::C::f") + + +def test_get_declaration_absolute_declaration(): + data = simple.parse_string( + "namespace n { namespace m { struct C; } struct ::n::m::C { int x; }; }" + ) + cls = data.namespace.namespaces["n"].classes[0] + assert simple.get_declaration(data, "n::m::C::x") is cls.fields[0] + with pytest.raises(KeyError): + simple.get_declaration(data, "n::n::m::C::x") + + +def test_get_declaration_skips_anonymous_declarations(): + data = simple.parse_string( + "namespace { int hidden; } struct { int hidden; } value; " + "enum { hidden }; struct C { int : 1; int x; };" + ) + assert simple.get_declaration(data, "value") is data.namespace.variables[0] + assert simple.get_declaration(data, "C::x") is data.namespace.classes[1].fields[1] + with pytest.raises(KeyError): + simple.get_declaration(data, "hidden") + + +def test_get_declaration_specialization(): + data = simple.parse_string( + "template struct C {}; template<> struct C { int x; };" + ) + assert simple.get_declaration(data, "C") is data.namespace.classes[0] + assert ( + simple.get_declaration(data, "C::x") is data.namespace.classes[1].fields[0] + ) + + +@pytest.mark.parametrize("target", ["bool", "const int*", "n::T"]) +def test_get_declaration_conversion_operator(target): + data = simple.parse_string( + "struct C { operator bool(); operator const int*(); operator n::T(); };" + ) + index = ["bool", "const int*", "n::T"].index(target) + assert ( + simple.get_declaration(data, "C::operator " + target) + is data.namespace.classes[0].methods[index] + ) + + +@pytest.mark.parametrize("name", [" C < int > :: x ", "::C::x"]) +def test_get_declaration_normalizes_template_name(name): + data = simple.parse_string("template<> struct C { int x; };") + assert simple.get_declaration(data, name) is data.namespace.classes[0].fields[0] + + +@pytest.mark.parametrize( + "name, index", + [ + (" C :: C ", 0), + ("C :: ~C", 1), + ("C :: ~ C", 1), + (" C :: operator + ", 2), + ("C :: operator ( )", 3), + ("C :: operator [ ]", 4), + ("C :: operator int const *", 5), + ], +) +def test_get_declaration_normalizes_method_name(name, index): + data = simple.parse_string( + "struct C { C(); ~C(); int operator+(int); int operator()(); " + "int operator[](int); operator const int*(); };" + ) + assert ( + simple.get_declaration(data, name) is data.namespace.classes[0].methods[index] + ) + + +@pytest.mark.parametrize( + "name", ["C<", "C<...>", "C*", "C::operator.", "C::operator bool()"] +) +def test_get_declaration_invalid_name(name): + data = simple.parse_string("struct C {};") + with pytest.raises(KeyError) as exc: + simple.get_declaration(data, name) + assert exc.value.args == (name,) + + +def test_get_declaration_operator_template(): + data = simple.parse_string("struct C { template<> int operator()(); };") + assert ( + simple.get_declaration(data, "C::operator () < int >") + is data.namespace.classes[0].methods[0] + ) + + +def test_get_declaration_does_not_search_unrelated_scopes(): + data = simple.parse_string("int x; namespace n { int x; } struct C { int x; };") + assert simple.get_declaration(data, "x") is data.namespace.variables[0] + assert ( + simple.get_declaration(data, "n::x") + is data.namespace.namespaces["n"].variables[0] + ) + assert simple.get_declaration(data, "C::x") is data.namespace.classes[0].fields[0] diff --git a/tests/test_parse_name.py b/tests/test_parse_name.py new file mode 100644 index 0000000..e35ad2a --- /dev/null +++ b/tests/test_parse_name.py @@ -0,0 +1,129 @@ +import pytest + +from cxxheaderparser import simple +from cxxheaderparser.errors import CxxParseError +from cxxheaderparser.types import ( + FundamentalSpecifier, + NameSpecifier, + PQName, + TemplateArgument, + TemplateSpecialization, + Type, +) + + +@pytest.mark.parametrize( + "text, segments", + [ + ("foo", ["foo"]), + (" :: name :: Bar :: count ", ["", "name", "Bar", "count"]), + ("C::C", ["C", "C"]), + ("C::~C", ["C", "~C"]), + ("C::~ C", ["C", "~C"]), + ], +) +def test_parse_name(text, segments): + assert simple.parse_name(text) == PQName([NameSpecifier(s) for s in segments]) + + +def test_parse_name_nested_template(): + argument = Type(PQName([NameSpecifier("ns"), NameSpecifier("T")])) + inner = Type( + PQName( + [ + NameSpecifier( + "Inner", TemplateSpecialization([TemplateArgument(argument)]) + ) + ] + ) + ) + assert simple.parse_name("Outer< Inner< ns :: T > >::member") == PQName( + [ + NameSpecifier("Outer", TemplateSpecialization([TemplateArgument(inner)])), + NameSpecifier("member"), + ] + ) + + +@pytest.mark.parametrize( + "text, name", + [ + ("operator +", "operator+"), + ("operator ()", "operator()"), + ("operator [ ]", "operator[]"), + ("operator <<", "operator<<"), + ("operator &&", "operator&&"), + ("operator ||", "operator||"), + ("operator ->", "operator->"), + ("operator ->*", "operator->*"), + ("operator <=>", "operator<=>"), + ("operator ~", "operator~"), + ("operator new []", "operatornew[]"), + ("operator delete", "operatordelete"), + ("operator co_await", "operatorco_await"), + ('operator "" _suffix', 'operator""_suffix'), + ("operator bool", "operator bool"), + ("operator int const *", "operator const int*"), + ("operator ns :: T &", "operator ns::T&"), + ("operator Container< ns::T >", "operator Container"), + ], +) +def test_parse_name_operator(text, name): + assert simple.parse_name("C::" + text) == PQName( + [NameSpecifier("C"), NameSpecifier(name)] + ) + assert simple.parse_name(text) == PQName([NameSpecifier(name)]) + + +@pytest.mark.parametrize( + "text", + [ + "", + "::", + "n::", + "C<", + "C") == PQName( + [ + NameSpecifier("C"), + NameSpecifier( + "operator" + operator, + TemplateSpecialization( + [TemplateArgument(Type(PQName([FundamentalSpecifier("int")])))] + ), + ), + ] + ) + + +def test_parse_name_rejects_empty_pack(): + with pytest.raises(CxxParseError): + simple.parse_name("C<...>") + + +def test_parse_typename_still_rejects_operators(): + with pytest.raises(CxxParseError): + simple.parse_typename("C::operator bool")