Skip to content

Commit 18b4048

Browse files
committed
added reified type parameters
1 parent f04527b commit 18b4048

23 files changed

Lines changed: 1594 additions & 128 deletions

File tree

crates/by_transforms/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
566566
let mut auto_quote_rev = reverse_transforms::auto_quote::AutoQuoteReverse::new(src);
567567
let mut compat_rev = reverse_transforms::compat::CompatReverse::new();
568568
let mut none_chain_rev = reverse_transforms::none_chain::NoneChainReverse::new(src);
569+
let mut reified_generic_rev =
570+
reverse_transforms::reified_generic::ReifiedGenericReverse::new(src);
569571
let mut string_tag_rev = reverse_transforms::string_tag::StringTagReverse::new(src);
570572
let mut typing_redirect_rev = reverse_transforms::typing_redirect::TypingRedirectReverse::new();
571573

@@ -588,6 +590,7 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
588590
auto_quote_rev.visit_stmt(stmt);
589591
compat_rev.visit_stmt(stmt);
590592
none_chain_rev.visit_stmt(stmt);
593+
reified_generic_rev.visit_stmt(stmt);
591594
string_tag_rev.visit_stmt(stmt);
592595
// `callable` rewrites callable annotations to the arrow form. it runs
593596
// for stubs too, but in a restricted "stub" mode (set above) that only
@@ -638,6 +641,7 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
638641
fixes.extend(auto_quote_rev.edits);
639642
fixes.extend(compat_rev.edits);
640643
fixes.extend(none_chain_rev.edits);
644+
fixes.extend(reified_generic_rev.edits);
641645
fixes.extend(string_tag_rev.edits);
642646
fixes.extend(typing_redirect_rev.edits);
643647

@@ -1061,6 +1065,30 @@ mod cross_file {
10611065
);
10621066
}
10631067

1068+
/// an imported *reified* generic function keeps its `[int]` specialization
1069+
/// — the `@generic` wrapper routes it through `__getitem__`. only the
1070+
/// cross-module type tells us `f` reifies `T` (value-position use), so the
1071+
/// single-file path can't make this call
1072+
#[test]
1073+
fn imported_reified_function_call_site_preserved() {
1074+
let db = project_db(&[
1075+
(
1076+
"/mod_a.by",
1077+
"def f[T](t: object) -> bool:\n return isinstance(t, T)\n",
1078+
),
1079+
("/mod_b.by", "from mod_a import f\nresult = f[int](1)\n"),
1080+
]);
1081+
let config = Config {
1082+
min_version: PythonVersion::PY312,
1083+
..Config::test_default()
1084+
};
1085+
let out = transpile_file(&db, "/mod_b.by", &config);
1086+
assert!(
1087+
out.contains("f[int](1)"),
1088+
"reified call site must keep its type args, got:\n{out}"
1089+
);
1090+
}
1091+
10641092
/// an imported plain class subscript-call (`Box[int](1)`) is a real generic
10651093
/// constructor and must be preserved — the cross-module type tells us it's
10661094
/// a class, not a function.

crates/by_transforms/src/reverse_transforms/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) mod none_chain;
2929
pub(crate) mod not_type;
3030
pub(crate) mod overload;
3131
pub(crate) mod prune_imports;
32+
pub(crate) mod reified_generic;
3233
pub(crate) mod string_tag;
3334
pub(crate) mod subscript;
3435
pub(crate) mod super_keyword;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! reverse of `crate::transforms::reified_generic`:
2+
//! `@generic # basedpython: reified\ndef f[T]: …` → `def f[T]: …`
3+
//!
4+
//! the forward transform tags the decorator line it synthesizes with the
5+
//! [`REIFIED_MARKER`](crate::transforms::reified_generic::REIFIED_MARKER)
6+
//! comment — provenance that this `@generic` came from reification, not from a
7+
//! user's own decorator. only a `@generic` carrying that marker is unwrapped;
8+
//! a hand-written `@generic` (no marker) is left untouched. the `generic`
9+
//! polyfill class and its `dataclasses` / `types` imports are dead once the
10+
//! wrapper is removed, and `prune_imports` drops them on the way out.
11+
12+
use ruff_diagnostics::{Edit, Fix};
13+
use ruff_python_ast::visitor::{Visitor, walk_stmt};
14+
use ruff_python_ast::{Decorator, Expr, Stmt, StmtFunctionDef};
15+
use ruff_text_size::{Ranged, TextRange, TextSize};
16+
17+
use crate::transforms::reified_generic::REIFIED_MARKER;
18+
use crate::transforms::source_util::line_start;
19+
20+
pub(crate) struct ReifiedGenericReverse<'src> {
21+
source: &'src str,
22+
pub(crate) edits: Vec<Fix>,
23+
}
24+
25+
impl<'src> ReifiedGenericReverse<'src> {
26+
pub(crate) fn new(source: &'src str) -> Self {
27+
Self {
28+
source,
29+
edits: Vec::new(),
30+
}
31+
}
32+
33+
/// the source position the `def`/`async` header begins at — either the next
34+
/// decorator's start, or the header keyword following this decorator
35+
fn next_header_start(&self, decorators: &[Decorator], idx: usize) -> Option<TextSize> {
36+
if let Some(next) = decorators.get(idx + 1) {
37+
return Some(next.range().start());
38+
}
39+
let after_dec = usize::from(decorators[idx].range().end());
40+
let rest = &self.source[after_dec..];
41+
// skip the marker comment to the newline, then to the header keyword
42+
let offset = rest.find("def")?;
43+
Some(TextSize::from(u32::try_from(after_dec + offset).ok()?))
44+
}
45+
46+
/// whether the decorator is a bare `@generic` whose line carries the
47+
/// reified-provenance marker comment. the marker lives in trivia (not the
48+
/// AST), so it is matched against the source slice from the decorator's end
49+
/// to the end of its physical line
50+
fn is_marked_generic(&self, decorator: &Decorator) -> bool {
51+
if !matches!(&decorator.expression, Expr::Name(n) if n.id.as_str() == "generic") {
52+
return false;
53+
}
54+
let after = usize::from(decorator.range().end());
55+
let rest = &self.source[after..];
56+
let line = rest.split('\n').next().unwrap_or(rest);
57+
line.contains(REIFIED_MARKER.trim_start())
58+
}
59+
60+
fn unwrap_function(&mut self, function: &StmtFunctionDef) {
61+
let decorators = &function.decorator_list;
62+
for (idx, decorator) in decorators.iter().enumerate() {
63+
if !self.is_marked_generic(decorator) {
64+
continue;
65+
}
66+
// delete from the decorator's `@` through to the next header start
67+
// (the following decorator, or the `def` keyword). this removes the
68+
// marker comment and the line break with it
69+
let Some(next_start) = self.next_header_start(decorators, idx) else {
70+
continue;
71+
};
72+
let start = line_start(self.source, decorator.range().start());
73+
self.edits
74+
.push(Fix::safe_edit(Edit::range_deletion(TextRange::new(
75+
start, next_start,
76+
))));
77+
}
78+
}
79+
}
80+
81+
impl<'ast> Visitor<'ast> for ReifiedGenericReverse<'_> {
82+
fn visit_stmt(&mut self, stmt: &'ast Stmt) {
83+
if let Stmt::FunctionDef(function) = stmt {
84+
self.unwrap_function(function);
85+
}
86+
walk_stmt(self, stmt);
87+
}
88+
}
89+
90+
#[cfg(test)]
91+
mod tests {
92+
use crate::transforms::reified_generic::REIFIED_MARKER;
93+
use crate::{Config, reverse_transpile};
94+
use ruff_python_ast::PythonVersion;
95+
96+
fn rev(source: &str) -> String {
97+
reverse_transpile(source, &Config::test_default()).unwrap()
98+
}
99+
100+
#[test]
101+
fn marked_generic_is_unwrapped() {
102+
let src = format!("@generic{REIFIED_MARKER}\ndef f[T]():\n print(T)\n");
103+
let out = rev(&src);
104+
assert!(
105+
!out.contains("@generic"),
106+
"wrapper should be removed: {out}"
107+
);
108+
assert!(out.contains("def f[T]():"), "def should remain: {out}");
109+
}
110+
111+
#[test]
112+
fn handwritten_generic_is_preserved() {
113+
// no marker — a user's own `@generic` decorator stays put
114+
let src = "@generic\ndef f(x):\n return x\n";
115+
let out = rev(src);
116+
assert!(
117+
out.contains("@generic"),
118+
"hand-written decorator must be preserved: {out}"
119+
);
120+
}
121+
122+
#[test]
123+
fn round_trip_rewraps() {
124+
// reverse then forward reproduces the wrapper
125+
let src = format!("@generic{REIFIED_MARKER}\ndef f[T]():\n print(T)\n");
126+
let bare = rev(&src);
127+
let config = Config {
128+
min_version: PythonVersion::PY312,
129+
..Config::test_default()
130+
};
131+
let forward = crate::transpile(&bare, &config).unwrap();
132+
assert!(
133+
forward.contains("@generic # basedpython: reified"),
134+
"forward should re-wrap the bare reified def: {forward}"
135+
);
136+
}
137+
}

crates/by_transforms/src/transforms/ast_driver.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ use super::{
3939
float_const, force_unwrap, generic_call, generics, identity_swap, implicit_typing, init_method,
4040
intersection, just_float, kw_subscript, literal_types, main_function, modifiers,
4141
mutable_defaults, none_chain, not_type, optional_type, overload, postfix_await, propagate,
42-
repeated_underscore, sentinel, some_ctor, string_tag, super_keyword, symbolic_type_op,
43-
top_star, tuple_index, type_is, typed_dict_literal, typed_lambda, typeof_keyword, unpack,
44-
use_site_variance,
42+
reified_generic, repeated_underscore, sentinel, some_ctor, string_tag, super_keyword,
43+
symbolic_type_op, top_star, tuple_index, type_is, typed_dict_literal, typed_lambda,
44+
typeof_keyword, unpack, use_site_variance,
4545
};
4646
use crate::Config;
4747
use crate::type_info::TypeInfo;
@@ -454,6 +454,8 @@ pub(crate) fn run_against_source<'a>(
454454
let float_const_pass = float_const::FloatConstPass::new();
455455
let kw_subscript_pass = kw_subscript::KwSubscriptPass::new(source_ref);
456456
let generic_call_pass = generic_call::GenericCallStripPass::new(source_ref);
457+
let reified_generic_pass =
458+
reified_generic::ReifiedGenericPass::new(source_ref, config.min_version);
457459
let implicit_typing_pass = implicit_typing::ImplicitTypingPass::new();
458460
let tuple_types_pass = annotation::TupleLiteralTypePass::new(source_ref);
459461
let literal_types_pass = literal_types::LiteralTypePass::new(source_ref);
@@ -529,6 +531,10 @@ pub(crate) fn run_against_source<'a>(
529531
&just_float_pass,
530532
&float_const_pass,
531533
&kw_subscript_pass,
534+
// reified generics wrap `def f[T]` (value-position `T`) in `@generic`;
535+
// must precede generic_call so the call-site strip skips the wrapped
536+
// function's specialized calls (they route through `generic.__getitem__`)
537+
&reified_generic_pass,
532538
&generic_call_pass,
533539
&implicit_typing_pass,
534540
&tuple_types_pass,

crates/by_transforms/src/transforms/generic_call.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ impl<'ast> Visitor<'ast> for GenericCallStrip<'_> {
6767
if let Expr::Call(call) = expr {
6868
if let Expr::Subscript(sub) = call.func.as_ref() {
6969
if let Expr::Name(name) = sub.value.as_ref() {
70-
if self.types.is_function(name) {
70+
// a reified generic keeps its `[…]`: the `@generic` wrapper
71+
// routes the specialization through `generic.__getitem__`,
72+
// so stripping it would erase a runtime-significant step
73+
if self.types.is_function(name) && !self.types.is_reified_function(name) {
7174
let fn_src = self.src(sub.value.range()).to_owned();
7275
self.edits
7376
.push(Fix::safe_edit(Edit::range_replacement(fn_src, sub.range())));

crates/by_transforms/src/transforms/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub(crate) mod optional_type;
3434
pub(crate) mod overload;
3535
pub(crate) mod postfix_await;
3636
pub(crate) mod propagate;
37+
pub(crate) mod reified_generic;
3738
pub(crate) mod repeated_underscore;
3839
pub(crate) mod sentinel;
3940
pub(crate) mod some_ctor;

0 commit comments

Comments
 (0)