Skip to content

Commit 503072d

Browse files
KotlinIslandclaude
andcommitted
unify type-expression lowering into a single pass
`&`/`and` (intersection), `or` (union), `not` (negation) and callable types (`(A) -> B`, Protocol synthesis) now lower through one pass (callable.rs). the separate intersection/not_type forward passes and their Fragment-template lowerer are gone — callable's rewrite gains the boolop cases and reuses the shared chain-flatten helpers, so `&`/`and`/`or`/`not` are defined in one place. this composes the per-leaf lowerings inside every type-form and context: a `float` / literal / `dynamic` arm inside an intersection, union arm, callable arg/return, or generic subscript is now wrapped (`JustFloat` / `Literal[…]` / `Any`) instead of leaking raw with an orphan import — by routing re-emitted leaves through the shared `rewrite_type_expr` composer and materialising narrow edits through `Fragment::Src` passthrough. it also closes three composed-position gaps found by sandpit triangulation (`by check` passes, the transpiled python crashed): - the `< 3.12` generics polyfill (`TypeVar(bound=)`, `TypeAliasType`) spliced its payload through a leaf-only rewriter, leaking raw `&`/`not`; it now routes through the full lowerer (`callable::lower_type_expr_full`), matching native - a synthesized `Protocol` class is hoisted to module top; its `__call__` annotations are now forward-reference strings, so they never evaluate a name defined later (was a runtime `NameError`) - a `typeof` nested under a structural type-form (`&`/`or`/`not`/`?`/arrow) is skipped by the typeof AST fold, whose statement re-render would otherwise drop the lowerer's wide edit and leak the operator raw Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4d1dcc2 commit 503072d

12 files changed

Lines changed: 618 additions & 383 deletions

File tree

crates/by_transforms/src/transforms/ast_driver.rs

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ use super::{
3737
annotation, anon_named_tuple, auto_quote, callable, cast, coalesce, coalesce_chain, compat,
3838
decl_site_variance, decorator_keyword, dedent_string, dynamic_keyword, empty_declarations,
3939
float_const, force_unwrap, generic_call, generics, identity_swap, implicit_typing, init_method,
40-
intersection, just_float, kw_subscript, literal_types, main_function, modifiers,
41-
mutable_defaults, none_chain, not_type, optional_type, overload, postfix_await, propagate,
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,
40+
just_float, kw_subscript, literal_types, main_function, modifiers, mutable_defaults,
41+
none_chain, optional_type, overload, postfix_await, propagate, reified_generic,
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,
4545
};
4646
use crate::Config;
4747
use crate::type_info::TypeInfo;
@@ -376,15 +376,6 @@ pub(crate) fn run_against_source<'a>(
376376
text_edits: RefCell::new(vec![]),
377377
};
378378

379-
let typeof_inner = typeof_keyword::TypeofFold::new();
380-
let typeof_pass = VisitorPass {
381-
inner: &typeof_inner,
382-
changed_cell: typeof_inner.changed_cell(),
383-
imports: vec![],
384-
hoist: RefCell::new(vec![]),
385-
text_edits: RefCell::new(vec![]),
386-
};
387-
388379
// resolve symbolic operations in type positions (`1 + 1` → `Literal[2]`)
389380
// up front, from the original parse where `typeof` operands are still
390381
// intact for ty to read. the pass replaces each operation node and must run
@@ -396,6 +387,23 @@ pub(crate) fn run_against_source<'a>(
396387
ctx.claimed_type_op_ranges = symbolic_folds.claimed_ranges();
397388
let symbolic_pass = symbolic_type_op::SymbolicTypeOp::new(symbolic_folds);
398389

390+
// a `typeof` nested under a structural type-form (`&` / `or` / `not` / an
391+
// arrow) belongs to the type-expression lowerer's wide edit; the fold
392+
// skips those so its statement re-render doesn't drop that edit
393+
let typeof_skip = typeof_keyword::collect_structural_typeof_ranges(
394+
parsed_handle.suite(),
395+
&semantic_model,
396+
&ctx.claimed_type_op_ranges,
397+
);
398+
let typeof_inner = typeof_keyword::TypeofFold::new(typeof_skip);
399+
let typeof_pass = VisitorPass {
400+
inner: &typeof_inner,
401+
changed_cell: typeof_inner.changed_cell(),
402+
imports: vec![],
403+
hoist: RefCell::new(vec![]),
404+
text_edits: RefCell::new(vec![]),
405+
};
406+
399407
let tuple_index_pass = tuple_index::TupleIndexPass::new();
400408

401409
let sentinel_inner = sentinel::Sentinel::new();
@@ -425,8 +433,6 @@ pub(crate) fn run_against_source<'a>(
425433
text_edits: RefCell::new(vec![]),
426434
};
427435

428-
let not_type_pass = not_type::NotType::new();
429-
let intersection_pass = intersection::IntersectionType::new();
430436
let dynamic_keyword_pass = dynamic_keyword::DynamicKeywordPass::new();
431437
let type_is_pass = type_is::TypeIs::new();
432438
let top_star_pass = top_star::TopStar::new();
@@ -525,8 +531,6 @@ pub(crate) fn run_against_source<'a>(
525531
// type-aware passes: operate on the salsa-owned parsed module (so
526532
// semantic queries hit the right AST nodes), emit text_edits / imports
527533
let type_aware: &[&dyn TypeAwarePass] = &[
528-
&not_type_pass,
529-
&intersection_pass,
530534
&dynamic_keyword_pass,
531535
&just_float_pass,
532536
&float_const_pass,

crates/by_transforms/src/transforms/callable.rs

Lines changed: 237 additions & 68 deletions
Large diffs are not rendered by default.

crates/by_transforms/src/transforms/dynamic_keyword.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ impl<'src> DynamicKeyword<'src> {
4343
}
4444
}
4545

46-
/// public so [`crate::transforms::just_float::rewrite_type_expr`] can drive
47-
/// a one-off lowering over a single expression without spinning up a pass
46+
/// public so [`crate::transforms::just_float::rewrite_type_expr_with_imports`]
47+
/// can drive a one-off lowering over a single expression without a pass
4848
/// (used by `generics.rs` when the PEP-695 polyfill replaces a whole type
4949
/// alias / bound and would otherwise subsume our minimal edits)
5050
pub(crate) fn emit_in_type_expr(&mut self, expr: &Expr) {

crates/by_transforms/src/transforms/float_const.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ impl<'src> FloatConst<'src> {
3737
}
3838

3939
/// Run the erasure over a single type expression (used by the composed
40-
/// `rewrite_type_expr` in `just_float`, where a polyfill replaces the whole
41-
/// range and would otherwise subsume our in-place edits)
40+
/// `rewrite_type_expr_with_imports` in `just_float`, where a polyfill
41+
/// replaces the whole range and would otherwise subsume our in-place edits)
4242
pub(crate) fn emit_in_type_expr(&mut self, expr: &Expr) {
4343
walk_one_type_expr(expr, self);
4444
}

crates/by_transforms/src/transforms/generics.rs

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ruff_python_ast::{
99
use ruff_text_size::{Ranged, TextRange, TextSize};
1010

1111
use crate::config::Config;
12-
use crate::transforms::just_float;
12+
use crate::transforms::callable::lower_type_expr_full;
1313
use crate::type_info::TypeInfo;
1414
use ruff_python_ast::PythonVersion;
1515

@@ -278,17 +278,16 @@ impl<'src> GenericPolyfill<'src> {
278278
format!("tuple[{inner}]")
279279
}
280280
} else {
281-
just_float::rewrite_type_expr(self.source, self.types, bound)
281+
lower_type_expr_full(self.source, self.types, bound)
282282
.unwrap_or_else(|| self.src(bound.range()).to_owned())
283283
};
284284
extra_args.push(format!("bound={bound_src}"));
285285
}
286286
}
287287

288288
if let Some(default) = &tv.default {
289-
let default_src =
290-
just_float::rewrite_type_expr(self.source, self.types, default)
291-
.unwrap_or_else(|| self.src(default.range()).to_owned());
289+
let default_src = lower_type_expr_full(self.source, self.types, default)
290+
.unwrap_or_else(|| self.src(default.range()).to_owned());
292291
if self.config.min_version < PythonVersion::PY313 {
293292
self.needed_imports.typevar_needs_ext = true;
294293
}
@@ -600,7 +599,7 @@ impl<'src> GenericPolyfill<'src> {
600599
// Pull in the literal-types + just-float rewrite for the RHS — our
601600
// `alias.range()` edit subsumes anything those emitted on the value
602601
// alone, so we have to splice the rewrite into our output.
603-
let literal_rewrite = just_float::rewrite_type_expr(self.source, self.types, &alias.value);
602+
let literal_rewrite = lower_type_expr_full(self.source, self.types, &alias.value);
604603

605604
let (type_params_arg, defs, value_src) = if let Some(tp) = &alias.type_params {
606605
let (generic_args, type_defs, rename_map) = self.process_type_params(&tp.type_params);
@@ -1440,15 +1439,18 @@ mod tests {
14401439

14411440
#[test]
14421441
fn tuple_bound_is_not_constraints() {
1443-
// In basedpython, `T: (int, str)` means bound=(int, str), NOT positional
1444-
// constraints. Use `T: constraints(int, str)` for that.
1442+
// In basedpython, `T: (int, str)` is a tuple-type upper bound, NOT
1443+
// positional constraints. Use `T: constraints(int, str)` for that. the
1444+
// bound lowers like any other type expression (matching the native
1445+
// `class Foo[T: tuple[int, str]]` form), since the polyfill routes the
1446+
// bound through the same type-expression lowerer
14451447
check(
14461448
indoc! {"
14471449
class Foo[T: (int, str)]: ...
14481450
"},
14491451
indoc! {"
14501452
from typing import TypeVar, Generic
1451-
_T = TypeVar(\"_T\", bound=(int, str))
1453+
_T = TypeVar(\"_T\", bound=tuple[int, str])
14521454
class Foo(Generic[_T]): ...
14531455
"},
14541456
);
@@ -1531,12 +1533,56 @@ mod tests {
15311533

15321534
#[test]
15331535
fn by_tuple_is_bound() {
1534-
// In .by files, T: (int, str) is an upper bound (tuple type), not constraints.
1536+
// In .by files, T: (int, str) is an upper bound (tuple type), not
1537+
// constraints — and it lowers to `tuple[int, str]` like the native form
15351538
check(
15361539
"class Foo[T: (int, str)]: ...\n",
15371540
indoc! {"
15381541
from typing import TypeVar, Generic
1539-
_T = TypeVar(\"_T\", bound=(int, str))
1542+
_T = TypeVar(\"_T\", bound=tuple[int, str])
1543+
class Foo(Generic[_T]): ...
1544+
"},
1545+
);
1546+
}
1547+
1548+
#[test]
1549+
fn intersection_bound_polyfilled() {
1550+
// an `&` bound lowers to `Intersection[...]` even on the < 3.12 polyfill
1551+
// path: the `TypeVar(bound=)` payload routes through the full type
1552+
// lowerer, matching the native `class Foo[T: Intersection[A, B]]`
1553+
check(
1554+
"class Foo[T: A & B]: ...\n",
1555+
indoc! {"
1556+
from ty_extensions import Intersection
1557+
from typing import TypeVar, Generic
1558+
_T = TypeVar(\"_T\", bound=Intersection[A, B])
1559+
class Foo(Generic[_T]): ...
1560+
"},
1561+
);
1562+
}
1563+
1564+
#[test]
1565+
fn negation_bound_polyfilled() {
1566+
check(
1567+
"class Foo[T: not int]: ...\n",
1568+
indoc! {"
1569+
from ty_extensions import Not
1570+
from typing import TypeVar, Generic
1571+
_T = TypeVar(\"_T\", bound=Not[int])
1572+
class Foo(Generic[_T]): ...
1573+
"},
1574+
);
1575+
}
1576+
1577+
#[test]
1578+
fn leaf_composes_in_polyfilled_bound() {
1579+
// per-leaf lowering still composes inside the spliced bound
1580+
check(
1581+
"class Foo[T: A & float]: ...\n",
1582+
indoc! {"
1583+
from ty_extensions import Intersection, JustFloat
1584+
from typing import TypeVar, Generic
1585+
_T = TypeVar(\"_T\", bound=Intersection[A, JustFloat])
15401586
class Foo(Generic[_T]): ...
15411587
"},
15421588
);

0 commit comments

Comments
 (0)