Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/pyrefly_config/src/error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ pub enum ErrorKind {
NoAccess,
/// Attempting to call an overloaded function, but none of the signatures match.
NoMatchingOverload,
/// Matching on an enum without covering all possible cases.
NonExhaustiveMatch,
/// Attempting to use something that isn't a type where a type is expected.
/// This is a very general error and should be used sparingly.
NotAType,
Expand Down Expand Up @@ -312,6 +314,7 @@ impl ErrorKind {
ErrorKind::MissingSource => Severity::Ignore,
ErrorKind::MissingOverrideDecorator => Severity::Ignore,
ErrorKind::OpenUnpacking => Severity::Ignore,
ErrorKind::NonExhaustiveMatch => Severity::Warn,
_ => Severity::Error,
}
}
Expand Down
90 changes: 90 additions & 0 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,58 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
self.solver().expand_vars_mut(ty);
}

fn is_pure_enum_class(&self, cls: &ClassType) -> bool {
self.get_metadata_for_class(cls.class_object())
.enum_metadata()
.is_some_and(|meta| !meta.is_flag)
}

fn is_pure_enum_type(&self, ty: &Type) -> bool {
match ty {
Type::ClassType(cls) | Type::SelfType(cls) => self.is_pure_enum_class(cls),
Type::Literal(Lit::Enum(lit_enum)) => {
let lit_enum = lit_enum.as_ref();
self.is_pure_enum_class(&lit_enum.class)
}
Type::Union(union) => {
let union = union.as_ref();
!union.members.is_empty()
&& union
.members
.iter()
.all(|member| self.is_pure_enum_type(member))
}
_ => false,
}
}

fn format_enum_literal_cases(&self, ty: &Type) -> Option<String> {
fn collect_cases(ty: &Type, acc: &mut Vec<String>) -> bool {
match ty {
Type::Literal(Lit::Enum(lit_enum)) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lit implements display, you can do lit_enum @ Lit::Enum(_) and then format!("{}", lit_enum) and it will do the same thing

let lit_enum = lit_enum.as_ref();
acc.push(format!("{}.{}", lit_enum.class.name(), lit_enum.member));
true
}
Type::Union(union) => {
let union = union.as_ref();
union
.members
.iter()
.all(|member| collect_cases(member, acc))
}
_ => false,
}
}

let mut cases = Vec::new();
if collect_cases(ty, &mut cases) {
Some(cases.join(", "))
} else {
None
}
}

fn check_del_typed_dict_field(
&self,
typed_dict: &Name,
Expand Down Expand Up @@ -1762,6 +1814,44 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
);
}
}
BindingExpect::MatchExhaustiveness {
subject,
remaining,
range,
} => {
let subject_info = self.get_idx(*subject);
let mut subject_ty = subject_info.ty().clone();
self.expand_vars_mut(&mut subject_ty);
if !self.is_pure_enum_type(&subject_ty) {
return Arc::new(EmptyAnswer);
}
let mut remaining_ty = subject_ty.clone();
if let Some((op, narrow_range)) = remaining {
let narrowed = self.narrow(&subject_info, op.as_ref(), *narrow_range, errors);
let mut ty = narrowed.ty().clone();
self.expand_vars_mut(&mut ty);
remaining_ty = ty;
}
if remaining_ty.is_never() {
return Arc::new(EmptyAnswer);
}
let subject_display = self.for_display(subject_ty);
let remaining_display = self.for_display(remaining_ty.clone());
let ctx = TypeDisplayContext::new(&[&subject_display, &remaining_display]);
let missing_cases = self
.format_enum_literal_cases(&remaining_ty)
.unwrap_or_else(|| ctx.display(&remaining_display).to_string());
self.error(
errors,
*range,
ErrorInfo::Kind(ErrorKind::NonExhaustiveMatch),
format!(
"Match on `{}` is not exhaustive; missing cases: {}",
ctx.display(&subject_display),
missing_cases,
),
);
}
}
Arc::new(EmptyAnswer)
}
Expand Down
23 changes: 23 additions & 0 deletions pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,12 @@ pub enum BindingExpect {
},
/// Expression used in a boolean context (`bool()`, `if`, or `while`)
Bool(Expr),
/// A match statement that may be non-exhaustive at runtime.
MatchExhaustiveness {
subject: Idx<Key>,
remaining: Option<(Box<NarrowOp>, TextRange)>,
range: TextRange,
},
}

impl DisplayWith<Bindings> for BindingExpect {
Expand Down Expand Up @@ -632,6 +638,23 @@ impl DisplayWith<Bindings> for BindingExpect {
ctx.display(*existing),
name
),
Self::MatchExhaustiveness {
subject,
remaining,
range,
} => {
let remaining_desc = remaining
.as_ref()
.map(|(_, narrow_range)| format!("{}", ctx.module().display(narrow_range)))
.unwrap_or_else(|| "None".to_owned());
write!(
f,
"MatchExhaustiveness({}, {}, {})",
ctx.display(*subject),
remaining_desc,
ctx.module().display(range)
)
}
}
}
}
Expand Down
39 changes: 33 additions & 6 deletions pyrefly/lib/binding/pattern.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use ruff_python_ast::Expr;
use ruff_python_ast::ExprNumberLiteral;
use ruff_python_ast::ExprStringLiteral;
use ruff_python_ast::Int;
use ruff_python_ast::MatchCase;
use ruff_python_ast::Number;
use ruff_python_ast::Pattern;
use ruff_python_ast::PatternKeyword;
Expand Down Expand Up @@ -360,23 +361,31 @@ impl<'a> BindingsBuilder<'a> {
// is carried over to the fallback case.
let mut negated_prev_ops = NarrowOps::new();
for case in x.cases {
let MatchCase {
pattern,
guard,
body,
range: case_range,
..
} = case;
self.start_branch();
if case.pattern.is_wildcard() || case.pattern.is_irrefutable() {
let case_is_irrefutable = pattern.is_wildcard() || pattern.is_irrefutable();
if case_is_irrefutable {
exhaustive = true;
}
self.bind_narrow_ops(
&negated_prev_ops,
NarrowUseLocation::Start(case.range),
NarrowUseLocation::Start(case_range),
&Usage::Narrowing(None),
);
let mut new_narrow_ops =
self.bind_pattern(match_narrowing_subject.clone(), case.pattern, subject_idx);
self.bind_pattern(match_narrowing_subject.clone(), pattern, subject_idx);
self.bind_narrow_ops(
&new_narrow_ops,
NarrowUseLocation::Span(case.range),
NarrowUseLocation::Span(case_range),
&Usage::Narrowing(None),
);
if let Some(mut guard) = case.guard {
if let Some(mut guard) = guard {
self.ensure_expr(&mut guard, &mut Usage::Narrowing(None));
let guard_narrow_ops = NarrowOps::from_expr(self, Some(guard.as_ref()));
self.bind_narrow_ops(
Expand All @@ -388,13 +397,31 @@ impl<'a> BindingsBuilder<'a> {
new_narrow_ops.and_all(guard_narrow_ops)
}
negated_prev_ops.and_all(new_narrow_ops.negate());
self.stmts(case.body, parent);
self.stmts(body, parent);
self.finish_branch();
}
if exhaustive {
self.finish_exhaustive_fork();
} else {
self.finish_non_exhaustive_fork(&negated_prev_ops);
if let Some(subject_name) = match match_narrowing_subject.as_ref() {
Copy link
Contributor

@yangdanny97 yangdanny97 Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bug somewhere here. if the subject is NarrowingSubject::Facets I don't think it works, since we don't preserve the facet information into the solving step.

For example, this:

from enum import Enum

class Color(Enum):
    RED = "red"
    BLUE = "blue"

class X:
    color: Color

def describe(x: X) -> str:
    match x.color:
        case Color.RED:
            return "danger"
    return "cool"

If we add a case for Color.BLUE the error still gets shown

Some(NarrowingSubject::Name(name)) => Some(name.clone()),
Some(NarrowingSubject::Facets(name, _)) => Some(name.clone()),
None => None,
} {
let remaining = negated_prev_ops
.0
.get(&subject_name)
.map(|(op, range)| (Box::new(op.clone()), *range));
self.insert_binding(
KeyExpect(x.range),
BindingExpect::MatchExhaustiveness {
subject: subject_idx,
remaining,
range: x.range,
},
);
}
}
}
}
17 changes: 17 additions & 0 deletions pyrefly/lib/test/pattern_match.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,20 @@ def f0(x: int | str):
assert_type(x, str) # E: assert_type(int | str, str)
"#,
);

testcase!(
test_non_exhaustive_enum_match_warning,
r#"
from enum import Enum

class Color(Enum):
RED = "red"
BLUE = "blue"

def describe(color: Color) -> str:
match color: # E: Match on `Color` is not exhaustive; missing cases: Color.BLUE
case Color.RED:
return "danger"
return "cool"
"#,
);
18 changes: 18 additions & 0 deletions website/docs/error-kinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,24 @@ def f(x: int | str) -> int | str:
f(1.0)
```

## non-exhaustive-match

Pyrefly warns when a `match` statement over an `Enum` attempts to enumerate the members
but forgets at least one case. Add the missing members or a default arm.

```python
from enum import Enum

class Color(Enum):
RED = "red"
BLUE = "blue"

def describe(color: Color) -> str:
match color: # non-exhaustive-match
case Color.RED:
return "danger"
```

## not-a-type

This indicates an attempt to use something that isn't a type where a type is expected.
Expand Down