diff --git a/Cargo.lock b/Cargo.lock index 614952e21..c5aa6cff5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2057,6 +2057,7 @@ dependencies = [ "serde_repr", "starlark_map", "static_assertions", + "strsim", "tempfile", "tikv-jemallocator", "tokio", diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 8f03a114c..68e1d79b9 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -115,7 +115,7 @@ "code": -2, "column": 23, "concise_description": "Could not find name `a`", - "description": "Could not find name `a`", + "description": "Could not find name `a`\n Did you mean `S`?", "line": 83, "name": "unknown-name", "severity": "error", @@ -126,7 +126,7 @@ "code": -2, "column": 28, "concise_description": "Could not find name `b`", - "description": "Could not find name `b`", + "description": "Could not find name `b`\n Did you mean `S`?", "line": 83, "name": "unknown-name", "severity": "error", @@ -1005,7 +1005,7 @@ "code": -2, "column": 44, "concise_description": "Could not find name `a`", - "description": "Could not find name `a`", + "description": "Could not find name `a`\n Did you mean `S`?", "line": 56, "name": "unknown-name", "severity": "error", @@ -1016,7 +1016,7 @@ "code": -2, "column": 49, "concise_description": "Could not find name `b`", - "description": "Could not find name `b`", + "description": "Could not find name `b`\n Did you mean `S`?", "line": 56, "name": "unknown-name", "severity": "error", @@ -1284,7 +1284,7 @@ "code": -2, "column": 14, "concise_description": "Could not find name `ClassF`", - "description": "Could not find name `ClassF`", + "description": "Could not find name `ClassF`\n Did you mean `ClassC`?", "line": 80, "name": "unknown-name", "severity": "error", @@ -2220,7 +2220,7 @@ "code": -2, "column": 7, "concise_description": "Object of class `Proto9` has no attribute `other_attribute2`", - "description": "Object of class `Proto9` has no attribute `other_attribute2`", + "description": "Object of class `Proto9` has no attribute `other_attribute2`\n Did you mean `other_attribute`?", "line": 197, "name": "missing-attribute", "severity": "error", @@ -2653,7 +2653,7 @@ "code": -2, "column": 14, "concise_description": "Could not find name `var`", - "description": "Could not find name `var`", + "description": "Could not find name `var`\n Did you mean `vars`?", "line": 40, "name": "unknown-name", "severity": "error", @@ -3585,7 +3585,7 @@ "code": -2, "column": 7, "concise_description": "Object of class `DC1` has no attribute `x`", - "description": "Object of class `DC1` has no attribute `x`", + "description": "Object of class `DC1` has no attribute `x`\n Did you mean `a`?", "line": 28, "name": "missing-attribute", "severity": "error", @@ -3596,7 +3596,7 @@ "code": -2, "column": 7, "concise_description": "Object of class `DC1` has no attribute `y`", - "description": "Object of class `DC1` has no attribute `y`", + "description": "Object of class `DC1` has no attribute `y`\n Did you mean `a`?", "line": 29, "name": "missing-attribute", "severity": "error", @@ -8082,7 +8082,7 @@ "code": -2, "column": 1, "concise_description": "Cannot set item in `Point`", - "description": "Cannot set item in `Point`\n Object of class `Point` has no attribute `__setitem__`", + "description": "Cannot set item in `Point`\n Object of class `Point` has no attribute `__setitem__`\n Did you mean `__getitem__`?", "line": 41, "name": "unsupported-operation", "severity": "error", @@ -8104,7 +8104,7 @@ "code": -2, "column": 5, "concise_description": "Cannot delete item in `Point`", - "description": "Cannot delete item in `Point`\n Object of class `Point` has no attribute `__delitem__`", + "description": "Cannot delete item in `Point`\n Object of class `Point` has no attribute `__delitem__`\n Did you mean `__getitem__`?", "line": 43, "name": "unsupported-operation", "severity": "error", @@ -9270,7 +9270,7 @@ "code": -2, "column": 19, "concise_description": "Could not find name `a`", - "description": "Could not find name `a`", + "description": "Could not find name `a`\n Did you mean `T`?", "line": 41, "name": "unknown-name", "severity": "error", @@ -9281,7 +9281,7 @@ "code": -2, "column": 24, "concise_description": "Could not find name `b`", - "description": "Could not find name `b`", + "description": "Could not find name `b`\n Did you mean `T`?", "line": 41, "name": "unknown-name", "severity": "error", @@ -9325,7 +9325,7 @@ "code": -2, "column": 17, "concise_description": "Could not find name `var1`", - "description": "Could not find name `var1`", + "description": "Could not find name `var1`\n Did you mean `vars`?", "line": 45, "name": "unknown-name", "severity": "error", @@ -11093,7 +11093,7 @@ "code": -2, "column": 1, "concise_description": "Object of class `Movie` has no attribute `clear`", - "description": "Object of class `Movie` has no attribute `clear`", + "description": "Object of class `Movie` has no attribute `clear`\n Did you mean `year`?", "line": 47, "name": "missing-attribute", "severity": "error", @@ -11115,7 +11115,7 @@ "code": -2, "column": 1, "concise_description": "Object of class `MovieOptional` has no attribute `clear`", - "description": "Object of class `MovieOptional` has no attribute `clear`", + "description": "Object of class `MovieOptional` has no attribute `clear`\n Did you mean `year`?", "line": 62, "name": "missing-attribute", "severity": "error", @@ -11514,7 +11514,7 @@ "code": -2, "column": 21, "concise_description": "Key `y` is not defined in TypedDict `A3`", - "description": "Key `y` is not defined in TypedDict `A3`", + "description": "Key `y` is not defined in TypedDict `A3`\n Did you mean `x`?", "line": 69, "name": "bad-typed-dict-key", "severity": "error", diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index f7d5218b2..bffd8b756 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -53,6 +53,7 @@ serde_json = { version = "1.0.140", features = ["alloc", "float_roundtrip", "raw serde_repr = "0.1.14" starlark_map = "0.13.0" static_assertions = "1.1.0" +strsim = "0.11.1" tempfile = "3.22" tokio = { version = "1.47.1", features = ["macros", "rt"] } toml = { version = "0.9.8", features = ["preserve_order"] } diff --git a/pyrefly/lib/alt/attr.rs b/pyrefly/lib/alt/attr.rs index be8d2e3b0..e088c08fa 100644 --- a/pyrefly/lib/alt/attr.rs +++ b/pyrefly/lib/alt/attr.rs @@ -23,6 +23,7 @@ use ruff_python_ast::name::Name; use ruff_text_size::TextRange; use starlark_map::small_set::SmallSet; use vec1::Vec1; +use vec1::vec1; use crate::alt::answers::LookupAnswer; use crate::alt::answers_solver::AnswersSolver; @@ -41,6 +42,7 @@ use crate::export::exports::Export; use crate::export::exports::ExportLocation; use crate::export::exports::Exports; use crate::solver::solver::SubsetError; +use crate::suggest::best_suggestion; use crate::types::callable::FuncMetadata; use crate::types::callable::Function; use crate::types::callable::FunctionKind; @@ -518,7 +520,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { todo_ctx: &str, ) -> Type { let attr_base = self.as_attribute_base(base.clone()); - let lookup_result = attr_base.map_or_else( + let lookup_result = attr_base.clone().map_or_else( || LookupResult::internal_error(InternalError::AttributeBaseUndefined(base.clone())), |attr_base| self.lookup_attr_from_base(attr_base, attr_name), ); @@ -545,13 +547,83 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } else { error_messages.sort(); error_messages.dedup(); - self.error( - errors, + let mut msg = vec1![error_messages.join("\n")]; + if let Some(suggestion) = attr_base + .as_ref() + .and_then(|attr_base| self.suggest_attribute_name(attr_name, attr_base)) + { + msg.push(format!("Did you mean `{suggestion}`?")); + } + errors.add( range, ErrorInfo::new(ErrorKind::MissingAttribute, context), - error_messages.join("\n"), - ) + msg, + ); + Type::any_error() + } + } + + fn add_class_fields(&self, class: &Class, candidates: &mut SmallSet) { + let mut add_fields_for = |cls: &Class| { + for name in self.get_class_field_map(cls).keys() { + candidates.insert(name.clone()); + } + }; + add_fields_for(class); + let mro = self.get_mro_for_class(class); + for ancestor in mro.ancestors_no_object() { + add_fields_for(ancestor.class_object()); + } + } + + fn collect_attribute_candidates_from_base( + &self, + base: &AttributeBase1, + candidates: &mut SmallSet, + ) { + match base { + AttributeBase1::ClassInstance(class) => { + self.add_class_fields(class.class_object(), candidates); + } + AttributeBase1::ClassObject(class_base) => { + self.add_class_fields(class_base.class_object(), candidates); + } + AttributeBase1::LiteralString => { + self.add_class_fields(self.stdlib.str().class_object(), candidates); + } + AttributeBase1::TypedDict(td) => { + self.add_class_fields(td.class_object(), candidates); + } + AttributeBase1::SuperInstance(class, ..) => { + self.add_class_fields(class.class_object(), candidates); + } + AttributeBase1::Quantified(_, class_type) => { + self.add_class_fields(class_type.class_object(), candidates); + } + AttributeBase1::SelfType(class_type) => { + self.add_class_fields(class_type.class_object(), candidates); + } + AttributeBase1::ProtocolSubset(inner) => { + self.collect_attribute_candidates_from_base(inner, candidates); + } + AttributeBase1::Intersect(lhs, rhs) => { + for b in lhs { + self.collect_attribute_candidates_from_base(b, candidates); + } + for b in rhs { + self.collect_attribute_candidates_from_base(b, candidates); + } + } + _ => {} + } + } + + fn suggest_attribute_name(&self, missing: &Name, attr_base: &AttributeBase) -> Option { + let mut candidates = SmallSet::new(); + for base in attr_base.0.iter() { + self.collect_attribute_candidates_from_base(base, &mut candidates); } + best_suggestion(missing, candidates.iter().map(|candidate| (candidate, 0))) } /// Can the attribute be successfully looked up in all cases? diff --git a/pyrefly/lib/alt/class/typed_dict.rs b/pyrefly/lib/alt/class/typed_dict.rs index 65f6d0d81..eb1a0762b 100644 --- a/pyrefly/lib/alt/class/typed_dict.rs +++ b/pyrefly/lib/alt/class/typed_dict.rs @@ -36,6 +36,7 @@ use crate::error::context::ErrorInfo; use crate::error::context::TypeCheckContext; use crate::error::context::TypeCheckKind; use crate::solver::solver::SubsetError; +use crate::suggest::best_suggestion; use crate::types::annotation::Qualifier; use crate::types::callable::Callable; use crate::types::callable::FuncMetadata; @@ -120,15 +121,21 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { ); } None => { - self.error( - check_errors, + let mut msg = vec1![format!( + "Key `{}` is not defined in TypedDict `{}`", + key_name, + typed_dict.name() + )]; + if let Some(suggestion) = best_suggestion( + &key_name, + fields.keys().map(|candidate| (candidate, 0usize)), + ) { + msg.push(format!("Did you mean `{suggestion}`?")); + } + check_errors.add( key.range(), ErrorInfo::Kind(ErrorKind::BadTypedDictKey), - format!( - "Key `{}` is not defined in TypedDict `{}`", - key_name, - typed_dict.name() - ), + msg, ); } } diff --git a/pyrefly/lib/alt/expr.rs b/pyrefly/lib/alt/expr.rs index 5252bdc92..d205886e6 100644 --- a/pyrefly/lib/alt/expr.rs +++ b/pyrefly/lib/alt/expr.rs @@ -59,6 +59,7 @@ use crate::error::collector::ErrorCollector; use crate::error::context::ErrorContext; use crate::error::context::ErrorInfo; use crate::error::context::TypeCheckContext; +use crate::suggest::best_suggestion; use crate::types::callable::Callable; use crate::types::callable::Param; use crate::types::callable::ParamList; @@ -2126,25 +2127,31 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let key_ty = self.expr_infer(slice, errors); self.distribute_over_union(&key_ty, |ty| match ty { Type::Literal(Lit::Str(field_name)) => { - if let Some(field) = - self.typed_dict_field(&typed_dict, &Name::new(field_name)) - { + let fields = self.typed_dict_fields(&typed_dict); + if let Some(field) = fields.get(&Name::new(field_name)) { field.ty.clone() } else if let ExtraItems::Extra(extra) = self.typed_dict_extra_items(typed_dict.class_object()) { extra.ty } else { - self.error( - errors, + let mut msg = vec1![format!( + "TypedDict `{}` does not have key `{}`", + typed_dict.name(), + field_name + )]; + if let Some(suggestion) = best_suggestion( + &Name::new(field_name), + fields.keys().map(|candidate| (candidate, 0usize)), + ) { + msg.push(format!("Did you mean `{suggestion}`?")); + } + errors.add( slice.range(), ErrorInfo::Kind(ErrorKind::BadTypedDictKey), - format!( - "TypedDict `{}` does not have key `{}`", - typed_dict.name(), - field_name - ), - ) + msg, + ); + Type::any_error() } } Type::ClassType(cls) diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 48e83d40a..cc06d7cfa 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -2624,7 +2624,15 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { ) } } - Binding::ClassBodyUnknownName(class_key, name) => { + Binding::ClassBodyUnknownName(class_key, name, suggestion) => { + let add_unknown_name_error = |errors: &ErrorCollector| { + let mut msg = vec1![format!("Could not find name `{name}`")]; + if let Some(suggestion) = &suggestion { + msg.push(format!("Did you mean `{suggestion}`?")); + } + errors.add(name.range, ErrorInfo::Kind(ErrorKind::UnknownName), msg); + Type::any_error() + }; // We're specifically looking for attributes that are inherited from the parent class if let Some(cls) = &self.get_idx(*class_key).as_ref().0 && !self.get_class_field_map(cls).contains_key(&name.id) @@ -2640,22 +2648,12 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { None, ); if attr_ty.is_error() { - self.error( - errors, - name.range, - ErrorInfo::Kind(ErrorKind::UnknownName), - format!("Could not find name `{name}`"), - ) + add_unknown_name_error(errors) } else { attr_ty } } else { - self.error( - errors, - name.range, - ErrorInfo::Kind(ErrorKind::UnknownName), - format!("Could not find name `{name}`"), - ) + add_unknown_name_error(errors) } } Binding::CompletedPartialType(unpinned_idx, first_use) => { diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index d09213e3f..742910dbe 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -1446,7 +1446,7 @@ pub enum Binding { /// A name in the class body that wasn't found in the static scope /// It could either be an unbound name or a reference to an inherited attribute /// We'll find out which when we solve the class - ClassBodyUnknownName(Idx, Identifier), + ClassBodyUnknownName(Idx, Identifier, Option), } impl DisplayWith for Binding { @@ -1710,13 +1710,17 @@ impl DisplayWith for Binding { ) } Self::Delete(x) => write!(f, "Delete({})", m.display(x)), - Self::ClassBodyUnknownName(class_key, name) => { + Self::ClassBodyUnknownName(class_key, name, suggestion) => { write!( f, - "ClassBodyUnknownName({}, {})", + "ClassBodyUnknownName({}, {}", m.display(ctx.idx_to_key(*class_key)), name, - ) + )?; + if let Some(suggestion) = suggestion { + write!(f, ", {suggestion}")?; + } + write!(f, ")") } } } @@ -1790,7 +1794,7 @@ impl Binding { | Binding::CompletedPartialType(..) | Binding::PartialTypeWithUpstreamsCompleted(..) | Binding::Delete(_) - | Binding::ClassBodyUnknownName(_, _) => None, + | Binding::ClassBodyUnknownName(_, _, _) => None, } } } diff --git a/pyrefly/lib/binding/expr.rs b/pyrefly/lib/binding/expr.rs index 0e8761761..51cd5bc78 100644 --- a/pyrefly/lib/binding/expr.rs +++ b/pyrefly/lib/binding/expr.rs @@ -28,6 +28,7 @@ use ruff_text_size::Ranged; use ruff_text_size::TextRange; use starlark_map::Hashed; use starlark_map::small_set::SmallSet; +use vec1::vec1; use crate::binding::binding::Binding; use crate::binding::binding::BindingDecorator; @@ -342,6 +343,9 @@ impl<'a> BindingsBuilder<'a> { self.insert_binding(key, Binding::Forward(value)) } NameLookupResult::NotFound => { + let suggestion = self + .scopes + .suggest_similar_name(&name.id, name.range.start()); if is_special_name(name.id.as_str()) { self.error( name.range, @@ -355,14 +359,17 @@ impl<'a> BindingsBuilder<'a> { } else if self.scopes.in_class_body() && let Some((cls, _)) = self.scopes.current_class_and_metadata_keys() { - self.insert_binding(key, Binding::ClassBodyUnknownName(cls, name.clone())) + self.insert_binding( + key, + Binding::ClassBodyUnknownName(cls, name.clone(), suggestion), + ) } else { // Record a type error and fall back to `Any`. - self.error( - name.range, - ErrorInfo::Kind(ErrorKind::UnknownName), - format!("Could not find name `{name}`"), - ); + let mut msg = vec1![format!("Could not find name `{name}`")]; + if let Some(suggestion) = suggestion { + msg.push(format!("Did you mean `{suggestion}`?")); + } + self.error_multiline(name.range, ErrorInfo::Kind(ErrorKind::UnknownName), msg); self.insert_binding(key, Binding::Type(Type::any_error())) } } diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 3b11f6d52..51a6ae06b 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -73,6 +73,7 @@ use crate::export::exports::LookupExport; use crate::export::special::SpecialExport; use crate::graph::index::Idx; use crate::module::module_info::ModuleInfo; +use crate::suggest::best_suggestion; use crate::types::class::ClassDefIndex; use crate::types::type_info::JoinStyle; @@ -1947,6 +1948,43 @@ impl Scopes { } } + pub fn suggest_similar_name(&self, missing: &Name, position: TextSize) -> Option { + let mut candidates: Vec<(&Name, usize)> = Vec::new(); + let mut flow_barrier = FlowBarrier::AllowFlowChecked; + let is_current_scope_annotation = matches!(self.current().kind, ScopeKind::Annotation); + for (lookup_depth, scope) in self.iter_rev().enumerate() { + let is_class = matches!(scope.kind, ScopeKind::Class(_)); + if is_class + && !((lookup_depth == 0) || (is_current_scope_annotation && lookup_depth == 1)) + { + continue; + } + + if flow_barrier < FlowBarrier::BlockFlow { + for candidate in scope.flow.info.keys() { + if let Some(static_info) = scope.stat.0.get(candidate) + && static_info.range.start() >= position + { + continue; + } + candidates.push((candidate, lookup_depth)); + } + } + + if !is_class { + for (candidate, static_info) in scope.stat.0.iter() { + if static_info.range.start() < position { + candidates.push((candidate, lookup_depth)); + } + } + } + + flow_barrier = max(flow_barrier, scope.flow_barrier); + } + + best_suggestion(missing, candidates) + } + /// Look up the information needed to create a `Usage` binding for a read of a name /// in the current scope stack. pub fn look_up_name_for_read(&self, name: Hashed<&Name>) -> NameReadInfo { diff --git a/pyrefly/lib/lib.rs b/pyrefly/lib/lib.rs index 4ae0da898..5a45e3fad 100644 --- a/pyrefly/lib/lib.rs +++ b/pyrefly/lib/lib.rs @@ -43,6 +43,7 @@ pub mod query; mod report; mod solver; mod state; +mod suggest; mod test; #[cfg(not(target_arch = "wasm32"))] mod tsp; diff --git a/pyrefly/lib/suggest.rs b/pyrefly/lib/suggest.rs new file mode 100644 index 000000000..7a6c23446 --- /dev/null +++ b/pyrefly/lib/suggest.rs @@ -0,0 +1,37 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +use ruff_python_ast::name::Name; +use strsim::levenshtein; + +/// Pick the closest candidate to `missing`, preferring smaller `priority` on ties. +pub fn best_suggestion<'a, I>(missing: &Name, candidates: I) -> Option +where + I: IntoIterator, +{ + let missing_str = missing.as_str(); + let mut best: Option<(Name, usize, usize)> = None; + for (candidate, priority) in candidates { + let candidate_str = candidate.as_str(); + let distance = levenshtein(missing_str, candidate_str); + let max_distance = match missing_str.len().max(candidate_str.len()) { + 0..=4 => 1, + 5..=8 => 2, + _ => 3, + }; + if distance == 0 || distance > max_distance { + continue; + } + match &best { + Some((_, best_distance, best_priority)) + if distance > *best_distance + || (distance == *best_distance && priority >= *best_priority) => {} + _ => best = Some((candidate.clone(), distance, priority)), + } + } + best.map(|(name, _, _)| name) +} diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index da7211ce5..83d114537 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -1812,6 +1812,64 @@ assert_type(C().f(), Any) "#, ); +testcase!( + test_missing_attribute_suggests_similar_name, + r#" +class Foo: + value = 1 + +def f(obj: Foo) -> None: + obj.vaule # E: Object of class `Foo` has no attribute `vaule`\n Did you mean `value`? +"#, +); + +testcase!( + test_missing_attribute_suggests_builtin_str_method, + r#" +"".lowerr # E: Object of class `str` has no attribute `lowerr`\n Did you mean `lower`? +"#, +); + +testcase!( + test_missing_attribute_suggests_inherited, + r#" +class Base: + field = 1 + +class Child(Base): + pass + +def f(x: Child) -> None: + x.filed # E: Object of class `Child` has no attribute `filed`\n Did you mean `field`? +"#, +); + +testcase!( + test_missing_attribute_suggests_typed_dict_field, + r#" +from typing import TypedDict +class TD(TypedDict): + foo: int + +def f(x: TD) -> int: + return x.fo # E: Object of class `TD` has no attribute `fo`\n Did you mean `foo`? +"#, +); + +testcase!( + test_missing_attribute_suggests_enum_member, + r#" +from enum import Enum +class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + +def f(x: Color) -> Color: + return x.BLU # E: Object of class `Color` has no attribute `BLU`\n Did you mean `BLUE`? +"#, +); + testcase!( test_class_toplevel_inherited_attr_name, r#" diff --git a/pyrefly/lib/test/lsp/lsp_interaction/notebook_sync.rs b/pyrefly/lib/test/lsp/lsp_interaction/notebook_sync.rs index 1818f30f9..e2ede8bd2 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/notebook_sync.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/notebook_sync.rs @@ -386,7 +386,7 @@ fn test_notebook_did_change_delete_cell() { "codeDescription": { "href": "https://pyrefly.org/en/docs/error-kinds/#unknown-name" }, - "message": "Could not find name `y`", + "message": "Could not find name `y`\n Did you mean `x`?", "range": { "start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5} diff --git a/pyrefly/lib/test/scope.rs b/pyrefly/lib/test/scope.rs index 948bd75eb..967a70e8a 100644 --- a/pyrefly/lib/test/scope.rs +++ b/pyrefly/lib/test/scope.rs @@ -18,6 +18,83 @@ class C: "#, ); +testcase!( + test_unknown_name_suggests_similar, + r#" +long_variable_name = 1 +long_variable_name2 = long_variuble_name # E: Did you mean `long_variable_name`? +"#, +); + +testcase!( + test_unknown_name_suggests_from_enclosing_scope, + r#" +outer_value = 10 +def f() -> int: + return outer_vlaue # E: Did you mean `outer_value`? +"#, +); + +testcase!( + test_unknown_name_no_suggest_from_future_defs, + r#" +future_value = missing # E: `missing` is uninitialized +missing = 1 +"#, +); + +testcase!( + test_unknown_name_no_suggest_from_class_scope_in_method, + r#" +class C: + x = 1 + def m(self) -> int: + return x # E: Could not find name `x` +"#, +); + +testcase!( + test_unknown_name_suggests_in_class_body, + r#" +class Foo: + x = 42 + y = xx + 42 # E: Did you mean `x`? +"#, +); + +testcase!( + test_unknown_name_prefers_inner_scope, + r#" +value = 0 +def f() -> int: + local_value = 1 + return local_valu # E: Did you mean `local_value`? +"#, +); + +testcase!( + test_unknown_name_ties_prefer_shallower_scope, + r#" +global_value = 1 +def outer() -> int: + global_value2 = 2 + def inner() -> int: + globl_value = 3 + return globl_valu # E: Did you mean `globl_value`? + return inner() +"#, +); + +testcase!( + test_unknown_name_no_suggestion_when_far, + r#" +alpha = 1 +beta = 2 +gamma = 3 +missing_completely = delta # E: Could not find name `delta` +"#, +); + // The python compiler enforces static scoping rules in most cases - for example, if a function // defines a name and we try to read it before we write it, Python will normally not fall back // to searching in enclosing scopes. diff --git a/pyrefly/lib/test/typed_dict.rs b/pyrefly/lib/test/typed_dict.rs index a8741e6af..91eeb7772 100644 --- a/pyrefly/lib/test/typed_dict.rs +++ b/pyrefly/lib/test/typed_dict.rs @@ -1965,3 +1965,15 @@ class D(TypedDict): D(x=5) "#, ); + +testcase!( + test_typed_dict_missing_key_via_subscript, + r#" +from typing import TypedDict +class TD(TypedDict): + foo: int + +td: TD = {"foo": 1} +td["fo"] # E: TypedDict `TD` does not have key `fo`\n Did you mean `foo`? +"#, +);