Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/developer/feature-test-map/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ https://github.com/TNO/Renaissance.Py/blob/main/LICENSE
- `test/syntax_tree/test_pattern_match.py`
- `test/syntax_tree/test_pattern_kind.py`
- `test/python/ast/test_python_matcher.py`
- `test/python/ast/test_python_pattern_type_parameters.py`
- `test/c_cpp/test_c_match_finder.py`
- **Code file(s):** `src/renaissance/syntax_tree/match_finder.py`, `src/renaissance/syntax_tree/pattern_kind.py`
- **Code file(s):** `src/renaissance/syntax_tree/match_finder.py`, `src/renaissance/syntax_tree/pattern_kind.py`,
`src/renaissance/integrations/python/ast/factory.py`

## 2. Rewrite semantics

Expand Down
8 changes: 8 additions & 0 deletions docs/user/features/pattern-matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Three cases
- Placeholders are AST Nodes
- Support of explicit and implicit placeholders

## Scenario: Placeholders for type parameters

- `[$$tps]` in `def $f[$$tps]($$args) -> $r:` or `class $c[$$tps]($$bases):` matches any number of type parameters
(`TypeVar`, `ParamSpec` and `TypeVarTuple`).
- `[$t]` matches exactly one type parameter.
- Each captured type parameter is an AST node.
- A type parameter written in the pattern, such as `[T]`, matches only that name.

## Scenario: Robustness for occurrence of implicit placeholders

- in strings, e.g. `"$X"`
Expand Down
7 changes: 6 additions & 1 deletion src/renaissance/integrations/python/ast/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
_MATCH_ALL_RE = re.compile(r"^" + re.escape(MATCH_ALL) + r"\w+$")
_MATCH_ONE_RE = re.compile(r"^" + re.escape(MATCH_ONE) + r"\w+$")

# TODO(FrancescoPezzella): a placeholder outside these kinds silently becomes a text match.
# https://github.com/TNO/Renaissance.Py/issues/387
_PLACEHOLDER_PARSER_KINDS = frozenset({"Name", "Expr", "arg", "Param", "TypeVar", "ParamSpec", "TypeVarTuple"})


class PythonPattern:
"""AI: Wrap a Python AST/RST node as a matchable pattern, detecting match-all/match-one placeholders."""
Expand Down Expand Up @@ -62,6 +66,7 @@ def __repr__(self) -> str:
return use_dollar(str(self.node))

def _derive_pattern_kind(self, node) -> None:
"""Set pattern_kind to MATCH_ONE or MATCH_ALL when node is a `$x` or `$$x` placeholder, including a type parameter."""
if isinstance(node, ast.arg):
signature = node.arg
elif isinstance(node, ast.Name):
Expand All @@ -73,7 +78,7 @@ def _derive_pattern_kind(self, node) -> None:
else:
signature = node.name

if node.parser_kind in {"Name", "Expr", "arg", "Param"}:
if node.parser_kind in _PLACEHOLDER_PARSER_KINDS:
if _MATCH_ALL_RE.match(signature):
self.pattern_kind = PatternKind.MATCH_ALL
elif _MATCH_ONE_RE.match(signature):
Expand Down
2 changes: 2 additions & 0 deletions src/renaissance/syntax_tree/match_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ def is_match_dict(src: dict, cmp: dict, expansions: dict | None = None) -> bool:
def match_property(n):
c = cmp.get(n)
s = src.get(n)
# TODO(FrancescoPezzella): a placeholder matched through a property is captured as text, not as a node.
# https://github.com/TNO/Renaissance.Py/issues/387
if isinstance(c, str) and (key := use_dollar(c)).startswith("$"):
return s == expansions[key][0] if key in expansions else (expansions.update({key: [s]}) or True)
return s == c
Expand Down
74 changes: 74 additions & 0 deletions test/python/ast/test_python_pattern_type_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copyright (C) 2024-2026 TNO
#
# Licensed under the terms specified in the LICENSE file in the root directory
# of this repository, also available at:
# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE

"""Tests for matching the PEP 695 type parameters of a function or class with `$` placeholders."""

import pytest
from hamcrest import assert_that, equal_to, has_length

from renaissance.integrations.python.ast.factory import PythonFactory, PythonPatternFactory
from renaissance.integrations.python.ast.rst_node import PythonRstNode
from renaissance.syntax_tree.match_finder import PatternMatch, match_pattern

FUNCTION_PATTERN = "def $f[$$tps]($$args) -> $r:\n $$body"
DECORATED_FUNCTION_PATTERN = "@$$decorators\ndef $f[$$tps]($$args) -> $r:\n $$body"
CLASS_PATTERN = "class $c[$$tps]($$bases):\n $$body"


def _find(pattern: str, code: str) -> list[PatternMatch]:
"""Return the matches of pattern in code, parsed with the RST backend."""
factory = PythonFactory(PythonRstNode)
root = factory.create_from_text(code, "demo.py")
return list(match_pattern([root], PythonPatternFactory(factory).create_statements(pattern)))


class TestPatternTypeParameters:
"""See module docstring."""

@pytest.mark.parametrize(
("pattern", "code", "expected_count"),
[
pytest.param(FUNCTION_PATTERN, "def f[_T](x: _T) -> _T:\n return x\n", 1, id="function_one"),
pytest.param(FUNCTION_PATTERN, "def f[A, B](a: A, b: B) -> A:\n return a\n", 2, id="function_two"),
pytest.param(FUNCTION_PATTERN, "def f[A, B, C](a: A) -> A:\n return a\n", 3, id="function_three"),
pytest.param(FUNCTION_PATTERN, "def f[_T: int](x: _T) -> _T:\n return x\n", 1, id="function_bound"),
pytest.param(FUNCTION_PATTERN, "def f[**_P](x) -> int:\n return 1\n", 1, id="function_param_spec"),
pytest.param(FUNCTION_PATTERN, "def f[*_Ts](x) -> int:\n return 1\n", 1, id="function_type_var_tuple"),
pytest.param(FUNCTION_PATTERN, "async def f[A, B](a: A) -> B:\n return a\n", 2, id="async_function"),
pytest.param(DECORATED_FUNCTION_PATTERN, "@d\ndef f[A, B](a: A) -> B:\n return a\n", 2, id="decorated_function"),
pytest.param(CLASS_PATTERN, "class C[_T]:\n a: _T\n", 1, id="class_one"),
pytest.param(CLASS_PATTERN, "class C[A, B]:\n a: A\n", 2, id="class_two"),
pytest.param(CLASS_PATTERN, "class C[A, B](Base):\n a: A\n", 2, id="class_with_base"),
],
)
def test_placeholder_captures_every_type_parameter(self, pattern: str, code: str, expected_count: int) -> None:
"""Verify `[$$tps]` matches the declaration and captures all of its type parameters, whatever their number or kind."""
matches = _find(pattern, code)

assert_that(matches, has_length(1))
assert_that(matches[0].expansions["$$tps"], has_length(expected_count))

def test_single_placeholder_captures_a_node(self) -> None:
"""Verify `[$t]` captures the type parameter as a node with its position, not as its name text."""
matches = _find("def $f[$t]($$args) -> $r:\n $$body", "def f[_T](x: _T) -> _T:\n return x\n")

assert_that(matches, has_length(1))
captured = matches[0].expansions["$t"][0]
assert_that(type(captured), equal_to(PythonRstNode))
assert_that(captured.signature, equal_to("_T"))

@pytest.mark.parametrize(
("code", "expected_count"),
[
pytest.param("def f[T](x: T) -> T:\n return x\n", 1, id="same_name"),
pytest.param("def f[_T](x: _T) -> _T:\n return x\n", 0, id="other_name"),
],
)
def test_written_type_parameter_matches_only_that_name(self, code: str, expected_count: int) -> None:
"""Verify a type parameter written in the pattern matches the same name only."""
matches = _find("def $f[T]($$args) -> $r:\n $$body", code)

assert_that(matches, has_length(expected_count))
Loading