Skip to content

Commit 7546770

Browse files
authored
Python: Add context-insensitive templating mechanism (#6681)
Introduces a templating system for rewrite-python, inspired by JavaTemplate and the JavaScript implementation. The system provides: - Template class for AST generation from code snippets with placeholders - Pattern class for matching AST structures with captures - Capture and RawCode for defining dynamic/static substitutions - PythonCoordinates for specifying template application locations - PatternMatchingComparator for comparing patterns against ASTs - TemplateEngine with caching for efficient template parsing Key features: - Context-insensitive parsing (templates parsed in isolation) - Placeholder syntax: {name} for substitutions - Named captures for extracting matched subtrees - Builder pattern for programmatic template/pattern construction
1 parent dc11f7a commit 7546770

16 files changed

Lines changed: 2715 additions & 0 deletions

File tree

rewrite-python/rewrite/src/rewrite/python/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
and printer for transforming Python source code.
2020
"""
2121

22+
# Template system
23+
from rewrite.python.template import (
24+
template,
25+
pattern,
26+
capture,
27+
raw,
28+
Template,
29+
Pattern,
30+
MatchResult,
31+
Capture,
32+
RawCode,
33+
PythonCoordinates,
34+
)
35+
2236
from rewrite.python.tree import (
2337
Py,
2438
Async,
@@ -78,6 +92,17 @@
7892
)
7993

8094
__all__ = [
95+
# Template system
96+
"template",
97+
"pattern",
98+
"capture",
99+
"raw",
100+
"Template",
101+
"Pattern",
102+
"MatchResult",
103+
"Capture",
104+
"RawCode",
105+
"PythonCoordinates",
81106
# Marker class
82107
"Py",
83108
# Python-specific types
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright 2025 the original author or authors.
2+
# <p>
3+
# Licensed under the Moderne Source Available License (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
# <p>
7+
# https://docs.moderne.io/licensing/moderne-source-available-license
8+
# <p>
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Python template system for generating and matching AST patterns.
17+
18+
This module provides a Python-idiomatic templating mechanism for OpenRewrite,
19+
similar to JavaTemplate but leveraging Python's f-string-like syntax.
20+
21+
Examples:
22+
# Import the templating API
23+
from rewrite.python.template import template, pattern, capture
24+
25+
# Create captures for matching/substitution
26+
expr = capture('expr')
27+
28+
# Create a pattern to match print() calls
29+
pat = pattern("print({expr})", expr=expr)
30+
31+
# Create a template to generate logging calls
32+
tmpl = template("logging.info({expr})", expr=expr)
33+
34+
# In a visitor
35+
class MyVisitor(PythonVisitor):
36+
def visit_method_invocation(self, method, ctx):
37+
match = pat.match(method, self.cursor)
38+
if match:
39+
return tmpl.apply(self.cursor, values=match)
40+
return super().visit_method_invocation(method, ctx)
41+
"""
42+
43+
from .capture import Capture, capture, RawCode, raw
44+
from .coordinates import PythonCoordinates, CoordinateMode, CoordinateLocation
45+
from .pattern import Pattern, MatchResult, pattern
46+
from .template import Template, TemplateBuilder, template
47+
from .engine import TemplateEngine, TemplateOptions
48+
49+
__all__ = [
50+
# Capture
51+
"Capture",
52+
"capture",
53+
"RawCode",
54+
"raw",
55+
56+
# Coordinates
57+
"PythonCoordinates",
58+
"CoordinateMode",
59+
"CoordinateLocation",
60+
61+
# Pattern
62+
"Pattern",
63+
"MatchResult",
64+
"pattern",
65+
66+
# Template
67+
"Template",
68+
"TemplateBuilder",
69+
"template",
70+
71+
# Engine
72+
"TemplateEngine",
73+
"TemplateOptions",
74+
]
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Copyright 2025 the original author or authors.
2+
# <p>
3+
# Licensed under the Moderne Source Available License (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
# <p>
7+
# https://docs.moderne.io/licensing/moderne-source-available-license
8+
# <p>
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Capture and RawCode classes for template placeholders."""
16+
17+
from __future__ import annotations
18+
19+
from dataclasses import dataclass
20+
from typing import Callable, Optional, TypeVar, Generic, TYPE_CHECKING
21+
22+
if TYPE_CHECKING:
23+
from rewrite.java import J
24+
25+
T = TypeVar('T')
26+
27+
28+
@dataclass(frozen=True)
29+
class Capture(Generic[T]):
30+
"""
31+
A capture specification for use in patterns and templates.
32+
33+
Captures define named placeholders that can match AST nodes in patterns
34+
and be substituted in templates.
35+
36+
Examples:
37+
# Simple capture
38+
expr = capture('expr')
39+
40+
# Variadic capture (matches zero or more elements)
41+
args = capture('args', variadic=True)
42+
43+
# Capture with constraint
44+
positive_int = capture('n', constraint=lambda n: is_positive_int(n))
45+
46+
# Typed capture for documentation
47+
typed = capture('x', type_hint='int')
48+
"""
49+
50+
name: str
51+
variadic: bool = False
52+
min_count: Optional[int] = None
53+
max_count: Optional[int] = None
54+
constraint: Optional[Callable[[T], bool]] = None
55+
type_hint: Optional[str] = None
56+
57+
def __hash__(self) -> int:
58+
# Exclude constraint from hash since functions aren't reliably hashable
59+
return hash((self.name, self.variadic, self.min_count, self.max_count, self.type_hint))
60+
61+
def __eq__(self, other: object) -> bool:
62+
if not isinstance(other, Capture):
63+
return False
64+
return (
65+
self.name == other.name and
66+
self.variadic == other.variadic and
67+
self.min_count == other.min_count and
68+
self.max_count == other.max_count and
69+
self.type_hint == other.type_hint
70+
)
71+
72+
73+
def capture(
74+
name: str,
75+
*,
76+
variadic: bool = False,
77+
min_count: Optional[int] = None,
78+
max_count: Optional[int] = None,
79+
constraint: Optional[Callable[[T], bool]] = None,
80+
type_hint: Optional[str] = None
81+
) -> Capture[T]:
82+
"""
83+
Create a capture specification for use in patterns and templates.
84+
85+
Args:
86+
name: Name for the capture. Used to reference matched values.
87+
variadic: If True, matches zero or more elements (for argument lists, etc.).
88+
min_count: Minimum elements for variadic captures.
89+
max_count: Maximum elements for variadic captures.
90+
constraint: Predicate function to validate matched nodes.
91+
type_hint: Type annotation string for documentation.
92+
93+
Returns:
94+
A Capture instance.
95+
96+
Examples:
97+
# Named capture
98+
x = capture('x')
99+
100+
# Variadic capture for function arguments
101+
args = capture('args', variadic=True)
102+
103+
# Capture with constraint
104+
positive = capture('n', constraint=lambda n: n.value > 0)
105+
106+
# Typed capture
107+
typed = capture('expr', type_hint='int')
108+
"""
109+
return Capture(
110+
name=name,
111+
variadic=variadic,
112+
min_count=min_count,
113+
max_count=max_count,
114+
constraint=constraint,
115+
type_hint=type_hint,
116+
)
117+
118+
119+
@dataclass(frozen=True)
120+
class RawCode:
121+
"""
122+
Raw code to be spliced into a template at construction time.
123+
124+
Unlike captures (which are placeholders resolved at apply time),
125+
RawCode splices its content directly into the template string before
126+
parsing. This is useful for dynamic method names, operators, etc.
127+
128+
Examples:
129+
# Dynamic method name from recipe options
130+
method_name = "warn"
131+
tmpl = template(f"logger.{raw(method_name)}(msg)")
132+
133+
# Configurable operator
134+
op = ">="
135+
tmpl = template(f"x {raw(op)} y")
136+
"""
137+
138+
code: str
139+
140+
141+
def raw(code: str) -> RawCode:
142+
"""
143+
Create a RawCode instance for splice-time code insertion.
144+
145+
Unlike captures (resolved at apply time), raw() splices code directly
146+
into the template string before parsing.
147+
148+
Args:
149+
code: The code string to splice.
150+
151+
Returns:
152+
A RawCode instance.
153+
154+
Examples:
155+
# Dynamic method name
156+
method_name = "warn"
157+
tmpl = template(f"logger.{raw(method_name)}(msg)")
158+
159+
# Configurable operator
160+
op = ">="
161+
tmpl = template(f"x {raw(op)} y")
162+
"""
163+
return RawCode(code=code)

0 commit comments

Comments
 (0)