Skip to content
7 changes: 4 additions & 3 deletions cppwg/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import shutil
import subprocess
import uuid
from pathlib import Path

import pygccxml

Expand Down Expand Up @@ -227,8 +226,10 @@ def log_unknown_classes(self) -> None:
source_locations = self.package_info._module_source_locations()

def in_source_locations(file_path: str) -> bool:
parents = Path(file_path).parents
return any(location in parents for location in source_locations)
return any(
utils.path_is_within(file_path, location)
for location in source_locations
)

seen_class_names = set()
for module_info in self.package_info.module_collection:
Expand Down
28 changes: 23 additions & 5 deletions cppwg/info/base_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,15 +254,25 @@ def hierarchy_attribute(self, attribute_name: str) -> Any:
Any
The attribute value, or None if not found.
"""
# Memoize by attribute name: this walks the class -> module -> package
# chain on every call, and is only read during generation, by which point
# the gathered config is fixed. Lazily created so any BaseInfo subclass
# works whether or not it ran BaseInfo.__init__.
cache = self.__dict__.setdefault("_hierarchy_attribute_cache", {})
if attribute_name in cache:
return cache[attribute_name]

value = getattr(self, attribute_name, None)
if value or isinstance(value, bool) or isinstance(value, Number):
return value

if self.parent is None:
result = value
elif self.parent is None:
# Reached the top of the hierarchy (i.e. PackageInfo)
return None
result = None
else:
result = self.parent.hierarchy_attribute(attribute_name)

return self.parent.hierarchy_attribute(attribute_name)
cache[attribute_name] = result
return result

def hierarchy_attribute_gather(self, attribute_name: str) -> list[Any]:
"""
Expand Down Expand Up @@ -319,10 +329,18 @@ def hierarchy_attribute_gather_flat(self, attribute_name: str) -> list[Any]:
list[Any]
The flattened list of items.
"""
# Memoize by attribute name (see hierarchy_attribute). The cached list is
# returned directly; callers only read/concatenate it, never mutate it.
cache = self.__dict__.setdefault("_hierarchy_gather_flat_cache", {})
if attribute_name in cache:
return cache[attribute_name]

flat: list[Any] = []
for value in self.hierarchy_attribute_gather(attribute_name):
if isinstance(value, (list, tuple, set)):
flat.extend(value)
else:
flat.append(value)

cache[attribute_name] = flat
return flat
61 changes: 48 additions & 13 deletions cppwg/info/module_info.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""Module information structure."""

from pathlib import Path
from typing import TYPE_CHECKING, Any

from pygccxml import declarations

from cppwg.info.base_info import BaseInfo
from cppwg.info.class_info import CppClassInfo
from cppwg.info.class_info import CppClassInfo, _unqualified_base_name
from cppwg.info.enum_info import CppEnumInfo
from cppwg.info.free_function_info import CppFreeFunctionInfo
from cppwg.utils import utils
Expand Down Expand Up @@ -174,11 +173,10 @@ def is_decl_in_source_path(self, decl: "declaration_t") -> bool:
if not self.source_locations:
return True

for location in self.source_locations:
if Path(location) in Path(decl.location.file_name).parents:
return True

return False
return any(
utils.path_is_within(decl.location.file_name, location)
for location in self.source_locations
)

def sort_classes(self) -> None:
"""
Expand Down Expand Up @@ -209,21 +207,58 @@ def sort_classes(self) -> None:
}

# Inheritance is a hard ordering constraint: a base precedes its
# subclasses.
# subclasses. Precompute each class's unqualified name and the set of
# unqualified names of the bases it declares, so the check is a set
# membership test (matching CppClassInfo.extends) rather than a per-pair
# scan over base_decls with a name reduction on each element.
unqualified_name = {cls: _unqualified_base_name(cls.name) for cls in classes}
declared_base_names = {
cls: {
_unqualified_base_name(base_decl.name)
for base_decl in cls.base_decls
if base_decl is not None
}
for cls in classes
}
for cls in classes:
base_names = declared_base_names[cls]
if not base_names:
continue
for other in classes:
if other is not cls and cls.extends(other):
if other is not cls and unqualified_name[other] in base_names:
predecessors[cls].add(other)

# Signature dependencies order a class after a wrapped type it uses,
# unless that contradicts an inheritance ordering already recorded.
# Argument type strings are gathered once per class to keep this cheap.
arg_types = {cls: cls.signature_arg_types() for cls in classes}
# Precompute, per class, its argument-type strings in canonical form and
# a compiled whole-token regex for its name, so the dependency test does
# not re-canonicalize and re-compile on every one of the ~C^2 pair
# comparisons.
canon_arg_types = {
cls: [
utils.canonicalize_type_whitespace(arg_type)
for arg_type in cls.signature_arg_types()
]
for cls in classes
}
name_regex = {cls: utils.compile_type_pattern(cls.name) for cls in classes}
requires_cache: dict[tuple[CppClassInfo, CppClassInfo], bool] = {}

def requires(a: CppClassInfo, b: CppClassInfo) -> bool:
return any(
utils.type_string_matches(arg_type, b.name) for arg_type in arg_types[a]
# Whether any of a's public method/constructor argument types name b
# as a whole token. Equivalent to
# `any(utils.type_string_matches(t, b.name) for t in a's arg types)`
# but reusing the precomputed canonical arg types and compiled regex.
key = (a, b)
cached = requires_cache.get(key)
if cached is not None:
return cached
regex = name_regex[b]
result = regex is not None and any(
regex.search(arg_type) for arg_type in canon_arg_types[a]
)
requires_cache[key] = result
return result

for cls in classes:
for other in classes:
Expand Down
24 changes: 14 additions & 10 deletions cppwg/info/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import os
import re
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any

from pygccxml import declarations
Expand Down Expand Up @@ -278,7 +277,7 @@ def collect_source_files(

# Skip files in restricted paths
if any(
Path(restricted_path) in Path(filepath).parents
utils.path_is_within(filepath, restricted_path)
for restricted_path in restricted_paths
):
continue
Expand Down Expand Up @@ -358,7 +357,10 @@ def collect_source_cpp(self, restricted_paths: list[str]) -> None:
cpp_files = [
filepath
for filepath in cpp_files
if any(location in Path(filepath).parents for location in locations)
if any(
utils.path_is_within(filepath, location)
for location in locations
)
]
self.source_cpp_files = cpp_files

Expand Down Expand Up @@ -760,7 +762,7 @@ def dependency(class_info: "CppClassInfo", decl) -> str | None:
c for c in module_info.class_collection if c.cpp_names or c.excluded
]

def _module_source_locations(self) -> list[Path]:
def _module_source_locations(self) -> list[str]:
"""
Return the source-location paths that scope the wrapped source tree.

Expand All @@ -773,15 +775,15 @@ def _module_source_locations(self) -> list[Path]:

Returns
-------
list[pathlib.Path]
list[str]
The directories that bound the project's own source files.
"""
locations: list[Path] = []
locations: list[str] = []
for module_info in self.module_collection:
if module_info.source_locations:
locations.extend(Path(loc) for loc in module_info.source_locations)
locations.extend(module_info.source_locations)
else:
locations.append(Path(self.source_root))
locations.append(self.source_root)
return locations

def _build_type_header_map(self) -> dict[str, str]:
Expand All @@ -807,8 +809,10 @@ def _build_type_header_map(self) -> dict[str, str]:
source_locations = self._module_source_locations()

def in_source_locations(file_path: str) -> bool:
parents = Path(file_path).parents
return any(location in parents for location in source_locations)
return any(
utils.path_is_within(file_path, location)
for location in source_locations
)

mapping: dict[str, str] = {}
ambiguous: set[str] = set()
Expand Down
9 changes: 4 additions & 5 deletions cppwg/parsers/source_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import logging
import os
from pathlib import Path

from pygccxml import declarations, parser
from pygccxml.declarations import declaration_t
Expand Down Expand Up @@ -117,7 +116,7 @@ def parse(self) -> namespace_t:
source_decls: list[declaration_t] = [
decl
for decl in filtered_decls
if Path(self.source_root) in Path(decl.location.file_name).parents
if utils.path_is_within(decl.location.file_name, self.source_root)
or decl.location.file_name == self.wrapper_header_collection
]

Expand Down Expand Up @@ -181,13 +180,13 @@ class name to the template argument lists found.

global_ns: namespace_t = declarations.get_global_namespace(decls)

source_file_real = os.path.realpath(source_file)

for class_decl in global_ns.classes(allow_empty=True):
# Keep only explicit instantiations defined in this file.
if class_decl.location is None:
continue
if os.path.realpath(class_decl.location.file_name) != os.path.realpath(
source_file
):
if os.path.realpath(class_decl.location.file_name) != source_file_real:
continue

if not declarations.templates.is_instantiation(class_decl.name):
Expand Down
80 changes: 72 additions & 8 deletions cppwg/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,18 +244,45 @@ def type_string_matches(type_string: str, pattern: str) -> bool:
bool
True if the pattern occurs in the type string as a whole token.
"""
regex = compile_type_pattern(pattern)
if regex is None:
return False

# Match on a whitespace-canonical form of the searched string so that
# differences in spacing around punctuation (which pygccxml and hand-written
# config may spell differently) do not defeat the match.
return regex.search(canonicalize_type_whitespace(type_string)) is not None


def compile_type_pattern(pattern: str) -> "re.Pattern | None":
"""
Compile a whole-token match regex for a C++ type pattern.

Returns a compiled regex that matches ``pattern`` as a whole token in a
*whitespace-canonical* type string (see :func:`type_string_matches`), or
``None`` if ``pattern`` is not a usable pattern (not a non-empty string, or
empty once canonicalized). Splitting the compile out lets a caller that
tests one pattern against many strings (e.g. class-dependency sorting)
canonicalize and compile the pattern once instead of on every comparison.

Parameters
----------
pattern : str
The type pattern to look for.

Returns
-------
re.Pattern | None
The compiled whole-token regex, or None if the pattern is unusable.
"""
# A non-string pattern (e.g. a yaml scalar like `arg_type_excludes: 5`) is
# not a valid type pattern; treat it as non-matching rather than crashing.
if not isinstance(pattern, str) or not pattern:
return False
return None

# Match on a whitespace-canonical form of both strings so that differences
# in spacing around punctuation (which pygccxml and hand-written config may
# spell differently) do not defeat the match.
type_string = canonicalize_type_whitespace(type_string)
pattern = canonicalize_type_whitespace(pattern)
if not pattern:
return False
return None

# Enforce an identifier boundary only on an edge whose pattern character is
# itself an identifier character. A pattern ending in e.g. > / * / & should
Expand All @@ -264,8 +291,45 @@ def type_string_matches(type_string: str, pattern: str) -> bool:
left = r"(?<![A-Za-z0-9_])" if _IDENTIFIER_CHAR.match(pattern[0]) else ""
right = r"(?![A-Za-z0-9_])" if _IDENTIFIER_CHAR.match(pattern[-1]) else ""

regex = left + re.escape(pattern) + right
return re.search(regex, type_string) is not None
return re.compile(left + re.escape(pattern) + right)


def path_is_within(path: str, ancestor: str) -> bool:
"""
Return whether ``path`` lies strictly beneath the directory ``ancestor``.

Equivalent to ``Path(ancestor) in Path(path).parents`` but implemented with
normalized-string comparison rather than allocating ``Path`` objects and
scanning the ``parents`` sequence. This is a cheaper equivalent used in hot
paths and may run during millions of times per generation on large projects.

Like ``Path.parents``, the test is *lexical* (no symlink resolution) and
*strict*: a path equal to ``ancestor`` is not "within" it. ``normcase`` is
applied so the comparison is case-insensitive on Windows, matching ``Path``.

Parameters
----------
path : str
The candidate descendant path.
ancestor : str
The directory that ``path`` may live beneath.

Returns
-------
bool
True if ``path`` is strictly beneath ``ancestor``.
"""
ancestor = os.path.normcase(os.path.normpath(ancestor))
path = os.path.normcase(os.path.normpath(path))
if path == ancestor:
return False
# Compare against the ancestor plus a trailing separator, so a sibling whose
# name merely starts with the ancestor (e.g. "/src2" under "/src") is not
# matched. A root such as "/" (or a Windows drive root) already ends in a
# separator after normpath, so do not append a second one.
if not ancestor.endswith(os.sep):
ancestor += os.sep
return path.startswith(ancestor)


def type_is_copy_assignable(decl_type: Any) -> bool:
Expand Down
Loading
Loading