Skip to content

Commit 04936f1

Browse files
committed
implement sealed classes
1 parent 496d3cf commit 04936f1

14 files changed

Lines changed: 697 additions & 140 deletions

File tree

crates/by_transforms/src/transforms/modifiers.rs

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
3232

3333
use super::ast_driver::{AstPass, PassContext};
3434

35+
/// Returns the head identifier of a base-class expression: `A` for `A`, and
36+
/// `A` for the generic form `A[int]`. Other base shapes have no simple name.
37+
fn base_head_name(base: &Expr) -> Option<&str> {
38+
match base {
39+
Expr::Name(n) => Some(n.id.as_str()),
40+
Expr::Subscript(s) => match s.value.as_ref() {
41+
Expr::Name(n) => Some(n.id.as_str()),
42+
_ => None,
43+
},
44+
_ => None,
45+
}
46+
}
47+
3548
#[expect(clippy::struct_excessive_bools)]
3649
pub(crate) struct Modifiers<'src> {
3750
source: &'src str,
@@ -50,9 +63,22 @@ pub(crate) struct Modifiers<'src> {
5063
pub(crate) exports: Vec<String>,
5164
/// Module-level names renamed by `private` (original → `_original`).
5265
pub(crate) private_renames: Vec<String>,
66+
/// Module-level classes declared `sealed`, in source order. Each gets a
67+
/// `<name>.__sealed_members__` tuple of its same-module subclasses.
68+
pub(crate) sealed_classes: Vec<String>,
69+
/// Module-level `(class name, base head names, statement end offset)`
70+
/// triples, in source order. Used to resolve the subclasses of each sealed
71+
/// class and to place the `__sealed_members__` assignment after the last one.
72+
pub(crate) class_bases: Vec<(String, Vec<String>, TextSize)>,
5373
/// Tracks the current class-nesting depth so visibility modifiers can
5474
/// distinguish module-level declarations from class members.
5575
class_depth: u32,
76+
77+
/// Tracks the current function-nesting depth. A class declared inside a
78+
/// function body is not a module-level name, so it must not be recorded as
79+
/// a sealed subclass (the runtime tuple assignment lives at module scope
80+
/// and cannot reference a function-local name).
81+
func_depth: u32,
5682
}
5783

5884
impl<'src> Modifiers<'src> {
@@ -70,10 +96,19 @@ impl<'src> Modifiers<'src> {
7096
needs_newtype: false,
7197
exports: Vec::new(),
7298
private_renames: Vec::new(),
99+
sealed_classes: Vec::new(),
100+
class_bases: Vec::new(),
73101
class_depth: 0,
102+
func_depth: 0,
74103
}
75104
}
76105

106+
/// True when the visitor is directly at module scope — not inside any class
107+
/// or function body.
108+
fn at_module_level(&self) -> bool {
109+
self.class_depth == 0 && self.func_depth == 0
110+
}
111+
77112
fn line_indent(&self, pos: TextSize) -> &str {
78113
super::source_util::line_indent(self.source, pos)
79114
}
@@ -96,6 +131,19 @@ impl<'src> Modifiers<'src> {
96131
}
97132

98133
fn process_class(&mut self, class: &StmtClassDef) {
134+
// Record module-level class → base-head-names so sealed-member tuples can
135+
// be resolved after the whole module has been visited.
136+
if self.at_module_level() {
137+
let bases = class
138+
.arguments
139+
.iter()
140+
.flat_map(|args| args.args.iter())
141+
.filter_map(base_head_name)
142+
.map(str::to_owned)
143+
.collect();
144+
self.class_bases
145+
.push((class.name.as_str().to_owned(), bases, class.range().end()));
146+
}
99147
for dec in &class.decorator_list {
100148
let Some(name) = self.synthetic_name(dec) else {
101149
continue;
@@ -107,6 +155,15 @@ impl<'src> Modifiers<'src> {
107155
self.edits
108156
.push(Fix::safe_edit(Edit::range_deletion(dec.range())));
109157
}
158+
"sealed" => {
159+
// Remove the modifier prefix; the `__sealed_members__` tuple is
160+
// emitted after the last subclass once they are all known.
161+
self.edits
162+
.push(Fix::safe_edit(Edit::range_deletion(dec.range())));
163+
if self.at_module_level() {
164+
self.sealed_classes.push(class.name.as_str().to_owned());
165+
}
166+
}
110167
"final" => {
111168
self.needs_final = true;
112169
self.edits.push(Fix::safe_edit(Edit::range_replacement(
@@ -292,7 +349,7 @@ impl<'src> Modifiers<'src> {
292349
node.range(),
293350
)));
294351
}
295-
"__abstract_annot__" | "__visibility_annot__" => {
352+
"__abstract_annot__" | "__visibility_annot__" | "__modifier_annot__" => {
296353
// erase only the modifier prefix; the rest of the
297354
// statement (`a: int [= v]`) remains in source unchanged
298355
let erase_range =
@@ -380,6 +437,12 @@ impl<'ast> Visitor<'ast> for Modifiers<'_> {
380437
}
381438
Stmt::FunctionDef(f) => {
382439
self.process_function(f);
440+
// Walk the body with `func_depth` incremented so a class defined
441+
// inside the function is not treated as a module-level subclass.
442+
self.func_depth += 1;
443+
walk_stmt(self, stmt);
444+
self.func_depth -= 1;
445+
return;
383446
}
384447
Stmt::AnnAssign(a) => {
385448
self.process_ann_assign(a);
@@ -446,6 +509,8 @@ impl AstPass for ModifiersPass<'_> {
446509
}
447510
let exports = std::mem::take(&mut inner.exports);
448511
let private_renames = std::mem::take(&mut inner.private_renames);
512+
let sealed_classes = std::mem::take(&mut inner.sealed_classes);
513+
let class_bases = std::mem::take(&mut inner.class_bases);
449514

450515
// typing import grouping mirrors lib.rs's preamble logic
451516
let mut typing_imports: Vec<&'static str> = Vec::new();
@@ -514,6 +579,47 @@ impl AstPass for ModifiersPass<'_> {
514579
.join(", ");
515580
ctx.epilogue.push(format!("__all__ = [{entries}]"));
516581
}
582+
583+
for sealed in &sealed_classes {
584+
let subclasses: Vec<&str> = class_bases
585+
.iter()
586+
.filter(|(_, bases, _)| bases.iter().any(|base| base == sealed))
587+
.map(|(name, _, _)| name.as_str())
588+
.collect();
589+
let tuple = match subclasses.as_slice() {
590+
[] => "()".to_owned(),
591+
[single] => format!("({single},)"),
592+
many => format!("({})", many.join(", ")),
593+
};
594+
595+
// Place the assignment right after the last subclass (or the sealed
596+
// class itself when it has none), so module-level code that follows
597+
// can read `__sealed_members__`. The module epilogue runs too late.
598+
let anchor_end = class_bases
599+
.iter()
600+
.filter(|(name, bases, _)| {
601+
name == sealed || bases.iter().any(|base| base == sealed)
602+
})
603+
.map(|(_, _, end)| *end)
604+
.max();
605+
let Some(anchor_end) = anchor_end else {
606+
continue;
607+
};
608+
let anchor_end = usize::from(anchor_end);
609+
610+
let assignment = format!("{sealed}.__sealed_members__ = {tuple}");
611+
// Insert at the start of the line after the anchor statement so the
612+
// edit never collides with `empty_declarations`' mid-line `: ...`.
613+
if let Some(rel) = self.source[anchor_end..].find('\n') {
614+
let pos = TextSize::try_from(anchor_end + rel + 1).expect("offset fits u32");
615+
ctx.text_edits
616+
.push((TextRange::empty(pos), format!("{assignment}\n")));
617+
} else {
618+
let pos = TextSize::try_from(self.source.len()).expect("offset fits u32");
619+
ctx.text_edits
620+
.push((TextRange::empty(pos), format!("\n{assignment}\n")));
621+
}
622+
}
517623
}
518624
}
519625

@@ -541,6 +647,22 @@ mod tests {
541647
);
542648
}
543649

650+
#[test]
651+
fn modifier_chain_with_non_leading_final() {
652+
// `final` anywhere in a modifier chain must parse — it was missing from
653+
// the chain-continuation set, so `sealed final class` (final non-leading)
654+
// panicked the parser. modifiers commute, so both orders match.
655+
let expected = indoc! {"
656+
from typing import final
657+
@final
658+
class A: ...
659+
class B(A): ...
660+
A.__sealed_members__ = (B,)
661+
"};
662+
check("sealed final class A\nclass B(A)\n", expected);
663+
check("final sealed class A\nclass B(A)\n", expected);
664+
}
665+
544666
#[test]
545667
fn final_def() {
546668
check(
@@ -909,6 +1031,16 @@ mod tests {
9091031
check("final override abstract a = 3\n", "a = 3\n");
9101032
}
9111033

1034+
#[test]
1035+
fn override_annotated_assign() {
1036+
// annotated assignment modifiers must parse and strip just like the
1037+
// unannotated form — previously `override x: T` was a parse error while
1038+
// `override x = v` worked
1039+
check("override x: int = 1\n", "x: int = 1\n");
1040+
check("final override x: int = 1\n", "x: int = 1\n");
1041+
check("override x: int\n", "x: int\n");
1042+
}
1043+
9121044
#[test]
9131045
fn arbitrary_modifier_chain_def() {
9141046
check(
@@ -1035,6 +1167,71 @@ mod tests {
10351167
);
10361168
}
10371169

1170+
#[test]
1171+
fn sealed_class_members() {
1172+
check(
1173+
indoc! {"
1174+
sealed class A
1175+
class B(A)
1176+
class C(A)
1177+
"},
1178+
indoc! {"
1179+
class A: ...
1180+
class B(A): ...
1181+
class C(A): ...
1182+
A.__sealed_members__ = (B, C)
1183+
"},
1184+
);
1185+
}
1186+
1187+
#[test]
1188+
fn sealed_members_exclude_function_local_subclass() {
1189+
// a subclass declared inside a function body is not a module-level name,
1190+
// so it must not appear in the module-level `__sealed_members__` tuple
1191+
// (it would be a `NameError`). matches ty's view of the member set.
1192+
check(
1193+
indoc! {"
1194+
sealed class A
1195+
def make():
1196+
class C(A): ...
1197+
class B(A)
1198+
"},
1199+
indoc! {"
1200+
class A: ...
1201+
def make():
1202+
class C(A): ...
1203+
class B(A): ...
1204+
A.__sealed_members__ = (B,)
1205+
"},
1206+
);
1207+
}
1208+
1209+
#[test]
1210+
fn sealed_class_single_member() {
1211+
check(
1212+
indoc! {"
1213+
sealed class A
1214+
class B(A)
1215+
"},
1216+
indoc! {"
1217+
class A: ...
1218+
class B(A): ...
1219+
A.__sealed_members__ = (B,)
1220+
"},
1221+
);
1222+
}
1223+
1224+
#[test]
1225+
fn sealed_class_no_members() {
1226+
check(
1227+
"sealed class A\n",
1228+
indoc! {"
1229+
class A: ...
1230+
A.__sealed_members__ = ()
1231+
"},
1232+
);
1233+
}
1234+
10381235
#[test]
10391236
fn export_and_private_together_in_module() {
10401237
check(

crates/ruff_python_parser/src/parser/statement.rs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ fn is_modifier_kw(text: &str) -> bool {
8787
"final"
8888
| "abstract"
8989
| "open"
90+
| "sealed"
9091
| "override"
9192
| "static"
9293
| "data"
@@ -378,6 +379,17 @@ impl<'src> Parser<'src> {
378379
));
379380
Some(self.parse_visibility_annot_decl(start))
380381
}
382+
TokenKind::Colon if idx >= 1 => {
383+
// any other modifier chain on an annotated assignment
384+
// (`override x: T`, `final override x: T`, …) — strip
385+
// the prefix, keep `x: T [= v]`. mirrors the
386+
// `[modifiers] name = value` form so the two are
387+
// symmetric rather than annotated-only being rejected
388+
self.error_if_not_basedpython(format!(
389+
"`{kw}` modifier on annotated assignments is not valid in .py files"
390+
));
391+
Some(self.parse_modifier_annot_decl(start, "__modifier_annot__"))
392+
}
381393
_ => None,
382394
};
383395
}
@@ -471,6 +483,10 @@ impl<'src> Parser<'src> {
471483
self.bump(TokenKind::Name);
472484
"open"
473485
}
486+
"sealed" => {
487+
self.bump(TokenKind::Name);
488+
"sealed"
489+
}
474490
"static" => {
475491
self.bump(TokenKind::Name);
476492
"static"
@@ -511,19 +527,11 @@ impl<'src> Parser<'src> {
511527
// another modifier keyword follows — keep looping
512528
if self.at(TokenKind::Name) {
513529
let kw = self.src_text(self.current_token_range());
514-
if matches!(
515-
kw,
516-
"data"
517-
| "enum"
518-
| "frozen"
519-
| "abstract"
520-
| "open"
521-
| "static"
522-
| "export"
523-
| "public"
524-
| "private"
525-
| "override"
526-
) {
530+
// any modifier keyword may follow another, in any order — reuse
531+
// the canonical set so none is accidentally omitted (a missing
532+
// `final` here used to drop the chain into `parse_class_definition`
533+
// with the modifier still current, panicking on `bump(Class)`)
534+
if is_modifier_kw(kw) {
527535
continue;
528536
}
529537
}
@@ -769,7 +777,15 @@ impl<'src> Parser<'src> {
769777
/// text, leaving `name: T [= v]` behind.
770778
fn parse_modifier_annot_decl(&mut self, start: TextSize, synthetic_id: &'static str) -> Stmt {
771779
let modifier_range = self.current_token_range();
772-
self.bump(TokenKind::Name); // consume modifier keyword
780+
// consume modifier keywords until we reach the variable name (the Name
781+
// token immediately followed by `:`), so chains like `final override x: T`
782+
// strip in full — not just the first modifier
783+
loop {
784+
if self.peek() == TokenKind::Colon {
785+
break;
786+
}
787+
self.bump(TokenKind::Name);
788+
}
773789
let name = self.parse_identifier();
774790
self.bump(TokenKind::Colon); // consume ":"
775791
let annotation_expr = self

0 commit comments

Comments
 (0)