diff --git a/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs b/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs index 8b35760a2..f9ebb82b2 100644 --- a/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs +++ b/crates/emmylua_code_analysis/src/check/checker/assign_type_mismatch.rs @@ -259,6 +259,10 @@ fn check_assign( ); } LuaVarExpr::IndexExpr(index_expr) => { + if let Some((_, write)) = semantic_model.directional_index(index_expr) { + check_value_against(context, semantic_model, &write.typ, &expr); + continue; + } let Some(resolved) = semantic_model.resolve_member(index_expr) else { check_self_member_assign(context, semantic_model, index_expr, &expr); continue; diff --git a/crates/emmylua_code_analysis/src/check/checker/duplicate_field.rs b/crates/emmylua_code_analysis/src/check/checker/duplicate_field.rs index 43f3eb9b0..0af1e96cf 100644 --- a/crates/emmylua_code_analysis/src/check/checker/duplicate_field.rs +++ b/crates/emmylua_code_analysis/src/check/checker/duplicate_field.rs @@ -16,7 +16,7 @@ use crate::semantic_model::SemanticModel; use crate::semantic_model::render::humanize_type; use crate::semantic_model::type_eval::expand_alias_branches; use crate::semantic_model::type_eval::expand_alias_head; -use crate::{LuaMemberKey, LuaType}; +use crate::{LuaMemberKey, LuaType, MemberAccess}; use super::{CheckContext, Checker}; @@ -80,6 +80,23 @@ impl Checker for DuplicateFieldChecker { if dupes.len() <= 1 { continue; } + // Getter and setter declarations form independent overload sets. + if is_type_def + && dupes + .iter() + .all(|member| member.access != MemberAccess::ReadWrite) + && [MemberAccess::Read, MemberAccess::Write] + .into_iter() + .all(|access| { + let mut direction = + dupes.iter().filter(|member| member.access == access); + direction.clone().count() <= 1 + || direction + .all(|member| is_function_like(semantic_model, member)) + }) + { + continue; + } let all_function = dupes .iter() .all(|member| is_function_like(semantic_model, member)); diff --git a/crates/emmylua_code_analysis/src/semantic_db/def/member.rs b/crates/emmylua_code_analysis/src/semantic_db/def/member.rs index 723dd51be..6a82c8584 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/def/member.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/def/member.rs @@ -16,8 +16,17 @@ pub struct MemberRef { pub name: SmolStr, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum MemberAccess { + #[default] + ReadWrite, + Read, + Write, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Member { + pub access: MemberAccess, /// Globally unique identity = declaration location (file + member-key token range). pub id: SemanticId, /// File-independent lookup key: `Name("Field")` / `Integer(1)`. @@ -54,6 +63,7 @@ impl Member { owner: SemanticId, ) -> Self { Member { + access: MemberAccess::ReadWrite, id: SemanticId::member(file_id, key_range), key, owner, diff --git a/crates/emmylua_code_analysis/src/semantic_db/def/mod.rs b/crates/emmylua_code_analysis/src/semantic_db/def/mod.rs index 8b62d16ce..afbd0fdd8 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/def/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/def/mod.rs @@ -29,7 +29,7 @@ pub use decl::{Decl, DeclKind}; pub use generic_param::DocGenericParam; pub use identity::{ChangedKeys, DependencyKey, ExportKey, FileDependencies, OwnerId}; pub use lua_type::*; -pub use member::{Member, MemberRef}; +pub use member::{Member, MemberAccess, MemberRef}; pub use member_key::LuaMemberKey; pub use module_export::ModuleExport; pub use module_info::{ModuleInfo, ModuleNode, ModuleNodeId}; diff --git a/crates/emmylua_code_analysis/src/semantic_db/facts.rs b/crates/emmylua_code_analysis/src/semantic_db/facts.rs index 153bab136..d76e607b6 100644 --- a/crates/emmylua_code_analysis/src/semantic_db/facts.rs +++ b/crates/emmylua_code_analysis/src/semantic_db/facts.rs @@ -18,8 +18,8 @@ use rowan::{TextRange, TextSize, WalkEvent}; use smol_str::SmolStr; use crate::DiagnosticCode; -use crate::FileId; use crate::semantic_db::def::SignatureReturnCast; +use crate::{FileId, MemberAccess}; use super::index::{Bucket, build_buckets, find_bucket}; @@ -259,6 +259,25 @@ impl FileFacts { .filter_map(move |&index| self.members.get(index)) } + /// Same-owner, same-key declarations with the requested read/write access. + pub(crate) fn members_with_access<'a>( + &'a self, + member: &'a Member, + access: MemberAccess, + ) -> impl Iterator { + let indices = match &member.key { + LuaMemberKey::Name(name) => self + .members_by_owner_name + .get(&(member.owner.clone(), name.clone())), + _ => self.members_by_owner.get(&member.owner), + }; + indices + .into_iter() + .flat_map(|indices| indices.iter()) + .filter_map(move |&index| self.members.get(index)) + .filter(move |other| other.key == member.key && other.access == access) + } + /// Type operator overloads (by owner + operator name). pub fn operator_of(&self, owner: &SemanticId, name: &str) -> Option<&OperatorDef> { let index = self @@ -1537,6 +1556,40 @@ impl FactsBuilder { let Some(key) = field_tag.get_field_key() else { return; }; + let mut access = MemberAccess::ReadWrite; + if let Some(flags) = field_tag.get_type_flag() { + for flag in flags.get_attrib_tokens() { + let direction = match flag.get_name_text() { + "read" => MemberAccess::Read, + "write" => MemberAccess::Write, + _ => continue, + }; + if access != MemberAccess::ReadWrite { + self.annotation_errors.push(AnnotationError { + range: flag.get_range(), + message: if access == direction { + format!("Duplicate ({}) field modifier", flag.get_name_text()).into() + } else { + "A field may specify only one of (read) or (write)".into() + }, + }); + // Retain the first direction so uses of this field do not + // produce cascading undefined-field diagnostics. + break; + } + access = direction; + } + } + if access != MemberAccess::ReadWrite && matches!(key, LuaDocFieldKey::Type(_)) { + self.annotation_errors.push(AnnotationError { + range: field_tag.get_range(), + message: + "Directional fields require a named, string-literal, or integer-literal key" + .into(), + }); + // Recover as an ordinary index signature after reporting the modifier. + access = MemberAccess::ReadWrite; + } // Integer keys in `@field [1]` are encoded as `LuaMemberKey::Integer` (matching index expression keys). let member_key = match &key { LuaDocFieldKey::Type(_) => LuaMemberKey::None, @@ -1554,6 +1607,7 @@ impl FactsBuilder { member_key, type_def_id, ); + member.access = access; member.index_key_syntax = match &key { LuaDocFieldKey::Type(ty) => Some(ty.get_syntax_id()), _ => None, diff --git a/crates/emmylua_code_analysis/src/semantic_model/access.rs b/crates/emmylua_code_analysis/src/semantic_model/access.rs index dda9a7a64..9221e0f59 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/access.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/access.rs @@ -1,4 +1,5 @@ use super::prelude::*; +use crate::MemberAccess; use crate::check::checker::generic_constraint_mismatch::resolved_call_signatures; use crate::check::checker::param_count::callable_functions; use crate::check::checker::param_type_check::callable_candidates_uncached; @@ -444,6 +445,17 @@ impl<'db> SemanticModel<'db> { owner: SemanticId, name: SmolStr, ) -> ResolvedMember { + let member_id = member_id.map(|id| { + if let Some(file) = file_id + && let Some(facts) = self.file_facts_of(file) + && let Some(member) = facts.member_by_id(&id) + && member.access == MemberAccess::Write + && let Some(read) = facts.members_with_access(member, MemberAccess::Read).next() + { + return read.id.clone(); + } + id + }); let mut member_type = None; let mut visibility = None; let mut is_method = false; @@ -766,6 +778,9 @@ impl<'db> SemanticModel<'db> { }); signatures } + /// Resolve an index expression to its declaration and read view. + /// Paired directional fields use the getter identity even on an assignment target. + /// Use [`Self::directional_index`] or [`Self::assignment_target_type`] for setter types. pub fn resolve_member(&self, index_expr: &LuaIndexExpr) -> Option { let syntax = index_expr.get_syntax_id(); let file_id = self.view.file_id(); diff --git a/crates/emmylua_code_analysis/src/semantic_model/directional.rs b/crates/emmylua_code_analysis/src/semantic_model/directional.rs new file mode 100644 index 000000000..cd0bfddbf --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/directional.rs @@ -0,0 +1,130 @@ +//! Separate read/write contracts for transformed properties. +use super::{ + SemanticModel, + member::{self, MemberInfo}, + type_eval::with_expanded_alias, +}; +use crate::{LuaMemberKey, LuaType, MemberAccess, SemanticId, TypeDefKind}; +use emmylua_parser::{LuaAstNode, LuaIndexExpr, LuaIndexKey}; + +impl SemanticModel<'_> { + /// Return the storage type checked by assignment. + /// Expression-local assertions do not redefine a directional setter contract. + pub fn assignment_target_type(&self, expr: &emmylua_parser::LuaExpr) -> LuaType { + if let emmylua_parser::LuaExpr::IndexExpr(index) = expr + && let Some((_, write)) = self.directional_index(index) + { + return write.typ; + } + self.type_of_expr(expr.get_syntax_id()) + } + + pub fn directional_declaration(&self, id: &SemanticId) -> Option<(MemberInfo, MemberInfo)> { + let SemanticId::Member(key) = id else { + return None; + }; + let facts = self.file_facts_of(key.file_id)?; + let member = facts.member_by_id(id)?; + let def = facts.type_def_by_id(&member.owner)?; + self.directional_member(&self.type_def_ref(def), &member.key) + } + + /// Return the read type and the union of setter overloads, with generic substitutions. + /// The write identity remains the first setter declaration for navigation. + pub fn directional_member( + &self, + prefix: &LuaType, + key: &LuaMemberKey, + ) -> Option<(MemberInfo, MemberInfo)> { + let (read, writes) = self.directional_member_views(prefix, key)?; + let mut write = writes.first()?.clone(); + // A setter overload accepts a value matching any declared write contract. + // Keep the first declaration identity for navigation, but expose every type. + write.typ = LuaType::from_vec(writes.into_iter().map(|info| info.typ).collect()); + Some((read, write)) + } + + /// Preserve every setter declaration for contextual callback signature selection. + pub(super) fn directional_member_views( + &self, + prefix: &LuaType, + key: &LuaMemberKey, + ) -> Option<(MemberInfo, Vec)> { + let read = self.member_info(prefix, key)?; + let (writes, directional) = self.member_write_views(prefix, key, &read)?; + // Leave wholly ordinary unions on the existing inference/flow path. + directional.then_some((read, writes)) + } + + /// Collect writer types and whether any component declares a direction. + /// A failed projection stays unresolved rather than falling back to its getter. + fn member_write_views( + &self, + prefix: &LuaType, + key: &LuaMemberKey, + read: &MemberInfo, + ) -> Option<(Vec, bool)> { + let type_id = match prefix { + LuaType::Ref(id) | LuaType::Def(id) => Some(id.clone()), + LuaType::Generic(generic) => Some(generic.get_base_type_id()), + _ => None, + }; + if type_id + .and_then(|id| self.type_def_of(&id)) + .is_some_and(|def| def.kind == TypeDefKind::Alias) + { + return with_expanded_alias(self, prefix, |expanded| { + self.member_write_views(expanded, key, read) + }) + .flatten(); + } + if let LuaType::Union(union) = prefix { + let mut writes = Vec::new(); + let mut directional = false; + for component in union.iter() { + // Missing members contribute nothing, as in ordinary union lookup. + let Some(component_read) = self.member_info(&component, key) else { + continue; + }; + let (component_writes, component_directional) = + self.member_write_views(&component, key, &component_read)?; + directional |= component_directional; + writes.extend(component_writes); + } + return Some((writes, directional)); + } + let Some(SemanticId::Member(id)) = read.id.as_ref() else { + return Some((vec![read.clone()], false)); + }; + let facts = self.file_facts_of(id.file_id)?; + let field = facts.member_by_id(read.id.as_ref()?)?; + if field.access == MemberAccess::ReadWrite { + return Some((vec![read.clone()], false)); + } + let mut writers = facts + .members_with_access(field, MemberAccess::Write) + .collect::>(); + if writers.is_empty() { + writers.push(field); + } + let writes = writers + .into_iter() + .map(|writer| member::member_info_for_declaration(self, prefix, key, &writer.id)) + .collect::>>()?; + Some((writes, true)) + } + + pub fn directional_index(&self, index: &LuaIndexExpr) -> Option<(MemberInfo, MemberInfo)> { + let prefix = self.type_of_expr(index.get_prefix_expr()?.get_syntax_id()); + let key = match index.get_index_key()? { + LuaIndexKey::Name(n) => LuaMemberKey::Name(n.get_name_text().into()), + LuaIndexKey::String(s) => LuaMemberKey::Name(s.get_value().into()), + LuaIndexKey::Integer(n) => match n.get_number_value() { + emmylua_parser::NumberResult::Int(n) => LuaMemberKey::Integer(n), + _ => return None, + }, + _ => return None, + }; + self.directional_member(&prefix, &key) + } +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index 77b53917f..05dcbc4a4 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -684,6 +684,11 @@ pub(crate) fn type_of_expr_at_node( if let Some(ty) = array::last_element_type(model, index_expr, offset) { return apply_casts(ty, All); } + if let Some((read, _)) = model.directional_index(index_expr) + && let Some(typ) = directional_read_at(model, index_expr, &read, offset) + { + return apply_casts(typ, All); + } if let Some(resolved) = model.resolve_member(index_expr) && let Some(member_id) = resolved.member_id.clone().or_else(|| { let prefix = index_expr.get_prefix_expr()?; @@ -5474,11 +5479,24 @@ fn filter_type_by_member_discriminant( continue; } } - let matched = model + let can_match_branch = model .member_type(&component, key) - .map(|member_ty| types_may_overlap(member_ty, literal.clone())) - .unwrap_or(false); - if matched == keep_matching { + .map(|member_ty| { + if keep_matching { + return types_may_overlap(member_ty, literal.clone()); + } + // A union-valued member can satisfy either branch. Exclude a + // receiver only when every member value matches the literal. + match member_ty { + LuaType::Union(union) => union + .into_vec() + .iter() + .any(|ty| !type_is_removed_by(ty, literal)), + other => !type_is_removed_by(&other, literal), + } + }) + .unwrap_or(!keep_matching); + if can_match_branch { kept.push(component); } } @@ -6112,6 +6130,112 @@ fn type_is_broader(broad: &LuaType, narrow: &LuaType) -> bool { ) } +/// Bound the total backward traversal across all paths of one directional read. +const DIRECTIONAL_READ_FLOW_BUDGET: usize = 512; + +/// Directional assignments invalidate earlier guards but never substitute the +/// write value for the property's read contract. At the traversal limit, retain +/// observed path modifiers over the declared getter instead of dropping results. +fn directional_read_at( + model: &SemanticModel, + index: &emmylua_parser::LuaIndexExpr, + read: &super::member::MemberInfo, + offset: TextSize, +) -> Option { + let member = read.id.as_ref()?; + let owner = model.resolve_member(index)?.owner; + let tree = model.flow_tree()?; + let root = model.chunk()?; + let start = tree.get_flow_id_at(offset)?; + let mut pending = vec![(start, PathState::default(), HashSet::new())]; + let mut results = Vec::new(); + let mut budget = DIRECTIONAL_READ_FLOW_BUDGET; + while let Some((mut current, mut path, mut seen)) = pending.pop() { + loop { + if budget == 0 { + // Preserve completed results and modifiers already seen on every + // unfinished path; default only the unseen prefix to the getter. + results.push(finalize(model, read.typ.clone(), &path)); + results.extend( + pending + .into_iter() + .map(|(_, path, _)| finalize(model, read.typ.clone(), &path)), + ); + return Some(LuaType::from_vec(results)); + } + if !seen.insert(current) { + results.push(finalize(model, read.typ.clone(), &path)); + break; + } + budget -= 1; + let node = tree.get_flow_node(current)?; + match &node.kind { + FlowNodeKind::Assignment(ptr) => { + let assignment = ptr.to_node(&root)?; + if assignment.get_range().end() <= offset + && (assignment.get_var_and_expr_list().0.iter().any(|var| { + let emmylua_parser::LuaVarExpr::IndexExpr(target) = var else { + return false; + }; + member_key_from_index_expr(model, target).as_ref() == Some(&read.key) + && model + .resolve_member(target) + .is_some_and(|resolved| resolved.owner == owner) + }) || member_write_match( + model, + tree, + current, + member, + Some(index), + None, + ) != MemberWrite::Unrelated) + { + results.push(finalize(model, read.typ.clone(), &path)); + break; + } + } + FlowNodeKind::TrueCondition(cond) | FlowNodeKind::FalseCondition(cond) => { + // Match receiver paths, including stable aliases, before narrowing. + let expr = cond.to_node(&root)?; + let relevant = std::iter::once(expr.syntax().clone()) + .chain(expr.syntax().descendants()) + .filter_map(emmylua_parser::LuaIndexExpr::cast) + .any(|other| member_access_matches(model, member, Some(index), &other)); + if relevant + && let Some(narrowing) = narrow_member_condition( + model, + member, + Some(index), + cond, + matches!(node.kind, FlowNodeKind::TrueCondition(_)), + ) + { + path.narrowings.push(narrowing); + } + } + FlowNodeKind::TagCast(ptr) => collect_member_cast(model, member, ptr, &mut path), + // Inline assertions apply to their expression, not unrelated member flow. + FlowNodeKind::AsCast(_) => {} + _ => {} + } + match &node.antecedent { + Some(FlowAntecedent::Single(next)) => current = *next, + Some(FlowAntecedent::Multiple(id)) => { + for next in tree.get_multi_antecedents(*id)? { + pending.push((*next, path.clone_path(), seen.clone())); + } + break; + } + None => { + results.push(finalize(model, read.typ.clone(), &path)); + break; + } + } + } + } + Some(LuaType::from_vec(results)) +} + /// Test-only counters for the flow fast paths and fallback trace work. /// /// Thread-local, and test-only. The harness runs tests in parallel, so diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/asymmetric_fields.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/asymmetric_fields.rs new file mode 100644 index 000000000..dc251ecc0 --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/asymmetric_fields.rs @@ -0,0 +1,1087 @@ +#![cfg(test)] +use crate::{DiagnosticCode, LuaType, VirtualWorkspace}; + +fn check_inherited_write_projection( + declarations: &str, + receiver: &str, + read_type: &str, + write_type: &str, + valid_value: &str, +) { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "{declarations}\n---@type {receiver}\nchild = {{}}" + )); + ws.def( + "read_value = child.value\nchild.callback = function(value)\ncallback_value = value\nend", + ); + assert_eq!(ws.expr_ty("read_value"), ws.ty(read_type)); + assert_eq!(ws.expr_ty("callback_value"), ws.ty(write_type)); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + &format!("child.value = {valid_value}") + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "child.value = true")); +} + +#[test] +fn asymmetric_fields_inherited_generic_ref() { + check_inherited_write_projection( + r#" +---@class Base +---@field (read) value T +---@field (write) value T +---@field (read) callback fun(value: T) +---@field (write) callback fun(value: T) +---@class Child: Base +"#, + "Child", + "string", + "string", + "'valid'", + ); +} + +#[test] +fn asymmetric_fields_inherited_generic_mapped() { + check_inherited_write_projection( + r#" +---@class Base +---@field (read) value T +---@field (write) value T +---@field (read) callback fun(value: T) +---@field (write) callback fun(value: T) +---@class Child: Base +"#, + "Child", + "number[]", + "number[]", + "{1, 2}", + ); +} + +#[test] +fn asymmetric_fields_inherited_generic_distinct_directions() { + check_inherited_write_projection( + r#" +---@class Base +---@field (read) value R +---@field (write) value W +---@field (read) callback boolean +---@field (write) callback fun(value: W) +---@class Child: Base +"#, + "Child", + "number", + "string[]", + "{'valid'}", + ); +} + +const DEFS: &str = r#" +---@meta +---@class Fuel +---@field name string +---@class Burner +---@field (read) fuel? Fuel +---@field (write) fuel? string|Fuel +---@field (read) force Fuel +---@field (write) force string|Fuel +---@field heat number +---@class Child: Burner +---@class Box +---@field (read) value T +---@field (write) value W +---@type Burner +burner = {} +---@type Child +child = {} +---@type Box +box = {} +"#; + +#[test] +fn asymmetric_fields_partial_override_replaces_both_directions() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class Parent +---@field (read) value number +---@field (write) value string +---@class Child: Parent +---@field (read) value boolean +---@type Parent +parent = {} +---@type Child +child = {} +"#, + ); + assert_eq!(ws.expr_ty("parent.value"), LuaType::Number); + assert_eq!(ws.expr_ty("child.value"), LuaType::Boolean); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "parent.value = 'valid'")); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "parent.value = true")); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "child.value = true")); + assert!(!ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "child.value = 'invalid'" + )); +} + +#[test] +fn asymmetric_fields_read_and_write() { + let mut ws = VirtualWorkspace::new(); + ws.def(DEFS); + assert_eq!(ws.expr_ty("burner.force"), ws.ty("Fuel")); + assert_eq!(ws.expr_ty("child.force"), ws.ty("Fuel")); + assert_eq!(ws.expr_ty("box.value"), ws.ty("Fuel")); + for source in [ + "burner.fuel = 'coal'", + "burner['fuel'] = 'coal'", + "burner.fuel = nil", + "child.force = 'player'", + "box.value = 'coal'", + "burner.fuel, burner.heat = 'coal', 10", + ] { + assert!( + ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, source), + "{source}" + ); + } + for source in [ + "burner.fuel = true", + "burner.force = nil", + "burner.force = true", + "child.force = false", + "box.value = 42", + "burner.heat = 'bad'", + ] { + assert!( + !ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, source), + "{source}" + ); + } +} + +#[test] +fn asymmetric_fields_flow_and_nested_writes() { + let mut ws = VirtualWorkspace::new(); + ws.def(DEFS); + ws.def( + r#" + burner.fuel = 'coal' + after_write = burner.fuel + if burner.fuel then + guarded = burner.fuel.name + burner.fuel = nil + after_nil = burner.fuel + end + box.value = 'coal' + generic_after = box.value + "#, + ); + assert_eq!(ws.expr_ty("after_write"), ws.ty("Fuel?")); + assert_eq!(ws.expr_ty("guarded"), LuaType::String); + assert_eq!(ws.expr_ty("after_nil"), ws.ty("Fuel?")); + assert_eq!(ws.expr_ty("generic_after"), ws.ty("Fuel")); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "burner.force.name = 'ok'" + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "burner.force.name = 3")); +} + +#[test] +fn asymmetric_fields_duplicate_rules() { + let mut ws = VirtualWorkspace::new(); + assert!(ws.has_no_diagnostic(DiagnosticCode::DuplicateDocField, DEFS)); + for fields in [ + "---@field (read) value string\n---@field (read) value number", + "---@field (write) value string\n---@field (write) value number", + "---@field value string\n---@field (write) value number", + ] { + assert!(!ws.has_no_diagnostic( + DiagnosticCode::DuplicateDocField, + &format!("---@class Duplicate\n{fields}\nlocal t = {{}}") + )); + } +} + +#[test] +fn asymmetric_fields_reindex_and_incomplete_directions() { + let mut ws = VirtualWorkspace::new(); + ws.def_file("defs.lua", DEFS); + assert_eq!(ws.expr_ty("burner.force"), ws.ty("Fuel")); + ws.def_file( + "defs.lua", + &DEFS.replace("(read) force Fuel", "(read) force number"), + ); + assert_eq!(ws.expr_ty("burner.force"), LuaType::Number); + ws.def("---@class Only\n---@field (write) value string\n---@type Only\nonly = {}"); + assert_eq!(ws.expr_ty("only.value"), LuaType::String); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "only.value = 'yes'")); +} + +#[test] +fn asymmetric_fields_contextual_table_assignment() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class Input +---@field name string +---@class Output +---@field result number +---@class Container +---@field (read) value Output +---@field (write) value Input +---@type Container +container = {} +"#, + ); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "container.value = { name = 'coal' }" + )); + assert!(!ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "container.value = { name = 3 }" + )); +} + +#[test] +fn asymmetric_fields_keys_and_independent_optionality() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@meta +---@class Keyed +---@field (read) ['odd-key'] number +---@field (write) ['odd-key']? string +---@field (read) [1] number +---@field (write) [1] string +---@type Keyed +keyed = {} +"#, + ); + assert_eq!(ws.expr_ty("keyed['odd-key']"), LuaType::Number); + assert_eq!(ws.expr_ty("keyed[1]"), LuaType::Number); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "keyed['odd-key'] = nil")); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "keyed[1] = nil")); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "keyed[1] = 'ok'")); + for fields in [ + "---@field (read,write) value number", + "---@field (read) [string] number", + ] { + assert!(!ws.has_no_diagnostic( + DiagnosticCode::AnnotationUsageError, + &format!("---@class Bad\n{fields}") + )); + } +} + +#[test] +fn asymmetric_fields_callback_context() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class CallbackHolder +---@field (read) callback fun(value: number) +---@field (write) callback fun(value: string) +---@type CallbackHolder +holder = {} +holder.callback = function(value) + callback_value = value +end +"#, + ); + assert_eq!(ws.expr_ty("callback_value"), LuaType::String); +} + +#[test] +fn asymmetric_fields_write_first_and_branch_invalidation() { + let mut ws = VirtualWorkspace::new(); + ws.def("---@class Converted\n---@field (write) value string\n---@field (read) value number?\n---@type Converted\na = {}\n---@type Converted\nb = {}\n"); + assert_eq!(ws.expr_ty("a.value"), ws.ty("number?")); + ws.def( + r#" + if a.value then + before = a.value + a.value = 'reset' + after = a.value + end + if b.value then + independent = a.value + end + if a.value then + if ... then a.value = 'reset' end + merged = a.value + end + "#, + ); + assert_eq!(ws.expr_ty("before"), LuaType::Number); + assert_eq!(ws.expr_ty("after"), ws.ty("number?")); + assert_eq!(ws.expr_ty("independent"), ws.ty("number?")); + assert_eq!(ws.expr_ty("merged"), ws.ty("number?")); +} + +// Directional member flow must retain upstream expression-local @as behavior. +#[googletest::gtest] +fn asymmetric_fields_keep_inline_assertions_expression_local() { + use googletest::prelude::*; + let mut ws = VirtualWorkspace::new(); + ws.def(DEFS); + ws.def( + r#" + local unrelated = 123 --[[@as string]] + before_assertion = burner.force + explicit_assertion = burner.force --[[@as integer]] + after_assertion = burner.force + optional_after_assertion = burner.fuel + "#, + ); + let fuel = ws.ty("Fuel"); + let optional_fuel = ws.ty("Fuel?"); + assert_that!(ws.expr_ty("before_assertion"), eq(&fuel)); + assert_that!(ws.expr_ty("explicit_assertion"), eq(&LuaType::Integer)); + assert_that!(ws.expr_ty("after_assertion"), eq(&fuel)); + assert_that!(ws.expr_ty("optional_after_assertion"), eq(&optional_fuel)); +} + +#[test] +fn asymmetric_fields_review_repeated_direction_flags() { + for flags in ["read,read", "write,write", "read,write", "write,read"] { + let mut ws = VirtualWorkspace::new(); + assert!( + !ws.has_no_diagnostic( + DiagnosticCode::AnnotationUsageError, + &format!("---@class Invalid\n---@field ({flags}) value number"), + ), + "{flags}" + ); + } +} + +#[test] +fn asymmetric_fields_review_nullable_contracts() { + for typ in ["Fuel", "Fuel[]", "fun(value: string)"] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Fuel\n---@field name string\n---@class Property\n---@field (read) value? {typ}\n---@field (write) value? {typ}\n---@type Property\nproperty = {{}}", + )); + assert_eq!( + ws.expr_ty("property.value"), + ws.ty(&format!("({typ})?")), + "{typ}" + ); + assert!( + ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = nil"), + "{typ}" + ); + assert!( + !ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = true"), + "{typ}" + ); + } +} + +#[test] +fn asymmetric_fields_review_branch_guard_merge() { + let mut ws = VirtualWorkspace::new(); + ws.def(DEFS); + ws.def( + r#" + if burner.fuel then + if ... then + branch = 1 + else + branch = 2 + end + shared_predecessor = burner.fuel + if ... then burner.fuel = 'coal' end + after_possible_write = burner.fuel + end + "#, + ); + assert_eq!(ws.expr_ty("shared_predecessor"), ws.ty("Fuel")); + assert_eq!(ws.expr_ty("after_possible_write"), ws.ty("Fuel?")); +} + +#[test] +fn asymmetric_fields_review_repeated_callback_writer_context() { + for fields in [ + "---@field callback fun(value: string)\n---@field callback fun(value: number)", + "---@field (read) callback fun(value: boolean)\n---@field (write) callback fun(value: string)\n---@field (write) callback fun(value: number)", + "---@field (write) callback fun(value: string)\n---@field (read) callback fun(value: boolean)\n---@field (write) callback fun(value: number)", + "---@field (write) callback fun(value: string)\n---@field (write) callback fun(value: number)\n---@field (read) callback fun(value: boolean)", + "---@field (write) callback fun(value: string)\n---@field (write) callback fun(value: number)", + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Holder\n{fields}\n---@type Holder\nholder = {{}}\nholder.callback = function(value)\n callback_value = value\nend", + )); + assert_eq!(ws.expr_ty("callback_value"), LuaType::Number, "{fields}"); + } +} + +#[test] +fn asymmetric_fields_review_repeated_callback_writer_generics() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@class Holder + ---@field (read) callback fun(value: boolean) + ---@field (write) callback fun(value: string) + ---@field (write) callback fun(value: T) + ---@class Unrelated + ---@field (write) callback fun(value: table) + ---@type Holder + holder = {} + holder.callback = function(value) + callback_value = value + end + "#, + ); + assert_eq!(ws.expr_ty("callback_value"), LuaType::Number); + assert_eq!(ws.expr_ty("holder.callback"), ws.ty("fun(value: boolean)")); +} + +#[test] +fn asymmetric_fields_review_member_collection_preserves_real_overloads() { + for fields in [ + "---@field (read) value number\n---@field (write) value string", + "---@field (write) value string\n---@field (read) value number", + ] { + let mut ws = VirtualWorkspace::new(); + let file = ws.def_file("pair.lua", &format!("---@class Pair\n{fields}")); + let model = ws.analysis.semantic_model(file); + let ty = LuaType::Ref(crate::LuaTypeDeclId::global("Pair")); + let infos = + model.member_infos_with_key_all(&ty, &crate::LuaMemberKey::Name("value".into())); + assert_eq!(infos.len(), 1, "{fields}: {infos:?}"); + assert_eq!(infos[0].typ, LuaType::Number); + } + for flag in ["", "(write) "] { + let mut ws = VirtualWorkspace::new(); + let file = ws.def_file("overloads.lua", &format!( + "---@class Overloads\n---@field {flag}callback fun(value: string)\n---@field {flag}callback fun(value: number)" + )); + let model = ws.analysis.semantic_model(file); + let ty = LuaType::Ref(crate::LuaTypeDeclId::global("Overloads")); + let infos = + model.member_infos_with_key_all(&ty, &crate::LuaMemberKey::Name("callback".into())); + assert_eq!(infos.len(), 2, "{flag}: {infos:?}"); + assert_ne!(infos[0].id, infos[1].id); + } +} + +#[test] +fn asymmetric_fields_review_repeated_direction_messages() { + for (flags, expected) in [ + ("read,read", "Duplicate (read) field modifier"), + ("write,write", "Duplicate (write) field modifier"), + ( + "read,write", + "A field may specify only one of (read) or (write)", + ), + ( + "write,read", + "A field may specify only one of (read) or (write)", + ), + ] { + let mut ws = VirtualWorkspace::new(); + let file = ws.def_file( + "invalid.lua", + &format!("---@class Invalid\n---@field ({flags}) value number"), + ); + let model = ws.analysis.semantic_model(file); + let errors = &model.file_facts().unwrap().annotation_errors; + assert_eq!(errors.len(), 1, "{flags}: {errors:?}"); + assert_eq!(errors[0].message, expected, "{flags}"); + let document = model.document(file).unwrap(); + assert_eq!( + document.get_text_slice(errors[0].range), + flags.split(',').nth(1).unwrap(), + "{flags}" + ); + } +} + +fn check_directional_guard_after_write(setup: &str, access: &str, action: &str, invalidates: bool) { + let mut ws = VirtualWorkspace::new(); + ws.analysis + .diagnostic + .enable_only(DiagnosticCode::ParamTypeMismatch); + let file = ws.def(&format!( + r#" +---@class Converted +---@field (read) value number? +---@field (write) value string? +---@class Root +---@field left Converted +---@field right Converted +---@param value number +local function accept_number(value) end +local function harmless() end +---@param a Converted +---@param other Converted +---@param root Root +---@param other_root Root +---@param unknown fun(value: Converted) +local function check(a, other, root, other_root, unknown) + {setup} + if {access} then + before_write = {access} + {action} + after_write = {access} + accept_number({access}) + end +end +"#, + )); + assert_eq!(ws.expr_ty("before_write"), LuaType::Number, "{action}"); + let expected = if invalidates { + ws.ty("number?") + } else { + LuaType::Number + }; + assert_eq!(ws.expr_ty("after_write"), expected, "{action}"); + let diagnostics = ws + .analysis + .diagnose_file(file, tokio_util::sync::CancellationToken::new()) + .unwrap(); + assert_eq!(diagnostics.len(), usize::from(invalidates), "{action}"); +} + +#[test] +fn asymmetric_fields_review_receiver_replacement_resets_guard() { + check_directional_guard_after_write("", "a.value", "a = other", true); +} + +#[test] +fn asymmetric_fields_review_ancestor_replacement_resets_guard() { + check_directional_guard_after_write("", "root.left.value", "root.left = other", true); +} + +#[test] +fn asymmetric_fields_review_root_replacement_resets_guard() { + check_directional_guard_after_write("", "root.left.value", "root = other_root", true); +} + +#[test] +fn asymmetric_fields_review_alias_write_resets_guard() { + check_directional_guard_after_write( + "local alias = a", + "a.value", + "alias.value = 'changed'", + true, + ); + check_directional_guard_after_write( + "local alias = root", + "root.left.value", + "alias.left = other", + true, + ); +} + +#[test] +fn asymmetric_fields_review_exact_write_restores_getter() { + for action in ["a.value = nil", "a.value = 'changed'"] { + check_directional_guard_after_write("", "a.value", action, true); + } +} + +#[test] +fn asymmetric_fields_review_unrelated_writes_and_calls_keep_guard() { + for action in ["", "harmless()", "unknown(a)", "other.value = 'changed'"] { + check_directional_guard_after_write("", "a.value", action, false); + } + check_directional_guard_after_write("", "root.left.value", "root.right = other", false); + check_directional_guard_after_write("local alias = a", "a.value", "alias = other", false); + check_directional_guard_after_write("local alias = a", "alias.value", "a = other", false); +} + +#[test] +fn asymmetric_fields_review_new_guard_after_replacement() { + check_directional_guard_after_write( + "", + "a.value", + "a = other\nif a.value == nil then return end", + false, + ); +} + +#[test] +fn asymmetric_fields_review_new_guard_after_setter_write() { + check_directional_guard_after_write( + "", + "a.value", + "a.value = 'changed'\nif a.value == nil then return end", + false, + ); +} + +#[test] +fn asymmetric_fields_review_nullable_discriminant_keeps_receiver() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class Absent +---@field (read) value nil +---@field (write) value string? +---@class Maybe +---@field (read) value number? +---@field (write) value string? +---@param a Absent|Maybe +local function check(a) + if a.value == nil then + matches_nil = a + else + may_have_number = a + number_value = a.value + end +end +"#, + ); + assert_eq!(ws.expr_ty("matches_nil"), ws.ty("Absent|Maybe")); + assert_eq!(ws.expr_ty("may_have_number"), ws.ty("Maybe")); + assert_eq!(ws.expr_ty("number_value"), LuaType::Number); +} + +#[test] +fn asymmetric_fields_review_ordinary_nullable_discriminant_keeps_receiver() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class Absent +---@field value nil +---@class Maybe +---@field value number? +---@param a Absent|Maybe +local function check(a) + if a.value == nil then + matches_nil = a + else + may_have_number = a + end +end +"#, + ); + assert_eq!(ws.expr_ty("matches_nil"), ws.ty("Absent|Maybe")); + assert_eq!(ws.expr_ty("may_have_number"), ws.ty("Maybe")); +} + +#[test] +fn asymmetric_fields_review_alias_guard_tracks_receiver() { + for (before_guard, after_guard, narrowed) in [ + ("", "", true), + ("a = other", "", false), + ("alias = other", "", false), + ("", "a = other", false), + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + r#" +---@class Converted +---@field (read) value number? +---@field (write) value string? +---@param a Converted +---@param other Converted +local function check(a, other) + local alias = a + {before_guard} + if alias.value then + {after_guard} + guarded = a.value + end +end +"#, + )); + let expected = if narrowed { "number" } else { "number?" }; + assert_eq!( + ws.expr_ty("guarded"), + ws.ty(expected), + "{before_guard}; {after_guard}" + ); + } +} + +const UNION_DEFS: &str = r#" +---@class A +---@field kind 'a' +---@field (read) value boolean +---@field (write) value string +---@class B +---@field kind 'b' +---@field (read) value number +---@field (write) value number +---@class Generic +---@field (read) value boolean +---@field (write) value T +---@class Ordinary +---@field value number +---@alias Choice A|B +---@alias GenericChoice Generic|B +"#; + +#[test] +fn asymmetric_fields_review_union_setter_alternatives() { + for receiver in ["A|B", "B|A", "Choice", "GenericChoice"] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "{UNION_DEFS}\n---@type {receiver}\nproperty = {{}}" + )); + assert_eq!(ws.expr_ty("property.value"), ws.ty("boolean|number")); + for value in ["'accepted'", "42"] { + assert!( + ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + &format!("property.value = {value}"), + ), + "{receiver}: {value}" + ); + } + assert!( + !ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = true"), + "{receiver}" + ); + } +} + +#[test] +fn asymmetric_fields_review_union_keeps_ordinary_writer_alternatives() { + for receiver in [ + "A|Ordinary", + "Ordinary|A", + "A|{value:number}", + "{value:number}|A", + ] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "{UNION_DEFS}\n---@type {receiver}\nproperty = {{}}" + )); + assert_eq!(ws.expr_ty("property.value"), ws.ty("boolean|number")); + for value in ["'accepted'", "42"] { + assert!( + ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + &format!("property.value = {value}"), + ), + "{receiver}: {value}" + ); + } + assert!( + !ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = true"), + "{receiver}" + ); + } +} + +#[test] +fn asymmetric_fields_review_union_keeps_distinct_generic_writers() { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "{UNION_DEFS}\n---@type Generic|Generic\nproperty = {{}}" + )); + assert_eq!(ws.expr_ty("property.value"), LuaType::Boolean); + for value in ["'accepted'", "42"] { + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + &format!("property.value = {value}"), + )); + } + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = true")); +} + +#[test] +fn asymmetric_fields_review_union_keeps_ordinary_lookup_policy() { + for receiver in ["A|nil", "A|Missing", "Recursive"] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "{UNION_DEFS}\n---@class Missing\n---@alias Recursive A|Recursive\n---@type {receiver}\nproperty = {{}}" + )); + assert_eq!(ws.expr_ty("property.value"), LuaType::Boolean); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "property.value = 'accepted'" + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "property.value = true")); + } +} + +#[test] +fn asymmetric_fields_review_union_narrowing_selects_setter() { + for (kind, valid, invalid) in [("a", "'accepted'", "42"), ("b", "42", "'rejected'")] { + let mut ws = VirtualWorkspace::new(); + ws.def(UNION_DEFS); + let assignment = |value| { + format!( + "---@param property A|B\nlocal function check(property)\nif property.kind == '{kind}' then\nproperty.value = {value}\nend\nend" + ) + }; + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, &assignment(valid))); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, &assignment(invalid))); + } +} + +#[test] +fn asymmetric_fields_review_invalid_annotation_retains_member() { + for field in [ + "(read,read) value", + "(write,write) value", + "(read,write) value", + "(write,read) value", + "(read) [string]", + ] { + let mut ws = VirtualWorkspace::new(); + ws.analysis + .diagnostic + .enable_only(DiagnosticCode::UndefinedField); + let source = |field: &str| { + format!( + "---@class Invalid\n---@field {field} number\n---@param target Invalid\nlocal function check(target)\nread_value = target.value\nend" + ) + }; + let file = ws.def(&source(field)); + assert_eq!( + ws.analysis + .semantic_model(file) + .file_facts() + .unwrap() + .annotation_errors + .len(), + 1, + "{field}" + ); + let diagnostics = ws + .analysis + .diagnose_file(file, tokio_util::sync::CancellationToken::new()) + .unwrap(); + assert!(diagnostics.is_empty(), "{field}: {diagnostics:?}"); + let ordinary_field = field.split_once(' ').unwrap().1; + let mut ordinary = VirtualWorkspace::new(); + ordinary.def(&source(ordinary_field)); + assert_eq!( + ws.expr_ty("read_value"), + ordinary.expr_ty("read_value"), + "{field}" + ); + if ordinary_field == "value" { + assert!(!ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + "---@param target Invalid\nlocal function check(target)\nreturn target.missing\nend" + )); + } + } +} + +#[test] +fn asymmetric_fields_review_overloads_are_checked_per_direction() { + for (single, repeated) in [("read", "write"), ("write", "read")] { + let fields = [ + format!("---@field ({single}) value number"), + format!("---@field ({repeated}) value fun(value: string)"), + format!("---@field ({repeated}) value fun(value: boolean)"), + ]; + for order in [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] { + let mut ws = VirtualWorkspace::new(); + let declarations = order.map(|i| fields[i].as_str()).join("\n"); + assert!( + ws.has_no_diagnostic( + DiagnosticCode::DuplicateDocField, + &format!("---@class Converted\n{declarations}"), + ), + "{declarations}" + ); + } + } +} + +#[test] +fn asymmetric_fields_review_invalid_duplicate_directions_still_report() { + for declarations in [ + "---@field (read) value number\n---@field (read) value number\n---@field (write) value fun(value: string)", + "---@field (write) value string\n---@field (write) value string\n---@field (read) value fun(value: number)", + "---@field (read) value number\n---@field (write) value fun(value: string)\n---@field value fun(value: boolean)", + "---@field value number\n---@field value number", + ] { + let mut ws = VirtualWorkspace::new(); + assert!( + !ws.has_no_diagnostic( + DiagnosticCode::DuplicateDocField, + &format!("---@class Converted\n{declarations}"), + ), + "{declarations}" + ); + } +} + +#[test] +fn asymmetric_fields_review_callback_assignment_accepts_each_writer() { + for (first, last) in [("string", "number"), ("number", "string")] { + for read in ["---@field (read) callback fun(value: boolean)\n", ""] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Holder\n{read}---@field (write) callback fun(value: {first})\n---@field (write) callback fun(value: {last})\n---@type Holder\nholder = {{}}" + )); + ws.def("holder.callback = function(value) callback_context = value end"); + assert_eq!(ws.expr_ty("callback_context"), ws.ty(last)); + assert!( + ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "holder.callback = function(value) callback_value = value end" + ), + "{first}/{last}: inferred callback" + ); + for typ in [first, last] { + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, + &format!("---@param value {typ}\nlocal function callback(value) end\nholder.callback = callback")), "{first}/{last}: {typ}"); + } + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, + "---@param value boolean\nlocal function callback(value) end\nholder.callback = callback")); + } + } +} + +#[test] +fn asymmetric_fields_review_ordinary_callback_assignment_is_unchanged() { + for (first, last) in [("string", "number"), ("number", "string")] { + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Holder\n---@field callback fun(value: {first})\n---@field callback fun(value: {last})\n---@type Holder\nholder = {{}}" + )); + assert!(!ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "holder.callback = function(value) end" + )); + assert!(ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, + &format!("---@param value {first}\nlocal function callback(value) end\nholder.callback = callback"))); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, + &format!("---@param value {last}\nlocal function callback(value) end\nholder.callback = callback"))); + } +} + +#[test] +fn asymmetric_fields_review_budget_preserves_completed_cast_paths() { + for op in ["", "+"] { + for length in [0, 600] { + let long = (0..length) + .map(|i| format!("noise = {i}\n")) + .collect::(); + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Box\n---@field (read) value number\n---@field (write) value number\n---@type Box\nlocal box = {{}}\nlocal noise = 0\nif flag then\n{long}else\n---@cast box.value {op}string\ncast_seen = box.value\nend\nresult = box.value" + )); + assert_eq!( + ws.expr_ty("result"), + ws.ty("number|string"), + "{op}: {length}" + ); + } + } +} + +#[test] +fn asymmetric_fields_review_lhs_assertion_keeps_setter_contract() { + let mut ws = VirtualWorkspace::new(); + ws.def("---@class Box\n---@field (read) value number\n---@field (write) value string\n---@type Box\nbox = {}"); + for (value, accepted) in [("'valid'", true), ("false", false)] { + let source = format!("box.value --[[@as boolean]] = {value}"); + assert!(ws.has_no_diagnostic(DiagnosticCode::SyntaxError, &source)); + assert_eq!( + ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, &source), + accepted + ); + } +} + +#[test] +fn asymmetric_fields_review_budget_preserves_partial_cast_paths() { + for (op, expected) in [("", "string"), ("+", "number|string")] { + for length in [0, 600] { + let long = (0..length) + .map(|i| format!("noise = {i}\n")) + .collect::(); + let mut ws = VirtualWorkspace::new(); + ws.def(&format!( + "---@class Box\n---@field (read) value number\n---@field (write) value number\n---@type Box\nlocal box = {{}}\nlocal noise = 0\nif flag then\nnoise = 1\nelse\n{long}end\n---@cast box.value {op}string\nresult = box.value" + )); + assert_eq!(ws.expr_ty("result"), ws.ty(expected), "{op}: {length}"); + } + } +} + +#[test] +fn asymmetric_fields_index_signature_domains_remain_separate() { + let fields = r#" +---@class Indexed +---@field [integer] T +---@field [string] boolean +---@field (read) integer number +---@field (write) integer string +"#; + let mut ws = VirtualWorkspace::new(); + assert!(ws.has_no_diagnostic(DiagnosticCode::DuplicateDocField, fields)); + assert!(!ws.has_no_diagnostic( + DiagnosticCode::DuplicateDocField, + &format!("{fields}\n---@field [integer] boolean") + )); + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def(&format!( + "{fields}\n---@type Indexed\nindexed = {{}}" + )); + let indexed_type = ws.ty("Indexed"); + let index_infos = ws + .analysis + .semantic_model(file_id) + .index_infos(&indexed_type); + assert_eq!(index_infos.len(), 2); + assert!( + index_infos + .iter() + .any(|info| info.key_type == LuaType::Integer && info.value_type == LuaType::String) + ); + assert!( + index_infos + .iter() + .any(|info| info.key_type == LuaType::String && info.value_type == LuaType::Boolean) + ); + assert_eq!(ws.expr_ty("indexed.integer"), LuaType::Number); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "indexed.integer = 'accepted'" + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "indexed.integer = 42")); +} + +#[test] +fn asymmetric_fields_array_members_belong_to_elements() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +---@class Element +---@field (read) value number +---@field (write) value string +---@type Element[] +items = {} +"#, + ); + assert_eq!(ws.expr_ty("items[1].value"), LuaType::Number); + assert!(ws.has_no_diagnostic( + DiagnosticCode::UndefinedField, + "local value = items[1].value" + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::UndefinedField, "local value = items.value")); + assert!(ws.has_no_diagnostic( + DiagnosticCode::AssignTypeMismatch, + "items[1].value = 'accepted'" + )); + assert!(!ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, "items[1].value = true")); + ws.def("items[1].value = 'accepted'\nread_after = items[1].value"); + assert_eq!(ws.expr_ty("read_after"), LuaType::Number); +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs index 66583daf0..2e77700c9 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs @@ -6,6 +6,7 @@ mod and_or_test; mod annotation_test; mod array_test; +mod asymmetric_fields; mod attribute_test; mod callable_return_infer_test; mod closure_generic; diff --git a/crates/emmylua_code_analysis/src/semantic_model/member.rs b/crates/emmylua_code_analysis/src/semantic_model/member.rs index 516af6715..627947f9d 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/member.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/member.rs @@ -7,7 +7,7 @@ use crate::semantic_db::def::{DeclKind, MemberRef, SemanticId, TypeDef, TypeDefK use crate::semantic_model::infer::unify::{self, TplBindings}; use crate::semantic_model::type_eval::{map_type, substitute_named_refs, with_expanded_alias}; use crate::{FileId, GenericTplId, InFiled, LuaMemberKey, LuaType}; -use crate::{Member, semantic_db::facts::FileFacts}; +use crate::{Member, MemberAccess, semantic_db::facts::FileFacts}; use smol_str::SmolStr; use super::SemanticModel; @@ -44,7 +44,16 @@ pub fn member_infos(model: &SemanticModel, prefix_type: &LuaType) -> Vec Vec { let mut out = Vec::new(); let mut visited = Vec::new(); - collect_members(model, prefix_type, None, None, &mut visited, &mut out, None); + collect_members( + model, + prefix_type, + None, + None, + &mut visited, + &mut out, + None, + None, + ); out } @@ -125,6 +134,7 @@ fn find_type_def_member( visited, &mut infos, Some(key), + None, ); if let Some(info) = infos.into_iter().next() { visited.pop(); @@ -382,11 +392,13 @@ pub fn member_infos_with_key( &mut visited, &mut out, Some(key), + None, ); dedup_by_key(out) } -/// Members with the given key of a prefix type (all matches, no dedup; duplicate `@field` lines are kept as overloads). +/// All matching declarations, preserving distinct `@field` overloads. +/// A setter redirected to its getter does not add another copy of that getter. pub(crate) fn member_infos_with_key_all( model: &SemanticModel, prefix_type: &LuaType, @@ -402,10 +414,34 @@ pub(crate) fn member_infos_with_key_all( &mut visited, &mut out, Some(key), + None, ); out } +/// Project one declaration through the receiver's inherited generic bindings. +/// Unlike a read query, this preserves a selected setter's own type and identity. +pub(super) fn member_info_for_declaration( + model: &SemanticModel, + prefix_type: &LuaType, + key: &LuaMemberKey, + declaration: &SemanticId, +) -> Option { + let mut out = Vec::new(); + collect_members( + model, + prefix_type, + None, + None, + &mut Vec::new(), + &mut out, + Some(key), + Some(declaration), + ); + out.into_iter() + .find(|info| info.id.as_ref() == Some(declaration)) +} + /// Member type for a prefix type + key (old `infer_member_type`). pub fn member_type( model: &SemanticModel, @@ -562,6 +598,7 @@ fn collect_members( visited: &mut Vec, out: &mut Vec, filter: Option<&LuaMemberKey>, + raw_member: Option<&SemanticId>, ) { match prefix_type { // Named type: @field + inheritance + runtime value members. @@ -578,6 +615,7 @@ fn collect_members( visited, out, filter, + raw_member, ); }); return; @@ -621,6 +659,7 @@ fn collect_members( visited, out, filter, + raw_member, ); return; } @@ -633,6 +672,7 @@ fn collect_members( visited, out, filter, + raw_member, ); } } @@ -642,7 +682,9 @@ fn collect_members( && def.kind == TypeDefKind::Alias { with_expanded_alias(model, prefix_type, |expanded| { - collect_members(model, expanded, None, None, visited, out, filter); + collect_members( + model, expanded, None, None, visited, out, filter, raw_member, + ); }); return; } @@ -675,13 +717,20 @@ fn collect_members( visited, out, filter, + raw_member, ); } // Anonymous table literal: field members of the synthesized owner (file, range). LuaType::TableConst(table) => { let owner = SemanticId::member(table.file_id, table.value); for member_ref in owner_member_refs(model, &owner, filter) { - if let Some(info) = member_info_of(model, &member_ref, bindings, name_bindings) { + if let Some(info) = member_info_for_collection( + model, + &member_ref, + bindings, + name_bindings, + raw_member, + ) { if let Some(expanded) = expand_table_multi_return_member(model, table, &member_ref, &info, filter) { @@ -706,9 +755,13 @@ fn collect_members( } for decl_owner in owners { for member_ref in owner_member_refs(model, &decl_owner, filter) { - if let Some(info) = - member_info_of(model, &member_ref, bindings, name_bindings) - { + if let Some(info) = member_info_for_collection( + model, + &member_ref, + bindings, + name_bindings, + raw_member, + ) { push_member_info(out, filter, info); } } @@ -735,7 +788,13 @@ fn collect_members( LuaType::String | LuaType::StringConst(_) | LuaType::DocStringConst(_) => { let string_owner = SemanticId::name(SmolStr::new("string")); for member_ref in owner_member_refs(model, &string_owner, filter) { - if let Some(info) = member_info_of(model, &member_ref, bindings, name_bindings) { + if let Some(info) = member_info_for_collection( + model, + &member_ref, + bindings, + name_bindings, + raw_member, + ) { push_member_info(out, filter, info); } } @@ -751,6 +810,7 @@ fn collect_members( visited, out, filter, + raw_member, ); } } @@ -766,6 +826,7 @@ fn collect_members( visited, &mut component_members, filter, + raw_member, ); for info in component_members { if let Some(existing) = out.iter_mut().find(|existing| existing.key == info.key) @@ -876,6 +937,7 @@ fn collect_type_def_members( visited: &mut Vec, out: &mut Vec, filter: Option<&LuaMemberKey>, + raw_member: Option<&SemanticId>, ) { if visited.contains(&def.id) || visited.len() >= MAX_MEMBER_INHERITANCE_DEPTH { return; @@ -884,14 +946,25 @@ fn collect_type_def_members( // 1. This class's `@field` (workspace member index, cross-file). for member_ref in owner_member_refs(model, &def.id, filter) { - if let Some(info) = member_info_of(model, &member_ref, bindings, name_bindings) { + if let Some(info) = + member_info_for_collection(model, &member_ref, bindings, name_bindings, raw_member) + { push_member_info(out, filter, info); } } // 2. Parent types for super_type in inherited_base_types(model, def, bindings, name_bindings) { - collect_members(model, &super_type, None, None, visited, out, filter); + collect_members( + model, + &super_type, + None, + None, + visited, + out, + filter, + raw_member, + ); } // 3. Runtime value members: members of the type's runtime value decl (same-file same-name `local M = {}`) (`M.x = 1`). @@ -900,7 +973,9 @@ fn collect_type_def_members( continue; } for member_ref in owner_member_refs(model, &owner, filter) { - if let Some(info) = member_info_of(model, &member_ref, bindings, name_bindings) { + if let Some(info) = + member_info_for_collection(model, &member_ref, bindings, name_bindings, raw_member) + { push_member_info(out, filter, info); } } @@ -973,6 +1048,19 @@ fn is_class_table_member( } } +/// Collection visits the getter declaration itself; skip a setter remapped to it. +/// Keep distinct declarations and generic projections, including real overloads. +fn member_info_for_collection( + model: &SemanticModel, + member_ref: &MemberRef, + bindings: Option<&TplBindings>, + name_bindings: Option<&HashMap>, + raw_member: Option<&SemanticId>, +) -> Option { + member_info_of_projection(model, member_ref, bindings, name_bindings, raw_member) + .filter(|info| info.id.as_ref() == Some(&member_ref.id)) +} + /// Member ref → `MemberInfo` (type projection + generic substitution). /// /// index-signature members and version-hidden members are skipped. @@ -982,8 +1070,36 @@ fn member_info_of( bindings: Option<&TplBindings>, name_bindings: Option<&HashMap>, ) -> Option { + member_info_of_projection(model, member_ref, bindings, name_bindings, None) +} + +fn member_info_of_projection( + model: &SemanticModel, + member_ref: &MemberRef, + bindings: Option<&TplBindings>, + name_bindings: Option<&HashMap>, + raw_member: Option<&SemanticId>, +) -> Option { + if raw_member.is_some_and(|id| id != &member_ref.id) { + return None; + } let facts = model.file_facts_of(member_ref.file_id)?; let member = facts.member_by_id(&member_ref.id)?; + if raw_member.is_none() + && member.access == MemberAccess::Write + && let Some(read) = facts.members_with_access(member, MemberAccess::Read).next() + { + return member_info_of( + model, + &MemberRef { + file_id: member_ref.file_id, + id: read.id.clone(), + name: read.key.to_path().into(), + }, + bindings, + name_bindings, + ); + } if member.index_key_syntax.is_some() || !member_version_visible(model, facts, &member) { return None; } @@ -1034,15 +1150,16 @@ pub(super) fn projected_member_type( // Generic aliases stay lazy and can resolve to primitives, so they retain nil. if member.is_nullable && !typ.is_nullable() - && !matches!( - typ, - LuaType::Function - | LuaType::DocFunction(_) - | LuaType::Ref(_) - | LuaType::Def(_) - | LuaType::Array(_) - | LuaType::Object(_) - ) + && (member.access != MemberAccess::ReadWrite + || !matches!( + typ, + LuaType::Function + | LuaType::DocFunction(_) + | LuaType::Ref(_) + | LuaType::Def(_) + | LuaType::Array(_) + | LuaType::Object(_) + )) { typ = LuaType::from_vec(vec![typ, LuaType::Nil]); } diff --git a/crates/emmylua_code_analysis/src/semantic_model/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/mod.rs index 1d42c4fa9..3be92063d 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/mod.rs @@ -9,6 +9,7 @@ pub mod type_eval; mod access; mod builtin; +mod directional; mod doc_types; mod prelude; mod signature; diff --git a/crates/emmylua_code_analysis/src/semantic_model/signature.rs b/crates/emmylua_code_analysis/src/semantic_model/signature.rs index ba546dfea..0aa659ffb 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/signature.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/signature.rs @@ -863,7 +863,11 @@ impl<'db> SemanticModel<'db> { .and_then(|prefix| self.declared_path_type(&prefix)) .unwrap_or(owner_ty); - let infos = member::member_infos_with_key_all(self, &owner_ty, &key); + let infos = if let Some((_, writes)) = self.directional_member_views(&owner_ty, &key) { + writes + } else { + member::member_infos_with_key_all(self, &owner_ty, &key) + }; let mut out = Vec::new(); for info in &infos { let typ = match &info.typ { diff --git a/crates/emmylua_ls/src/handlers/common/directional.rs b/crates/emmylua_ls/src/handlers/common/directional.rs new file mode 100644 index 000000000..c38231256 --- /dev/null +++ b/crates/emmylua_ls/src/handlers/common/directional.rs @@ -0,0 +1,25 @@ +use emmylua_code_analysis::{MemberInfo, SemanticModel}; +use emmylua_parser::{LuaAssignStat, LuaAstNode, LuaIndexExpr, LuaSyntaxToken}; + +pub(crate) fn directional_index_at( + model: &SemanticModel, + token: &LuaSyntaxToken, +) -> Option<(MemberInfo, MemberInfo, bool)> { + let index = token.parent_ancestors().find_map(LuaIndexExpr::cast)?; + if !index + .get_index_key()? + .get_range()? + .contains_range(token.text_range()) + { + return None; + } + let (read, write) = model.directional_index(&index)?; + let assignment = index.get_parent::().is_some_and(|assign| { + assign + .get_var_and_expr_list() + .0 + .iter() + .any(|var| var.get_range() == index.get_range()) + }); + Some((read, write, assignment)) +} diff --git a/crates/emmylua_ls/src/handlers/common/mod.rs b/crates/emmylua_ls/src/handlers/common/mod.rs index f7b675424..3b777f23a 100644 --- a/crates/emmylua_ls/src/handlers/common/mod.rs +++ b/crates/emmylua_ls/src/handlers/common/mod.rs @@ -2,3 +2,6 @@ mod semantic_reference; pub(crate) use semantic_reference::*; mod semantic_alias; pub(crate) use semantic_alias::*; + +mod directional; +pub(crate) use directional::*; diff --git a/crates/emmylua_ls/src/handlers/completion/completion_builder.rs b/crates/emmylua_ls/src/handlers/completion/completion_builder.rs index b880dd03e..b6226ed2a 100644 --- a/crates/emmylua_ls/src/handlers/completion/completion_builder.rs +++ b/crates/emmylua_ls/src/handlers/completion/completion_builder.rs @@ -257,6 +257,11 @@ impl<'a> CompletionBuilder<'a> { let emmylua_parser::LuaVarExpr::IndexExpr(index_expr) = var else { return None; }; + if self.semantic_model.directional_index(index_expr).is_some() { + // Directional targets use the write-aware providers, which also keep + // literal alternatives when a setter accepts both functions and values. + return None; + } let key_text = index_expr.get_index_key()?.get_path_part(); // 1. Normal member resolution. diff --git a/crates/emmylua_ls/src/handlers/completion/providers/equality_provider.rs b/crates/emmylua_ls/src/handlers/completion/providers/equality_provider.rs index 38c9098d3..adb7985dd 100644 --- a/crates/emmylua_ls/src/handlers/completion/providers/equality_provider.rs +++ b/crates/emmylua_ls/src/handlers/completion/providers/equality_provider.rs @@ -126,7 +126,7 @@ fn get_token_should_type(builder: &CompletionBuilder) -> Option> { let var = vars.first()?; let var_type = builder .semantic_model - .type_of_expr(var.to_expr().get_syntax_id()); + .assignment_target_type(&var.to_expr()); if !contains_function(builder, &var_type) && !matches!(var_type, LuaType::Unknown) { return Some(vec![var_type]); } @@ -190,7 +190,7 @@ fn expr_should_type(builder: &CompletionBuilder, expr: &emmylua_parser::LuaExpr) ret } -fn contains_function(builder: &CompletionBuilder, typ: &LuaType) -> bool { +pub(super) fn contains_function(builder: &CompletionBuilder, typ: &LuaType) -> bool { match typ { LuaType::DocFunction(_) | LuaType::Function | LuaType::Signature(_) => true, LuaType::Union(union) => union.into_vec().iter().any(|component| { diff --git a/crates/emmylua_ls/src/handlers/completion/providers/function_provider.rs b/crates/emmylua_ls/src/handlers/completion/providers/function_provider.rs index 41a7f1611..8b718182e 100644 --- a/crates/emmylua_ls/src/handlers/completion/providers/function_provider.rs +++ b/crates/emmylua_ls/src/handlers/completion/providers/function_provider.rs @@ -120,8 +120,8 @@ fn infer_assign_target(builder: &CompletionBuilder) -> Option> { let var = vars.first()?; let var_type = builder .semantic_model - .type_of_expr(var.to_expr().get_syntax_id()); - Some(vec![var_type]) + .assignment_target_type(&var.to_expr()); + super::equality_provider::contains_function(builder, &var_type).then(|| vec![var_type]) } fn infer_param_list( diff --git a/crates/emmylua_ls/src/handlers/definition/mod.rs b/crates/emmylua_ls/src/handlers/definition/mod.rs index 4ce0eb9ab..e93414bf5 100644 --- a/crates/emmylua_ls/src/handlers/definition/mod.rs +++ b/crates/emmylua_ls/src/handlers/definition/mod.rs @@ -3,7 +3,7 @@ mod goto_label; mod goto_module_file; mod goto_string; -use emmylua_code_analysis::{EmmyLuaAnalysis, FileId}; +use emmylua_code_analysis::{EmmyLuaAnalysis, FileId, SemanticId}; use emmylua_parser::{LuaAstNode, LuaAstToken, LuaStringToken, LuaTokenKind}; use lsp_types::{ ClientCapabilities, GotoDefinitionParams, GotoDefinitionResponse, OneOf, Position, @@ -60,6 +60,17 @@ pub fn definition( } }; + if let Some((read, write, assignment)) = super::common::directional_index_at(&model, &token) + && let Some(SemanticId::Member(key)) = if assignment { write.id } else { read.id } + && let Some(document) = analysis.db.document(key.file_id) + && let Some(uri) = document.get_uri() + && let Some(range) = document.to_lsp_range(key.key_range) + { + return Some(GotoDefinitionResponse::Scalar(lsp_types::Location::new( + uri, range, + ))); + } + // 1. Label definition (goto / label). if let Some(response) = goto_label::goto_label_definition(&model, &analysis.db, &token) { return Some(response); diff --git a/crates/emmylua_ls/src/handlers/hover/desc.rs b/crates/emmylua_ls/src/handlers/hover/desc.rs index 3a298d7ff..151802c9a 100644 --- a/crates/emmylua_ls/src/handlers/hover/desc.rs +++ b/crates/emmylua_ls/src/handlers/hover/desc.rs @@ -169,6 +169,26 @@ fn normalize_description(text: &str) -> String { .to_string() } +/// Return the description immediately preceding a field in its own comment block. +pub(super) fn preceding_field_description( + model: &SemanticModel<'_>, + member: &SemanticId, +) -> Option { + let SemanticId::Member(key) = member else { + return None; + }; + let tree = model.syntax_tree_of(key.file_id)?; + let root = tree.get_red_root(); + let token = root.token_at_offset(key.key_range.start()).right_biased()?; + let field = token.parent_ancestors().find_map(LuaDocTagField::cast)?; + let description = field + .get_descriptions() + .into_iter() + .find(|description| description.get_range().end() <= field.get_range().start())?; + let text = normalize_description(&description.get_description_text()); + (!text.is_empty()).then_some(text) +} + /// Clean up `@field` descriptions: strip the common `#` prefix. fn clean_field_description(text: &str) -> String { normalize_description(text) diff --git a/crates/emmylua_ls/src/handlers/hover/directional.rs b/crates/emmylua_ls/src/handlers/hover/directional.rs new file mode 100644 index 000000000..eba87db49 --- /dev/null +++ b/crates/emmylua_ls/src/handlers/hover/directional.rs @@ -0,0 +1,49 @@ +use super::{ + desc::{member_description, preceding_field_description}, + render::humanize, +}; +use emmylua_code_analysis::{SemanticDatabase, SemanticModel}; +use emmylua_parser::{LuaAstNode, LuaDocTagField, LuaSyntaxToken}; +use lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind}; + +pub(super) fn hover( + model: &SemanticModel, + db: &SemanticDatabase, + token: &LuaSyntaxToken, +) -> Option { + let pair = super::super::common::directional_index_at(model, token) + .map(|(read, write, _)| (read, write)) + .or_else(|| { + let field = token.parent()?.ancestors().find_map(LuaDocTagField::cast)?; + if !field + .get_field_key_range()? + .contains_range(token.text_range()) + { + return None; + } + model.directional_declaration(&model.find_decl(token.clone().into())?) + })?; + let (read, write) = pair; + let mut value = format!( + "```lua\nRead: {}\nWrite: {}\n```", + humanize(model, &read.typ), + humanize(model, &write.typ) + ); + if let Some(id) = read.id.as_ref() { + let desc = member_description(model, id); + let text = desc.text.or_else(|| preceding_field_description(model, id)); + if let Some(text) = text { + value.push_str("\n\n"); + value.push_str(&text); + } + } + Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + range: db + .document(model.file_id())? + .to_lsp_range(token.text_range()), + }) +} diff --git a/crates/emmylua_ls/src/handlers/hover/mod.rs b/crates/emmylua_ls/src/handlers/hover/mod.rs index 330b400b2..01c12b5e2 100644 --- a/crates/emmylua_ls/src/handlers/hover/mod.rs +++ b/crates/emmylua_ls/src/handlers/hover/mod.rs @@ -1,5 +1,6 @@ mod build_hover; pub(crate) mod desc; +mod directional; mod keyword_hover; pub(crate) mod render; @@ -70,6 +71,9 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> } TokenAtOffset::None => return None, }; + if let Some(hover) = directional::hover(&model, &analysis.db, &token) { + return Some(hover); + } match token { keywords if is_keyword(keywords.clone()) => Some(Hover { contents: HoverContents::Markup(MarkupContent { diff --git a/crates/emmylua_ls/src/handlers/test/asymmetric_fields_test.rs b/crates/emmylua_ls/src/handlers/test/asymmetric_fields_test.rs new file mode 100644 index 000000000..1bf122b8d --- /dev/null +++ b/crates/emmylua_ls/src/handlers/test/asymmetric_fields_test.rs @@ -0,0 +1,376 @@ +#![cfg(test)] +use crate::handlers::{ + completion::completion, definition::definition, hover::hover, + test_lib::ProviderVirtualWorkspace, +}; +use googletest::prelude::*; +use lsp_types::{ + CompletionResponse, CompletionTriggerKind, GotoDefinitionResponse, HoverContents, Position, +}; +use tokio_util::sync::CancellationToken; + +const DEFS: &str = "---@meta\n---@class Property\n--- A transformed property.\n---@field (read) value number\n---@field (write) value 'coal'|'wood'\n---@type Property\nproperty = {}\n"; + +#[gtest] +fn asymmetric_fields_hover_and_navigation() { + let mut ws = ProviderVirtualWorkspace::new(); + ws.def_file("defs.lua", DEFS); + let file = ws.def_file("use.lua", "local x = property.value\nproperty.value = 'coal'\nlocal y = property.value\nproperty['value'] = 'wood'"); + for (line, col, target_line) in [(0, 20, 3), (1, 11, 4), (2, 20, 3), (3, 12, 4)] { + let result = hover(&ws.analysis, file, Position::new(line, col)).expect("hover"); + let HoverContents::Markup(text) = result.contents else { + panic!("markup"); + }; + assert!(text.value.contains("Read: number"), "{}", text.value); + assert!(text.value.contains("Write:"), "{}", text.value); + assert!(text.value.contains("coal"), "{}", text.value); + assert!( + text.value.contains("A transformed property."), + "{}", + text.value + ); + let definition = + definition(&ws.analysis, file, Position::new(line, col)).expect("definition"); + let locations = match definition { + GotoDefinitionResponse::Scalar(location) => vec![location], + GotoDefinitionResponse::Array(locations) => locations, + _ => panic!("locations"), + }; + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].range.start.line, target_line); + } +} + +#[gtest] +fn asymmetric_fields_completion() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + ws.def_file("defs.lua", DEFS); + for (source, expected, count) in [ + ("property.", "value", 1), + ("property.value = ", "coal", 1), + ] { + let (source, position) = ProviderVirtualWorkspace::handle_file_content(source)?; + let file = ws.def(&source); + let response = completion( + &ws.analysis, + file, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .expect("completion"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + if expected == "value" { + let item = items.iter().find(|item| item.label == "value").unwrap(); + let resolved = crate::handlers::completion::completion_resolve( + &ws.analysis, + item.clone(), + crate::context::ClientId::Intellij, + ); + let detail = serde_json::to_string(&resolved).unwrap(); + assert!( + detail.contains("Read: number") + && detail.contains("Write:") + && detail.contains("coal"), + "{detail}" + ); + } + assert_eq!( + items + .iter() + .filter(|item| item.label.contains(expected)) + .count(), + count, + "{items:?}" + ); + } + Ok(()) +} + +#[gtest] +fn asymmetric_fields_review_hover_paragraphs() { + for (key, expression) in [ + ("value", "property.value"), + ("['odd-key']", "property['odd-key']"), + ("[1]", "property[1]"), + ] { + for newline in ["\n", "\r\n"] { + let mut ws = ProviderVirtualWorkspace::new(); + ws.def_file("defs.lua", &format!( + "--- 中文 🦀\n---@class Property\n--- First paragraph.\n---\n--- Second paragraph.\n---@field (read) {key} number\n---@field (write) {key} string\n---@type Property\nproperty = {{}}", + ).replace('\n', newline)); + let (source, position) = ProviderVirtualWorkspace::handle_file_content(&format!( + "local value = {expression}" + )) + .expect("position"); + let file = ws.def_file("use.lua", &source); + let result = hover(&ws.analysis, file, position).expect("hover"); + let HoverContents::Markup(text) = result.contents else { + panic!("markup"); + }; + assert!( + text.value.contains("First paragraph.\n\nSecond paragraph."), + "{key}: {}", + text.value + ); + assert!(!text.value.contains("中文"), "{}", text.value); + } + } +} + +#[gtest] +fn asymmetric_fields_review_hover_comment_without_space() { + let mut ws = ProviderVirtualWorkspace::new(); + ws.def_file( + "defs.lua", + "---@class Property\n---Property documentation.\n---@field (read) value number\n---@field (write) value string\n---@type Property\nproperty = {}", + ); + let file = ws.def_file("use.lua", "local value = property.value"); + let result = hover(&ws.analysis, file, Position::new(0, 25)).expect("hover"); + let HoverContents::Markup(text) = result.contents else { + panic!("markup"); + }; + assert!( + text.value.contains("Property documentation."), + "{}", + text.value + ); +} + +#[gtest] +fn asymmetric_fields_review_hover_does_not_borrow_another_field_description() { + let mut ws = ProviderVirtualWorkspace::new(); + ws.def_file( + "defs.lua", + "---@class Property\n---@field other number # Other field documentation.\n---@field (read) value number\n---@field (write) value string\n---@type Property\nproperty = {}", + ); + let file = ws.def_file("use.lua", "local value = property.value"); + let result = hover(&ws.analysis, file, Position::new(0, 25)).expect("hover"); + let HoverContents::Markup(text) = result.contents else { + panic!("markup"); + }; + assert!(text.value.contains("Read: number"), "{}", text.value); + assert!( + !text.value.contains("Other field documentation."), + "{}", + text.value + ); +} + +#[gtest] +fn asymmetric_fields_review_hover_unicode_inline_and_prefix() { + let mut ws = ProviderVirtualWorkspace::new(); + let defs = ws.def_file( + "defs.lua", + "--- 中文 🦀\n---@class Property\n--- Preceding documentation.\n---@field (read) value number # Inline documentation.\n---@field (write) value string\n---@type Property\nproperty = {}", + ); + let file = ws.def_file("use.lua", "local value = property.value"); + let result = hover(&ws.analysis, file, Position::new(0, 25)).expect("hover"); + assert_eq!(result.range.unwrap().start, Position::new(0, 23)); + let HoverContents::Markup(text) = result.contents else { + panic!("markup"); + }; + assert!( + text.value.contains("Inline documentation."), + "{}", + text.value + ); + assert!( + !text.value.contains("Preceding documentation."), + "{}", + text.value + ); + assert!(!text.value.contains("中文"), "{}", text.value); + let definition = + definition(&ws.analysis, file, Position::new(0, 18)).expect("prefix definition"); + let locations = match definition { + GotoDefinitionResponse::Scalar(location) => vec![location], + GotoDefinitionResponse::Array(locations) => locations, + _ => panic!("locations"), + }; + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].range.start.line, 6); + assert_eq!(locations[0].uri, ws.analysis.get_uri(defs).unwrap()); +} + +#[gtest] +fn asymmetric_fields_review_assignment_completion_controls() -> Result<()> { + for (declaration, expected) in [ + ("---@type 'coal'|'wood'\nlocal target", "\"coal\""), + ( + "---@alias Callback fun(value: string)\n---@type Callback\nlocal target", + "function(value) end", + ), + ( + "---@type (fun(value: string))|'coal'\nlocal target", + "function(value) end", + ), + ] { + let mut ws = ProviderVirtualWorkspace::new(); + let (source, position) = ProviderVirtualWorkspace::handle_file_content(&format!( + "{declaration}\ntarget = " + ))?; + let file = ws.def(&source); + let response = completion( + &ws.analysis, + file, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .expect("completion"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert_eq!( + items.iter().filter(|item| item.label == expected).count(), + 1, + "{items:?}" + ); + } + Ok(()) +} + +#[gtest] +fn asymmetric_fields_review_hover_keeps_field_types_separate() { + for (field, directional) in [ + ("---@field (read) value Value", true), + ("---@field (write) value Value", true), + ("---@field (read) value Value", false), + ("---@field (write) value Value", false), + ] { + let mut ws = ProviderVirtualWorkspace::new(); + let (source, position) = ProviderVirtualWorkspace::handle_file_content(&format!( + "---@class Value\n---@field name string\n---@class Property\n{field}" + )) + .expect("position"); + let file = ws.def_file("defs.lua", &source); + let result = hover(&ws.analysis, file, position).expect("hover"); + let HoverContents::Markup(text) = result.contents else { + panic!("markup") + }; + assert_eq!( + text.value.contains("Read:"), + directional, + "{field}: {}", + text.value + ); + assert!(text.value.contains("Value"), "{}", text.value); + } +} + +#[gtest] +fn asymmetric_fields_review_assignment_completion_providers() { + for (typ, expected) in [ + ("'coal'|'wood'", vec!["coal", "wood"]), + ("Choice", vec!["coal", "wood"]), + ("42|43", vec!["42", "43"]), + ("Callback", vec!["function(value) end"]), + ("Callback|'coal'", vec!["function(value) end", "coal"]), + ("number", vec![]), + ("unknown", vec![]), + ("Callable", vec![]), + ("CallableAlias", vec![]), + ] { + for read in [ + "---@field (read) value boolean\n---@field (write)", + "---@field", + ] { + let mut ws = ProviderVirtualWorkspace::new(); + let (source, position) = ProviderVirtualWorkspace::handle_file_content(&format!( + "---@alias Choice 'coal'|'wood'\n---@alias Callback fun(value: string)\n---@class Callable\n---@field __call fun(self: Callable, value: string)\n---@alias CallableAlias Callable\n---@class Property\n{read} value {typ}\n---@type Property\nlocal property = {{}}\nlocal available = 1\nproperty.value = " + )).expect("position"); + let file = ws.def_file("use.lua", &source); + let response = completion( + &ws.analysis, + file, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .expect("completion"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + // Ordinary callable targets use the earlier implementation-snippet path. + // Directional targets reach the shared type dispatch and retain enum alternatives. + let expected = if read == "---@field" && typ == "Callback|'coal'" { + vec!["function(value) end"] + } else { + expected.clone() + }; + for label in &expected { + assert_eq!( + items + .iter() + .filter(|item| item.label.trim_matches('"') == *label) + .count(), + 1, + "{read} {typ}: {label}: {items:?}" + ); + } + if expected.is_empty() { + assert!( + items.iter().any(|item| item.label == "available"), + "{typ}: {items:?}" + ); + assert!( + !items.iter().any(|item| item.label == "function(value) end"), + "{typ}: {items:?}" + ); + } + } + } +} + +#[gtest] +fn asymmetric_fields_review_assignment_completion_uses_writer_with_callable_getter() { + for (writer, expected) in [ + ("fun(setter: string)", vec!["function(setter) end"]), + ("'coal'|'wood'", vec!["coal", "wood"]), + ( + "(fun(setter: string))|'coal'", + vec!["function(setter) end", "coal"], + ), + ("unknown", vec!["available"]), + ] { + let mut ws = ProviderVirtualWorkspace::new(); + let (source, position) = ProviderVirtualWorkspace::handle_file_content(&format!( + "---@class Property\n---@field (read) value fun(getter: boolean)\n---@field (write) value {writer}\n---@type Property\nlocal property = {{}}\nlocal available = 1\nproperty.value = " + )).expect("position"); + let file = ws.def_file("use.lua", &source); + let response = completion( + &ws.analysis, + file, + position, + CompletionTriggerKind::INVOKED, + CancellationToken::new(), + ) + .expect("completion"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + for label in expected { + assert_eq!( + items + .iter() + .filter(|item| item.label.trim_matches('"') == label) + .count(), + 1, + "{writer}: {label}: {items:?}" + ); + } + assert!( + !items + .iter() + .any(|item| item.label == "function(getter) end"), + "{writer}: {items:?}" + ); + } +} diff --git a/crates/emmylua_ls/src/handlers/test/mod.rs b/crates/emmylua_ls/src/handlers/test/mod.rs index ed95cd3f8..e62b0e326 100644 --- a/crates/emmylua_ls/src/handlers/test/mod.rs +++ b/crates/emmylua_ls/src/handlers/test/mod.rs @@ -10,3 +10,5 @@ mod references_test; mod rename_test; mod semantic_token_test; mod signature_helper_test; + +mod asymmetric_fields_test; diff --git a/docs/emmylua_doc/annotations_CN/field.md b/docs/emmylua_doc/annotations_CN/field.md index 286ba879d..97d3b56f2 100644 --- a/docs/emmylua_doc/annotations_CN/field.md +++ b/docs/emmylua_doc/annotations_CN/field.md @@ -111,3 +111,53 @@ local scores = { 3. **索引签名** 4. **复杂类型支持** 5. **嵌套结构** + + +## 不同的读取与写入类型 + +如果属性的 setter 接受的类型与 getter 返回的类型不同,可以声明一对互补字段: + +```lua +---@class Burner +---@field (read) fuel? Fuel +---@field (write) fuel? string|Fuel + +---@type Burner +local burner +burner.fuel = "coal" -- 按 string|Fuel|nil 检查赋值 +local fuel = burner.fuel -- 即使刚写入字符串,读取类型仍为 Fuel|nil +``` + +不带方向修饰符的字段继续对读写使用同一个类型。每个方向可以分别设置可选标记, +也支持泛型。支持具名字段、字符串字面量键和整数字面量键;不支持带方向的索引签名, +也不建模 `__newindex`。在同一个字段上同时指定两种方向,或重复同一种修饰符, +会产生注解错误。重复声明同一方向的字段仍会产生重复字段错误(已有的函数重载规则仍然适用)。 +重复的 `(write)` 函数声明允许赋入匹配任意一个 setter 签名的回调。 +回调的上下文推断保留现有重载顺序;普通、不带方向修饰符的函数字段不受影响。 + +对于 `A|B` 这样的联合类型接收者,读取结果合并各分支的 getter 类型,赋值则接受 +任一分支的 setter 类型,与普通联合类型字段的赋值行为一致。联合类型中的普通字段 +提供其共同的读写类型。接收者收窄到某一分支后,可用的 setter 类型也随之收窄。 + +给成对声明的属性赋值会使先前的控制流类型收窄失效,恢复其声明的 getter 类型。 +后续条件判断可以再次收窄该类型。嵌套赋值中的接收者始终按读取处理: +`object.property.child = x` 对 `property` 使用 getter 类型,对 `child` 使用 setter 类型。 + +这些注解描述类型,而不是访问权限。如果只声明一个方向,则读写都使用该类型, +不会引入只读或只写诊断。悬停提示和补全文档会同时显示一对字段的读写类型。 + +子类声明会整体覆盖继承的属性。因此,只声明一个方向的覆盖也会同时决定读写类型: + +```lua +---@class Parent +---@field (read) value number +---@field (write) value string + +---@class Child: Parent +---@field (read) value boolean + +---@type Child +local child +child.value = true -- Child 接受 boolean;Parent 接受 string +local value = child.value -- boolean +``` diff --git a/docs/emmylua_doc/annotations_EN/field.md b/docs/emmylua_doc/annotations_EN/field.md index 475a6742e..c36ede7d7 100644 --- a/docs/emmylua_doc/annotations_EN/field.md +++ b/docs/emmylua_doc/annotations_EN/field.md @@ -111,3 +111,60 @@ local scores = { 3. **Index signatures** 4. **Complex type support** 5. **Nested structures** + +## Different read and write types + +A property whose setter accepts a different type from its getter can declare a +complementary pair of fields: + +```lua +---@class Burner +---@field (read) fuel? Fuel +---@field (write) fuel? string|Fuel + +---@type Burner +local burner +burner.fuel = "coal" -- checked against string|Fuel|nil +local fuel = burner.fuel -- Fuel|nil, even after the string assignment +``` + +Unqualified fields continue to use one type for both operations. Each direction +may use its own optional marker and generic types. Named fields, string-literal +keys, and integer-literal keys are supported; directional index signatures and +`__newindex` modeling are not supported. Both modifiers on a single field are an +annotation error. Repeating a declaration for the same direction remains a +duplicate-field error (existing function overload rules still apply). Repeated +`(write)` function declarations accept an assigned callback matching any of the +declared setter signatures. Contextual callback inference retains the existing +overload order; ordinary, unqualified function fields are unchanged. + +For a union receiver such as `A|B`, reads combine the getter types and assignments +accept any setter type contributed by either variant, matching ordinary union-field +assignment behavior. Ordinary fields in the union contribute their shared read/write +type. Narrowing the receiver to one variant also narrows the available setters. + +Assignments to a paired property invalidate its previous flow narrowing and +restore its declared getter type. Later checks can narrow that type normally. +The receiver of a nested assignment is always read: `object.property.child = x` +uses the getter type of `property` and the setter type of `child`. + +These annotations describe types, not access permissions. If only one direction +is supplied it is used for both operations; no read-only or write-only diagnostic +is introduced. Hover and completion documentation show both types for a pair. + +A child declaration shadows the inherited property as a unit. A single-direction +override therefore supplies both the read type and the write type: + +```lua +---@class Parent +---@field (read) value number +---@field (write) value string + +---@class Child: Parent +---@field (read) value boolean + +---@type Child +local child +child.value = true -- Child accepts boolean; Parent accepts string +local value = child.value -- boolean +```