diff --git a/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs b/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs index 18d177004..6dac4ae33 100644 --- a/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs +++ b/crates/emmylua_code_analysis/src/check/checker/param_type_check.rs @@ -758,6 +758,20 @@ fn check_arg_pairs( if skip_arg(&call_arg_ty, ¶m_ty) { continue; } + // Check the whole boolean domain before selecting a single union component. + if value_kind(semantic_model, &call_arg_ty) == ValueKind::Boolean + && !param_ty.contains_tpl_node() + { + if !is_compatible(semantic_model, &call_arg_ty, ¶m_ty) + && !literal_compatible(semantic_model, &call_arg_ty, ¶m_ty) + { + out.push(Mismatch { + range: arg.get_range(), + message: mismatch_message(semantic_model, &call_arg_ty, ¶m_ty, name), + }); + } + continue; + } // `---@param x string|true`: non-function arguments pass when they match any union component. // Unions containing generic components (`` `T`|T ``) cannot be swallowed early by is_compatible, otherwise T won't bind. let has_generic_union_tpl = matches!( @@ -1229,7 +1243,8 @@ fn literal_compatible( let Some(arg_text) = arg_text else { return false; }; - literal_values(semantic_model, param_ty, &mut Vec::new()) + let boolean_only = arg_ty.is_boolean(); + literal_values(semantic_model, param_ty, &mut Vec::new(), boolean_only) .is_some_and(|values| values.iter().any(|value| value == &arg_text)) } @@ -1238,16 +1253,21 @@ fn literal_values( semantic_model: &SemanticModel<'_>, ty: &LuaType, visited: &mut Vec, + boolean_only: bool, ) -> Option> { match ty { - LuaType::StringConst(s) | LuaType::DocStringConst(s) => Some(vec![s.as_ref().to_string()]), - LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) => Some(vec![i.to_string()]), + LuaType::StringConst(s) | LuaType::DocStringConst(s) if !boolean_only => { + Some(vec![s.as_ref().to_string()]) + } + LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) if !boolean_only => { + Some(vec![i.to_string()]) + } LuaType::BooleanConst(b) | LuaType::DocBooleanConst(b) => Some(vec![b.to_string()]), LuaType::Union(union) => { let mut values = Vec::new(); for component in union.into_vec() { if let Some(mut component_values) = - literal_values(semantic_model, &component, visited) + literal_values(semantic_model, &component, visited, boolean_only) { values.append(&mut component_values); } @@ -1261,9 +1281,9 @@ fn literal_values( visited.push(id.clone()); let def = semantic_model.type_def_of(id)?; if def.kind == TypeDefKind::Alias { - return semantic_model - .alias_target(&def) - .and_then(|target| literal_values(semantic_model, &target, visited)); + return semantic_model.alias_target(&def).and_then(|target| { + literal_values(semantic_model, &target, visited, boolean_only) + }); } // enum: runtime table member names and values. if def.kind == TypeDefKind::Enum { @@ -1271,7 +1291,9 @@ fn literal_values( let decl = facts.decl_named(def.name.as_str())?; let mut values = Vec::new(); for member_ref in semantic_model.members_of_owner(&decl.id).iter() { - values.push(member_ref.name.to_string()); + if !boolean_only { + values.push(member_ref.name.to_string()); + } if let Some(member_facts) = semantic_model.file_facts_of(member_ref.file_id) && let Some(member) = member_facts.member_by_id(&member_ref.id) && let Some(value_syntax) = member.value_syntax @@ -1280,7 +1302,10 @@ fn literal_values( .and_then(|tree| value_syntax.to_node_from_root(&tree.get_red_root())) { let raw = node.text().to_string(); - values.push(raw.trim_matches(['"', '\'']).to_string()); + // Boolean arguments match values, not member names or quoted text. + if !boolean_only || matches!(raw.as_str(), "true" | "false") { + values.push(raw.trim_matches(['"', '\'']).to_string()); + } } } return (!values.is_empty()).then_some(values); diff --git a/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs b/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs index ff23d2a7e..713699228 100644 --- a/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs +++ b/crates/emmylua_code_analysis/src/check/checker/return_type_mismatch.rs @@ -80,12 +80,7 @@ fn check_return_stat( docs: &SignatureDoc, ) { for expr in ret.get_expr_list() { - let mut raw = - semantic_model.type_of_expr_at(expr.get_syntax_id(), expr.get_range().start()); - // `return dd() ---@as integer`: the inline type assertion overrides the expression type. - if let Some(as_type) = inline_as_type(semantic_model, ret, &expr) { - raw = as_type; - } + let raw = semantic_model.type_of_expr_at(expr.get_syntax_id(), expr.get_range().start()); // Uninstantiated function generic argument `Ref("T")`: check against the constraint when present, otherwise skip. if is_unresolved_generic_ref(semantic_model, &raw, docs) { if let Some(constraint) = generic_constraint_type(semantic_model, &raw) @@ -277,76 +272,3 @@ fn generic_constraint_type(semantic_model: &SemanticModel<'_>, ty: &LuaType) -> } None } - -/// Inline type for `return expr ---@as integer` / `--[[@as integer]]`. -fn inline_as_type( - semantic_model: &SemanticModel<'_>, - ret: &LuaReturnStat, - expr: &emmylua_parser::LuaExpr, -) -> Option { - let expr_siblings = expr.syntax().siblings_with_tokens(rowan::Direction::Next); - let ret_siblings = ret.syntax().siblings_with_tokens(rowan::Direction::Next); - for sibling in expr_siblings.chain(ret_siblings) { - if let Some(ty) = as_type_from_token(semantic_model, sibling.as_token()) { - return Some(ty); - } - } - // The parser may not attach the end-of-line comment as a sibling of ReturnStat; fall back to searching by position inside the closure. - if let Some(closure) = ret.ancestors::().next() { - let mut prev_text = String::new(); - let mut pending_as = false; - for item in closure.syntax().descendants_with_tokens() { - let Some(token) = item.into_token() else { - continue; - }; - let text = token.text(); - if pending_as { - let name: String = text - .trim() - .trim_start_matches(['[', '-']) - .chars() - .take_while(|c| { - c.is_alphanumeric() || matches!(c, '.' | '_' | '?' | '|' | '<' | '>') - }) - .collect(); - let name = name.trim_end_matches([']', '-']); - if !name.is_empty() { - return Some(semantic_model.type_from_name(name)); - } - if !text.trim().is_empty() { - pending_as = false; - } - } - if prev_text.ends_with("---@") && text == "as" { - pending_as = true; - } - prev_text = text.to_string(); - if token.text_range().start() >= expr.get_range().end() { - if let Some(ty) = as_type_from_token(semantic_model, Some(&token)) { - return Some(ty); - } - } - } - } - None -} - -fn as_type_from_token( - semantic_model: &SemanticModel<'_>, - token: Option<&emmylua_parser::LuaSyntaxToken>, -) -> Option { - let token = token?; - let text = token.text(); - let mark = text.find("@as")?; - let rest = &text[mark + 3..]; - let name: String = rest - .trim_start_matches(|c: char| c.is_whitespace() || c == '[' || c == '-') - .chars() - .take_while(|c| c.is_alphanumeric() || matches!(c, '.' | '_' | '?' | '|' | '<' | '>')) - .collect(); - let name = name.trim_end_matches([']', '-']); - if name.is_empty() { - return None; - } - Some(semantic_model.type_from_name(name)) -} diff --git a/crates/emmylua_code_analysis/src/check/test/boolean_compatibility_test.rs b/crates/emmylua_code_analysis/src/check/test/boolean_compatibility_test.rs new file mode 100644 index 000000000..a45f0557c --- /dev/null +++ b/crates/emmylua_code_analysis/src/check/test/boolean_compatibility_test.rs @@ -0,0 +1,198 @@ +use super::check_source; +use crate::DiagnosticCode; + +// Source annotation, expression, target annotation, expected compatibility. +const CASES: &[(&str, &str, &str, bool)] = &[ + ("true | false", "value", "boolean", true), + ("boolean", "value", "true | false", true), + ("Choice", "value", "Bool", true), + ("Bool", "value", "Choice", true), + ("Switch", "value", "Choice", true), + ("boolean", "value", "Truth | Falsity | string", true), + ("boolean", "value", "Choice | string", true), + ("boolean", "value", "boolean | nil", true), + ("boolean", "true", "boolean", true), + ("boolean", "false", "boolean", true), + ("boolean", "true", "Truth", true), + ("boolean", "false", "Falsity", true), + ("true", "value", "boolean", true), + ("false", "value", "boolean", true), + ("true", "value", "Truth | string", true), + ("false", "value", "Falsity | nil", true), + ("boolean", "value", "true", false), + ("boolean", "value", "false", false), + ("boolean", "value", "Truth | string", false), + ("boolean", "value", "Falsity | nil", false), + ("Bool", "value", "Truth", false), + ("Switch", "value", "Truth", false), + ("Bool", "value", "Falsity | nil", false), + ("Truth", "value", "Falsity", false), + ("Falsity", "value", "Truth | string", false), + ("boolean", "false", "true", false), + ("boolean", "true", "false", false), + ("boolean", "true", "'true'", false), + ("boolean", "false", "'false'", false), + ("boolean", "false", "Truth | string", false), + ("boolean", "true", "Falsity | nil", false), + ("false", "value", "true", false), + ("true", "value", "false", false), + ("false", "value", "Truth | string", false), + ("true", "value", "Falsity | nil", false), + ("boolean", "value", "string", false), + ("boolean", "value", "number", false), + ("boolean", "value", "nil", false), + ("boolean", "'text'", "string", true), + ("boolean", "1", "number", true), + ("boolean", "nil", "nil", true), + ("boolean", "'text'", "boolean", false), + ("boolean", "1", "boolean", false), + ("boolean", "nil", "boolean", false), +]; + +fn check_cases(code: DiagnosticCode, make_source: impl Fn(&str, &str, &str) -> String) { + let mut failures = Vec::new(); + for &(source_type, expression, target_type, accepted) in CASES { + let source = format!( + r#" + ---@diagnostic disable: unused + ---@alias Truth true + ---@alias Falsity false + ---@alias Choice Truth | Falsity + ---@alias Bool boolean + ---@alias Switch Bool + {} + "#, + make_source(source_type, expression, target_type) + ); + let diagnostics = check_source(&source); + let mut expected = if accepted { vec![] } else { vec![code] }; + // The return-count checker treats an annotated nil return as zero values. + if code == DiagnosticCode::ReturnTypeMismatch && target_type == "nil" && !accepted { + expected.push(DiagnosticCode::RedundantReturnValue); + } + let actual: Vec<_> = diagnostics.iter().map(|d| d.code).collect(); + if actual != expected { + failures.push(format!( + "{source_type}: {expression} -> {target_type}, accepted={accepted}: {diagnostics:?}" + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn boolean_return_compatibility() { + check_cases( + DiagnosticCode::ReturnTypeMismatch, + |source, value, target| { + format!( + r#" + ---@param value {source} + ---@return {target} + local function test(value) + return {value} + end + return test + "# + ) + }, + ); +} + +#[test] +fn boolean_argument_compatibility() { + check_cases( + DiagnosticCode::ParamTypeMismatch, + |source, value, target| { + format!( + r#" + ---@param value {target} + local function accept(value) end + ---@param value {source} + local function test(value) + accept({value}) + end + return test + "# + ) + }, + ); +} + +#[test] +fn boolean_enum_arguments_match_member_values() { + for value in [true, false] { + for target in ["Flag", "FlagAlias", "FlagAlias | number"] { + for (argument, accepted) in [ + ("Flag.Value".to_string(), true), + (value.to_string(), true), + ((!value).to_string(), false), + ] { + let diagnostics = check_source(&format!( + r#" + ---@diagnostic disable: unused + ---@enum Flag + local Flag = {{ + Value = {value}, + Label = "{opposite}", + ["{opposite}"] = 0, + }} + ---@alias FlagAlias Flag + ---@param value {target} + local function accept(value) end + accept({argument}) + "#, + opposite = !value, + )); + let actual: Vec<_> = diagnostics.iter().map(|d| d.code).collect(); + let expected = if accepted { + vec![] + } else { + vec![DiagnosticCode::ParamTypeMismatch] + }; + assert_eq!(actual, expected, "{argument} -> {target}, member={value}"); + } + } + } +} + +#[test] +fn boolean_local_initialization_compatibility() { + check_cases( + DiagnosticCode::AssignTypeMismatch, + |source, value, target| { + format!( + r#" + ---@param value {source} + local function test(value) + ---@type {target} + local result = {value} + return result + end + return test + "# + ) + }, + ); +} + +#[test] +fn boolean_reassignment_compatibility() { + check_cases( + DiagnosticCode::AssignTypeMismatch, + |source, value, target| { + format!( + r#" + ---@param value {source} + local function test(value) + ---@type {target} + local result + result = {value} + return result + end + return test + "# + ) + }, + ); +} diff --git a/crates/emmylua_code_analysis/src/check/test/mod.rs b/crates/emmylua_code_analysis/src/check/test/mod.rs index f4d8834fb..94f378922 100644 --- a/crates/emmylua_code_analysis/src/check/test/mod.rs +++ b/crates/emmylua_code_analysis/src/check/test/mod.rs @@ -5,6 +5,7 @@ mod analyze_error_test; mod assign_type_mismatch_test; mod attribute_check_test; mod await_in_sync_test; +mod boolean_compatibility_test; mod call_non_callable_test; mod cast_type_mismatch_test; mod check_export_test; diff --git a/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs b/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs index e9d445837..6c5b646c7 100644 --- a/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs +++ b/crates/emmylua_code_analysis/src/check/test/return_type_mismatch_test.rs @@ -2,6 +2,63 @@ mod tests { use crate::{DiagnosticCode, VirtualWorkspace}; + #[test] + fn test_issue_1226() { + let diagnostics = crate::check::test::check_source( + r#" + ---@return boolean + local function test1() + return true ---@as true | false + end + + ---@return true | false + local function test2() + return true ---@as boolean + end + "#, + ); + assert!( + diagnostics + .iter() + .all(|d| d.code != DiagnosticCode::ReturnTypeMismatch), + "{diagnostics:?}" + ); + } + + #[test] + fn test_inline_as_return_compatibility() { + for (target, expression, cast, accepted) in [ + ("boolean", "true", "true|false", true), + ("true", "true", "boolean", false), + ("false", "false", "boolean", false), + ("true", "true", "false", false), + ("false", "false", "true", false), + ("string", "1", "string", true), + ("number", "'text'", "number", true), + ("nil", "true", "nil", true), + ("string", "'text'", "number", false), + ("number", "1", "string", false), + ("boolean", "true", "nil", false), + ] { + let mut ws = VirtualWorkspace::new(); + assert_eq!( + ws.has_no_diagnostic( + DiagnosticCode::ReturnTypeMismatch, + &format!( + r#" + ---@return {target} + local function test() + return {expression} ---@as {cast} + end + "#, + ), + ), + accepted, + "{expression} as {cast} -> {target}" + ); + } + } + #[test] fn test_issue_242() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs index c7e97ac6e..481659d5e 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/mod.rs @@ -155,6 +155,27 @@ fn check_general_type_compact( return Ok(()); } + // Resolve boolean aliases before assignment mode selects a target union member. + if matches!(source, LuaType::Ref(_) | LuaType::Def(_)) { + let mut resolved = source.clone(); + let mut alias_guard = check_guard; + while let LuaType::Ref(id) | LuaType::Def(id) = &resolved + && let Some(target) = context.alias_target_of(id) + && let Ok(next_guard) = alias_guard.next_level() + { + resolved = target; + alias_guard = next_guard; + } + if resolved.is_boolean() { + return check_general_type_compact(context, &resolved, compact_type, alias_guard); + } + } + + // Check the whole boolean domain before assignment mode selects a union member. + if matches!(source, LuaType::Boolean) { + return simple_type::check_simple_type_compact(context, source, compact_type, check_guard); + } + // Assignment mode: integer is assignable to number; number is not assignable to integer. if context.assign_mode { match (source, compact_type) { diff --git a/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs b/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs index 84b9f47f4..83a3b70ab 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/type_check/simple_type.rs @@ -86,17 +86,41 @@ pub fn check_simple_type_compact( return Ok(()); } } - LuaType::Boolean | LuaType::BooleanConst(_) => { - if compact_type.is_boolean() { - return Ok(()); + LuaType::Boolean => { + // Both values must fit, possibly through different target union members. + let next_guard = check_guard.next_level()?; + for value in [true, false] { + super::check_general_type_compact( + context, + &LuaType::BooleanConst(value), + compact_type, + next_guard, + )?; } - // Union targets such as `string|true`: a boolean constant matching any component is compatible. - if let LuaType::Union(union) = compact_type - && union.iter().any(|ty| ty.is_boolean()) - { + return Ok(()); + } + LuaType::BooleanConst(value) | LuaType::DocBooleanConst(value) => match compact_type { + LuaType::Boolean => return Ok(()), + LuaType::BooleanConst(other) | LuaType::DocBooleanConst(other) if value == other => { return Ok(()); } - } + LuaType::Union(union) => { + for component in union.iter() { + match super::check_general_type_compact( + context, + source, + component, + check_guard.next_level()?, + ) { + Ok(()) => return Ok(()), + Err(err) if err.is_type_not_match() => {} + Err(err) => return Err(err), + } + } + return Err(context.mismatch(source, compact_type)); + } + _ => {} + }, LuaType::String => match compact_type { LuaType::String | LuaType::StringConst(_) @@ -241,22 +265,6 @@ pub fn check_simple_type_compact( } _ => {} }, - LuaType::DocBooleanConst(b) => match compact_type { - LuaType::BooleanConst(t) => { - if b == t { - return Ok(()); - } - return Err(context.mismatch(source, compact_type)); - } - LuaType::Boolean => return Err(context.mismatch(source, compact_type)), - LuaType::DocBooleanConst(t) => { - if b == t { - return Ok(()); - } - return Err(context.mismatch(source, compact_type)); - } - _ => {} - }, LuaType::StrTplRef(_) => { if compact_type.is_string() { return Ok(());