Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
12 changes: 0 additions & 12 deletions crates/emmylua_code_analysis/src/check/checker/param_type_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,18 +789,6 @@ fn check_arg_pairs(
let param_ty = unify::substitute(param_ty, bindings);
let param_ty = type_eval::expand_alias_generic(semantic_model, &param_ty);
let param_ty = type_eval::eval_conditionals(semantic_model, &param_ty);
// Missing union member: when only A in `A|C` has `handle`, accessing `target.handle`
// should report ParamTypeMismatch even if the type face happens to be string (missing members count as nil).
if let LuaExpr::IndexExpr(index_expr) = arg
&& semantic_model.member_missing_in_union(index_expr)
{
out.push(Mismatch {
code: DiagnosticCode::ParamTypeMismatch,
range: arg.get_range(),
message: mismatch_message(semantic_model, &LuaType::Nil, &param_ty, name),
});
continue;
}
// Table literals targeting object / intersection: check field by field and then fully take over.
if let LuaExpr::TableExpr(table) = arg {
out.extend(table_field_mismatches(semantic_model, &param_ty, arg));
Expand Down
135 changes: 135 additions & 0 deletions crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3562,3 +3562,138 @@ mod test {
);
}
}
#[test]
fn test_nullable_union_projection_argument_contracts() {
use crate::{DiagnosticCode, VirtualWorkspace};
for (target, present, expected_ok) in [
("integer?", "integer", true),
("any", "integer", true),
("integer", "integer", false),
("integer?", "string", false),
] {
for receiver in [
"ProjectionPresent|ProjectionAbsent",
"ProjectionAlias",
"ProjectionGeneric<ProjectionPresent>",
] {
for argument in ["event.player_index", "(event.player_index)", "copy"] {
let source = format!(
r#"
---@class ProjectionPresent
---@field player_index {present}
local Present = {{}}
---@class ProjectionAbsent
local Absent = {{}}
---@param index {target}
local function accept(index) end
---@alias ProjectionAlias ProjectionPresent|ProjectionAbsent
---@alias ProjectionGeneric<T> T|ProjectionAbsent
---@param event {receiver}
local function handler(event)
local copy = event.player_index
accept({argument})
end
"#
);
let mut ws = VirtualWorkspace::new();
assert_eq!(
ws.has_no_diagnostic(DiagnosticCode::ParamTypeMismatch, &source),
expected_ok,
"{source}"
);
}
}
}
}

#[test]
fn test_nullable_union_projection_preserves_guarded_member() {
use crate::{DiagnosticCode, VirtualWorkspace};
let source = r#"
---@class ProjectionGuardPresent
---@field player_index integer
local Present = {}
---@class ProjectionGuardAbsent
local Absent = {}
---@param index integer
local function accept(index) end
---@param event ProjectionGuardPresent|ProjectionGuardAbsent
local function handler(event)
if event.player_index then accept(event.player_index) end
end
"#;
let mut ws = VirtualWorkspace::new();
assert!(ws.has_no_diagnostic(DiagnosticCode::ParamTypeMismatch, source));
assert!(ws.has_no_diagnostic(DiagnosticCode::UnnecessaryIf, source));
}

#[test]
fn test_nullable_union_projection_retains_integer_and_nil_in_type() {
use crate::{LuaType, VirtualWorkspace};
use emmylua_parser::{LuaAstNode, LuaIndexExpr};
let mut ws = VirtualWorkspace::new();
let file = ws.def(
r#"
---@class ProjectionTypePresent
---@field player_index integer
local Present = {}
---@class ProjectionTypeAbsent
local Absent = {}
---@param event ProjectionTypePresent|ProjectionTypeAbsent
local function handler(event) return event.player_index end
"#,
);
let model = ws.analysis.semantic_model(file);
let index = model
.chunk()
.unwrap()
.descendants::<LuaIndexExpr>()
.next()
.unwrap();
assert_eq!(
model.type_of_expr_at(index.get_syntax_id(), index.get_position()),
LuaType::from_vec(vec![LuaType::Integer, LuaType::Nil])
);
}

#[test]
fn test_nullable_union_projection_named_aliases() {
use crate::{DiagnosticCode, LuaType, VirtualWorkspace};
use emmylua_parser::{LuaAstNode, LuaIndexExpr};
for annotation in [
"ProjectionEvent",
"ProjectionGeneric<ProjectionAliasPresent>",
] {
let source = format!(
r#"
---@class ProjectionAliasPresent
---@field player_index integer
local Present = {{}}
---@class ProjectionAliasAbsent
local Absent = {{}}
---@alias ProjectionEvent ProjectionAliasPresent|ProjectionAliasAbsent
---@alias ProjectionGeneric<T> T|ProjectionAliasAbsent
---@param value integer
local function accept(value) end
---@param event {annotation}
local function handler(event) accept(event.player_index) end
"#
);
let mut ws = VirtualWorkspace::new();
let accepts = ws.has_no_diagnostic(DiagnosticCode::ParamTypeMismatch, &source);
let file = ws.def(&source);
let model = ws.analysis.semantic_model(file);
let index = model
.chunk()
.unwrap()
.descendants::<LuaIndexExpr>()
.next()
.unwrap();
assert_eq!(
model.type_of_expr_at(index.get_syntax_id(), index.get_position()),
LuaType::from_vec(vec![LuaType::Integer, LuaType::Nil]),
"{annotation}"
);
assert!(!accepts, "{annotation}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ fn unnarrowed_union_still_reports_missing_member() {
"#,
);
assert_that!(diagnostics.len(), eq(1));
assert_that!(diagnostics[0].message, contains_substring("nil"));
assert_that!(diagnostics[0].message, contains_substring("`uint32?`"));
}

#[gtest]
Expand Down Expand Up @@ -172,7 +172,7 @@ fn cast_can_widen_non_union_owner_to_union_with_missing_member() {
"#,
);
assert_that!(diagnostics.len(), eq(1));
assert_that!(diagnostics[0].message, contains_substring("nil"));
assert_that!(diagnostics[0].message, contains_substring("`uint32?`"));
}

#[gtest]
Expand Down
3 changes: 3 additions & 0 deletions crates/emmylua_code_analysis/src/semantic_model/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,9 @@ impl SemanticLocalCache {
.set(self.incomplete_revision.get().wrapping_add(1));
}

/// No incomplete dependency has been observed since this snapshot.
/// Successful cache insertions do not advance the revision; recursion and
/// budget exhaustion do, so their partial results cannot prove absence.
pub(crate) fn is_complete(&self, revision: u64) -> bool {
self.revision() == revision
}
Expand Down
19 changes: 18 additions & 1 deletion crates/emmylua_code_analysis/src/semantic_model/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,24 @@ 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(resolved) = model.resolve_member(index_expr)
// A union projection cannot use one component's declaration as its entire value.
let union_prefix = index_expr.get_prefix_expr().and_then(|prefix| {
let ty = model.type_of_expr_at(prefix.get_syntax_id(), offset);
matches!(
super::type_eval::expand_alias_head(model, &ty),
Some(LuaType::Union(_))
)
.then_some(ty)
});
if let Some(prefix) = &union_prefix
&& let Some(key) = member_key_from_index_expr(model, index_expr)
&& let Some(ty) = model.member_type(prefix, &key)
{
let ty = guarded_index_type(model, index_expr, offset, &ty).unwrap_or(ty);
return apply_casts(ty, All);
}
if union_prefix.is_none()
&& 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()?;
let prefix_ty = model.type_of_expr_at(prefix.get_syntax_id(), offset);
Expand Down
Loading