Skip to content

Commit 8d0a469

Browse files
committed
fix(type): canonicalize identical callable unions
Deduplicate semantically identical callable members when building unions. Fixes #1020
1 parent 381d677 commit 8d0a469

3 files changed

Lines changed: 107 additions & 7 deletions

File tree

crates/emmylua_code_analysis/src/compilation/test/pcall_test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,40 @@ mod test {
199199
assert_eq!(ws.expr_ty("success"), ws.ty("unknown"));
200200
assert_eq!(ws.expr_ty("failure"), ws.ty("string"));
201201
}
202+
203+
#[test]
204+
fn test_issue_1020_pcall_preserves_pairs_value_function_return() {
205+
let mut ws = VirtualWorkspace::new_with_init_std_lib();
206+
207+
ws.def(
208+
r#"
209+
local t = {
210+
{ id = 1, func = function() return a > 0 end },
211+
{ id = 2, func = function() return a > 0 end },
212+
{ id = 3, func = function() return a > 0 end },
213+
}
214+
215+
for _, v in pairs(t) do
216+
local f = v.func
217+
captured_f = f
218+
local success, result = pcall(f)
219+
outside = result
220+
if success then
221+
success_result = result
222+
else
223+
failure_result = result
224+
end
225+
end
226+
"#,
227+
);
228+
229+
let captured_f = ws.expr_ty("captured_f");
230+
let outside = ws.expr_ty("outside");
231+
let success_result = ws.expr_ty("success_result");
232+
let failure_result = ws.expr_ty("failure_result");
233+
assert_eq!(ws.humanize_type(captured_f), "fun() -> boolean");
234+
assert_eq!(ws.humanize_type(outside), "(boolean|string)");
235+
assert_eq!(ws.humanize_type(success_result), "boolean");
236+
assert_eq!(ws.humanize_type(failure_result), "string");
237+
}
202238
}

crates/emmylua_code_analysis/src/db_index/type/type_ops/test.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,30 @@ mod tests {
180180
// }
181181
}
182182

183+
#[test]
184+
fn test_union_collapses_equivalent_callable_variants() {
185+
let mut ws = VirtualWorkspace::new();
186+
187+
ws.def(
188+
r#"
189+
---@return boolean
190+
function foo()
191+
return true
192+
end
193+
"#,
194+
);
195+
196+
let doc = ws.ty("fun(): boolean");
197+
let sig = ws.expr_ty("foo");
198+
let doc_first = TypeOps::Union.apply(ws.get_db_mut(), &doc, &sig);
199+
assert!(!doc_first.is_union());
200+
assert_eq!(ws.humanize_type(doc_first), "fun() -> boolean");
201+
202+
let sig_first = TypeOps::Union.apply(ws.get_db_mut(), &sig, &doc);
203+
assert!(!sig_first.is_union());
204+
assert_eq!(ws.humanize_type(sig_first), "fun() -> boolean");
205+
}
206+
183207
#[test]
184208
fn test_remove_type() {
185209
let mut ws = VirtualWorkspace::new();

crates/emmylua_code_analysis/src/db_index/type/type_ops/union_type.rs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use std::ops::Deref;
1+
use std::{ops::Deref, sync::Arc};
22

3-
use crate::{DbIndex, LuaType, LuaUnionType, get_real_type};
3+
use crate::{DbIndex, LuaFunctionType, LuaType, LuaUnionType, get_real_type};
44

55
pub fn union_type(db: &DbIndex, source: LuaType, target: LuaType) -> LuaType {
66
let match_source = get_real_type(db, &source)
77
.cloned()
88
.unwrap_or_else(|| source.clone());
9-
union_type_impl(&match_source, source, target)
9+
canonicalize_callable_union(db, union_type_impl(&match_source, source, target))
1010
}
1111

1212
pub(crate) fn union_type_shallow(source: LuaType, target: LuaType) -> LuaType {
@@ -106,15 +106,55 @@ fn union_type_impl(match_source: &LuaType, source: LuaType, target: LuaType) ->
106106
}
107107
// two union
108108
(LuaType::Union(left), LuaType::Union(right)) => {
109-
let mut left = left.into_vec();
110-
let right = right.into_vec();
111-
left.extend(right);
109+
if left == right {
110+
return source.clone();
111+
}
112112

113-
LuaType::from_vec(left)
113+
let mut merged = left.into_vec();
114+
merged.extend(right.into_vec());
115+
116+
LuaType::from_vec(merged)
114117
}
115118

116119
// same type
117120
(left, right) if *left == *right => source.clone(),
118121
_ => LuaType::from_vec(vec![source, target]),
119122
}
120123
}
124+
125+
// `Signature` and `DocFunction` carry the same callable shape through different variants.
126+
// Collapse them after the normal union merge so the core merge logic stays simple.
127+
fn canonicalize_callable_union(db: &DbIndex, ty: LuaType) -> LuaType {
128+
let LuaType::Union(union) = ty else {
129+
return ty;
130+
};
131+
132+
let mut types = Vec::new();
133+
for member in union.into_vec() {
134+
let member_callable = as_callable_type(db, &member);
135+
if types.iter().any(|existing| {
136+
existing == &member
137+
|| as_callable_type(db, existing)
138+
.as_ref()
139+
.zip(member_callable.as_ref())
140+
.is_some_and(|(existing, member)| existing == member)
141+
}) {
142+
continue;
143+
}
144+
types.push(member);
145+
}
146+
147+
LuaType::from_vec(types)
148+
}
149+
150+
fn as_callable_type(db: &DbIndex, ty: &LuaType) -> Option<Arc<LuaFunctionType>> {
151+
match ty {
152+
LuaType::DocFunction(func) => Some(func.clone()),
153+
LuaType::Signature(signature_id) => db
154+
.get_signature_index()
155+
.get(signature_id)
156+
.filter(|signature| signature.is_resolve_return())
157+
.map(|signature| signature.to_doc_func_type()),
158+
_ => None,
159+
}
160+
}

0 commit comments

Comments
 (0)