Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (previously the dominant CPU cost of a large generation).
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
11 changes: 6 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,15 @@ class name to the template argument lists found.

global_ns: namespace_t = declarations.get_global_namespace(decls)

# realpath(source_file) is loop-invariant; resolve it once rather
# than per declaration (each realpath is a filesystem syscall).
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
72 changes: 64 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,37 @@ 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. The ``Path``-based form dominated
profiled generation time (millions of per-declaration and per-file checks),
so this cheaper equivalent is used on the hot paths.

Like ``Path.parents``, the test is *lexical* (no symlink resolution) and
*strict*: a path equal to ``ancestor`` is not "within" it.

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.normpath(ancestor)
path = os.path.normpath(path)
return path.startswith(ancestor + os.sep)


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