Skip to content
Merged
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
43 changes: 34 additions & 9 deletions crates/emmylua_code_analysis/src/check/checker/param_type_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,20 @@ fn check_arg_pairs(
if skip_arg(&call_arg_ty, &param_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, &param_ty)
&& !literal_compatible(semantic_model, &call_arg_ty, &param_ty)
{
out.push(Mismatch {
range: arg.get_range(),
message: mismatch_message(semantic_model, &call_arg_ty, &param_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!(
Expand Down Expand Up @@ -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))
}

Expand All @@ -1238,16 +1253,21 @@ fn literal_values(
semantic_model: &SemanticModel<'_>,
ty: &LuaType,
visited: &mut Vec<LuaTypeDeclId>,
boolean_only: bool,
) -> Option<Vec<String>> {
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);
}
Expand All @@ -1261,17 +1281,19 @@ 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 {
let facts = semantic_model.file_facts_of(def.file_id)?;
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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<LuaType> {
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::<LuaClosureExpr>().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<LuaType> {
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))
}
Original file line number Diff line number Diff line change
@@ -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
"#
)
},
);
}
1 change: 1 addition & 0 deletions crates/emmylua_code_analysis/src/check/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading