diff --git a/docs/developer/feature-test-map/core.md b/docs/developer/feature-test-map/core.md index 5fb2f1d7..b5c71d2d 100644 --- a/docs/developer/feature-test-map/core.md +++ b/docs/developer/feature-test-map/core.md @@ -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 diff --git a/docs/user/features/pattern-matching.md b/docs/user/features/pattern-matching.md index d79ba777..ab2169c8 100644 --- a/docs/user/features/pattern-matching.md +++ b/docs/user/features/pattern-matching.md @@ -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"` diff --git a/src/renaissance/integrations/python/ast/factory.py b/src/renaissance/integrations/python/ast/factory.py index 86059d96..3d897100 100644 --- a/src/renaissance/integrations/python/ast/factory.py +++ b/src/renaissance/integrations/python/ast/factory.py @@ -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.""" @@ -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): @@ -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): diff --git a/src/renaissance/syntax_tree/match_finder.py b/src/renaissance/syntax_tree/match_finder.py index a494fd56..f62f8c5c 100644 --- a/src/renaissance/syntax_tree/match_finder.py +++ b/src/renaissance/syntax_tree/match_finder.py @@ -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 diff --git a/test/python/ast/test_python_pattern_type_parameters.py b/test/python/ast/test_python_pattern_type_parameters.py new file mode 100644 index 00000000..ad909fe6 --- /dev/null +++ b/test/python/ast/test_python_pattern_type_parameters.py @@ -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))