Skip to content

Commit 430a6be

Browse files
committed
added or/and keywords for union/intersection types
in type positions, `A or B` lowers to `A | B` and `A and B` to `Intersection[A, B]`. forward transform folds boolops into the shared intersection/union lowering (flatten n-ary, mix with &/|/not, python precedence: and over or). ty resolves boolop in .by type expressions. value-position boolops untouched
1 parent 6dafb44 commit 430a6be

8 files changed

Lines changed: 602 additions & 76 deletions

File tree

crates/by_transforms/src/transforms/intersection.rs

Lines changed: 348 additions & 53 deletions
Large diffs are not rendered by default.

crates/by_transforms/src/transforms/not_type.rs

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@
1111
//! `Annotated[T, …]` first arg, `Callable[[P], R]` parameter list + return.
1212
//! `not x` in non-type contexts (boolean negation) is never affected.
1313
14-
use ruff_python_ast::{
15-
AtomicNodeIndex, Expr, ExprContext, ExprName, ExprSubscript, Stmt, UnaryOp, name::Name,
16-
};
14+
use ruff_python_ast::{Expr, Stmt, UnaryOp};
1715
use ruff_text_size::TextRange;
1816

1917
use super::ast_driver::{PassContext, TypeAwarePass, render_expr};
18+
use super::intersection::{LowerImports, lower};
2019
use super::type_expr_walker::{Recurse, TypeExprVisitor, TypePos, walk_type_positions};
2120
use crate::type_info::TypeInfo;
2221

@@ -32,42 +31,30 @@ impl TypeAwarePass for NotType {
3231
fn run(&self, stmts: &[Stmt], types: &dyn TypeInfo, ctx: &mut PassContext) {
3332
let mut state = State {
3433
edits: Vec::new(),
35-
needs_import: false,
34+
needs: LowerImports::default(),
3635
};
3736
walk_type_positions(stmts, Some(types), &mut state);
3837
ctx.text_edits.extend(state.edits);
39-
if state.needs_import {
40-
ctx.required_imports
41-
.push("from ty_extensions import Not".to_owned());
42-
}
38+
state.needs.push_required(ctx);
4339
}
4440
}
4541

4642
struct State {
4743
edits: Vec<(TextRange, String)>,
48-
needs_import: bool,
44+
needs: LowerImports,
4945
}
5046

5147
impl TypeExprVisitor for State {
5248
fn visit(&mut self, expr: &Expr, _pos: TypePos) -> Recurse {
5349
if let Expr::UnaryOp(u) = expr
5450
&& matches!(u.op, UnaryOp::Not)
5551
{
56-
let new_node = Expr::Subscript(ExprSubscript {
57-
node_index: AtomicNodeIndex::NONE,
58-
range: TextRange::default(),
59-
value: Box::new(Expr::Name(ExprName {
60-
node_index: AtomicNodeIndex::NONE,
61-
range: TextRange::default(),
62-
id: Name::from("Not"),
63-
ctx: ExprContext::Load,
64-
})),
65-
slice: Box::new((*u.operand).clone()),
66-
ctx: ExprContext::Load,
67-
is_typeof: false,
68-
});
69-
self.needs_import = true;
52+
// the shared lowering rewrites the whole `not …` chain, including
53+
// `&` / `and` / `or` and nested `not` inside the operand, so the
54+
// wide edit can't leak surface operators
55+
let new_node = lower(expr, &mut self.needs);
7056
self.edits.push((u.range, render_expr(&new_node)));
57+
return Recurse::Stop;
7158
}
7259
Recurse::Descend
7360
}
@@ -271,6 +258,41 @@ mod tests {
271258
unchanged("from typing import Literal\na: Literal[True, False]\n");
272259
}
273260

261+
#[test]
262+
fn not_of_intersection_operand_lowered() {
263+
// the operand lowers through the shared intersection/union lowering
264+
// so `&` doesn't leak into the emitted python
265+
check(
266+
"a: not (A & B)\n",
267+
indoc! {"
268+
from ty_extensions import Intersection, Not
269+
a: Not[Intersection[A, B]]
270+
"},
271+
);
272+
}
273+
274+
#[test]
275+
fn not_of_or_keyword_operand_lowered() {
276+
check(
277+
"a: not (A or B)\n",
278+
indoc! {"
279+
from ty_extensions import Not
280+
a: Not[A | B]
281+
"},
282+
);
283+
}
284+
285+
#[test]
286+
fn nested_not_lowered() {
287+
check(
288+
"a: not not int\n",
289+
indoc! {"
290+
from ty_extensions import Not
291+
a: Not[Not[int]]
292+
"},
293+
);
294+
}
295+
274296
#[test]
275297
fn nested_not_in_dict_slice() {
276298
// unparenthesized tuple inside subscript slice — both elements are

crates/by_transforms/src/transforms/type_expr_walker.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ impl TypePosWalker<'_> {
140140
self.visit_type_expr(&b.left, TypePos::Nested);
141141
self.visit_type_expr(&b.right, TypePos::Nested);
142142
}
143+
// `A or B` / `A and B` — keyword spellings of union / intersection
144+
Expr::BoolOp(b) => {
145+
for value in &b.values {
146+
self.visit_type_expr(value, TypePos::Nested);
147+
}
148+
}
143149
// `not T` (negation) and `T?` (optional) both carry a nested type
144150
// expression in their operand, so descend so sibling transforms
145151
// (intersection, not, float, …) apply inside `(not A)?`, `(A & B)?`
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# basedpython: `or` / `and` type operators
2+
3+
basedpython accepts the keywords `or` and `and` in type positions as spellings of union and
4+
intersection — `A or B` is `A | B`, `A and B` is `A & B`.
5+
6+
```toml
7+
[environment]
8+
python-version = "3.12"
9+
```
10+
11+
## `or` is union
12+
13+
```by
14+
def f(x: int or str) -> None:
15+
reveal_type(x) # revealed: int | str
16+
```
17+
18+
## `and` is intersection
19+
20+
```by
21+
class P: ...
22+
class Q: ...
23+
24+
def f(x: P and Q) -> None:
25+
reveal_type(x) # revealed: P & Q
26+
```
27+
28+
## n-ary chains flatten
29+
30+
```by
31+
class A: ...
32+
class B: ...
33+
class C: ...
34+
35+
def f(x: A and B and C, y: int or str or None) -> None:
36+
reveal_type(x) # revealed: A & B & C
37+
reveal_type(y) # revealed: int | str | None
38+
```
39+
40+
## `and` binds tighter than `or`
41+
42+
matching python's boolean precedence, and `&` over `|`:
43+
44+
```by
45+
class A: ...
46+
class B: ...
47+
class C: ...
48+
49+
def f(x: A and B or C) -> None:
50+
reveal_type(x) # revealed: (A & B) | C
51+
```
52+
53+
## parentheses compose
54+
55+
```by
56+
class A: ...
57+
class B: ...
58+
class C: ...
59+
60+
def f(x: (A or B) and C) -> None:
61+
reveal_type(x) # revealed: (A & C) | (B & C)
62+
```
63+
64+
## keyword and symbolic forms mix
65+
66+
```by
67+
class A: ...
68+
class B: ...
69+
class C: ...
70+
71+
def f(x: A & B and C, y: int | str or None) -> None:
72+
reveal_type(x) # revealed: A & B & C
73+
reveal_type(y) # revealed: int | str | None
74+
```
75+
76+
## nested in a generic
77+
78+
```by
79+
class HasA:
80+
a: int
81+
82+
class HasB:
83+
b: str
84+
85+
def f(items: list[HasA and HasB], names: list[int or str]) -> None:
86+
if items and names:
87+
reveal_type(items[0].a) # revealed: int
88+
reveal_type(items[0].b) # revealed: str
89+
reveal_type(names[0]) # revealed: int | str
90+
```
91+
92+
## intersection of attribute presence
93+
94+
```by
95+
class HasA:
96+
a: int
97+
98+
class HasB:
99+
b: str
100+
101+
def f(x: HasA and HasB) -> tuple[int, str]:
102+
return (x.a, x.b)
103+
```
104+
105+
## value positions keep boolean semantics
106+
107+
`or` / `and` outside type positions are ordinary boolean operators:
108+
109+
```by
110+
def f(a: int, b: int) -> None:
111+
reveal_type(bool(a) or bool(b)) # revealed: bool
112+
x = True and False
113+
reveal_type(x) # revealed: False
114+
```
115+
116+
## still rejected in python files
117+
118+
```py
119+
def f(
120+
x: int or str, # error: [invalid-type-form] "Boolean operations are not allowed in parameter annotations"
121+
y: int and str, # error: [invalid-type-form] "Boolean operations are not allowed in parameter annotations"
122+
) -> None: ...
123+
```

crates/ty_python_semantic/src/types/infer/builder/type_expression.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,22 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
791791
}
792792

793793
ast::Expr::BoolOp(bool_op) => {
794+
// basedpython: `A or B` / `A and B` in a type annotation are the
795+
// keyword spellings of union / intersection. allowed only in
796+
// `.by` / `.byi`
797+
if self.is_basedpython_file() {
798+
let elements: Vec<Type<'db>> = bool_op
799+
.values
800+
.iter()
801+
.map(|value| self.infer_type_expression(value))
802+
.collect();
803+
return match bool_op.op {
804+
ast::BoolOp::Or => {
805+
UnionType::from_elements_leave_aliases(self.db(), elements)
806+
}
807+
ast::BoolOp::And => IntersectionType::from_elements(self.db(), elements),
808+
};
809+
}
794810
if !self.in_string_annotation() {
795811
self.infer_boolean_expression(bool_op, TypeContext::default());
796812
}

docs/basedpython/features/intersection.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ chains are flattened: `A & B & C` becomes `Intersection[A, B, C]` rather than
2828
nested. intersections compose with unions by precedence: `&` binds tighter than
2929
`|`, so `A & B | C` parses as `(A & B) | C`
3030

31+
the keywords `and` / `or` are accepted as alternate spellings of `&` / `|`
32+
see [`or` / `and` type operators](or-and-types.md)
33+
3134
## scope
3235

3336
the `&` operator is recognized only in syntactic type positions: annotations,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# `or` / `and` type operators
2+
3+
basedpython accepts the keywords `or` and `and` in annotation positions as
4+
spellings of union and intersection — `A or B` means `A | B`, `A and B`
5+
means `A & B`:
6+
7+
```by
8+
def parse(raw: str or bytes) -> int or None: ...
9+
10+
handlers: list[HasName and HasId]
11+
12+
cb: A and B or C
13+
```
14+
15+
transpiles to:
16+
17+
```python
18+
def parse(raw: str | bytes) -> int | None: ...
19+
20+
handlers: list[Intersection[HasName, HasId]]
21+
22+
cb: Intersection[A, B] | C
23+
```
24+
25+
## semantics
26+
27+
`A or B` is the union of `A` and `B`; `A and B` is the type of values that
28+
satisfy both `A` *and* `B` — exactly the semantics of `|` and `&` from
29+
[intersection types](intersection.md). the keywords follow python's boolean
30+
precedence, which mirrors the bitwise one: `and` binds tighter than `or`, so
31+
`A and B or C` is `(A & B) | C`
32+
33+
n-ary chains flatten: `A and B and C` becomes `Intersection[A, B, C]` rather
34+
than nested, and `A or B or C` becomes one `A | B | C` union. the keyword and
35+
symbolic spellings mix freely — `A & B and C` is one three-arm intersection,
36+
`A | B or C` one three-arm union. parentheses compose as usual:
37+
`(A or B) and C` is `Intersection[A | B, C]`
38+
39+
## scope
40+
41+
the keywords are recognized only in syntactic type positions: annotations,
42+
return types, type aliases, and subscript slices that are themselves type
43+
contexts. boolean `or` / `and` in value expressions is untouched:
44+
45+
```by
46+
x = a or b # boolean or — unchanged
47+
a: A or B # union — rewritten
48+
```
49+
50+
## polyfill
51+
52+
`or` lowers to the native `|` union, which needs no import. `and` lowers to
53+
`Intersection` from `ty_extensions`, exactly like the `&` operator — see
54+
[intersection types](intersection.md)
55+
56+
## round-tripping
57+
58+
`or` / `and` are alternate input spellings, not the canonical surface form.
59+
reverse transpilation re-sugars `Intersection[…]` to `&` and leaves `|`
60+
as-is, so a round-trip emits the symbolic operators

docs/basedpython/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ basedpython is a Python-like language that transpiles to pure Python
2525
- [tuple type literals](features/tuple-types.md)
2626
- [callable arrow syntax](features/callable.md)
2727
- [intersection types](features/intersection.md)
28+
- [`or` / `and` type operators](features/or-and-types.md)
2829
- [negation types (`not T`)](features/not-type.md)
2930
- [`typeof` keyword](features/typeof.md)
3031
- [star projections (`X[*]`)](features/star-projection.md)

0 commit comments

Comments
 (0)