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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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));
Expand Down
10 changes: 10 additions & 0 deletions crates/emmylua_code_analysis/src/semantic_db/def/member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down Expand Up @@ -54,6 +63,7 @@ impl Member {
owner: SemanticId,
) -> Self {
Member {
access: MemberAccess::ReadWrite,
id: SemanticId::member(file_id, key_range),
key,
owner,
Expand Down
2 changes: 1 addition & 1 deletion crates/emmylua_code_analysis/src/semantic_db/def/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
56 changes: 55 additions & 1 deletion crates/emmylua_code_analysis/src/semantic_db/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Item = &'a Member> {
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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions crates/emmylua_code_analysis/src/semantic_model/access.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ResolvedMember> {
let syntax = index_expr.get_syntax_id();
let file_id = self.view.file_id();
Expand Down
130 changes: 130 additions & 0 deletions crates/emmylua_code_analysis/src/semantic_model/directional.rs
Original file line number Diff line number Diff line change
@@ -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<MemberInfo>)> {
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<MemberInfo>, 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::<Vec<_>>();
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::<Option<Vec<_>>>()?;
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)
}
}
Loading
Loading