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
31 changes: 31 additions & 0 deletions crates/emmylua_code_analysis/src/check/test/inject_field_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,34 @@ mod dictionary_constructor {
}
}
}

#[cfg(test)]
mod test_issue_1318 {
use crate::{DiagnosticCode, VirtualWorkspace};

const LINTER: &str = r#"
---@class Linter
---@field cmd string the command to run
---@field stdin boolean pass content via stdin
"#;

/// #1318: a literal typed `Partial<T>` (or a class inheriting it) may set any field of `T`.
#[test]
fn test_partial_literal_accepts_fields_of_t() {
let mut ws = VirtualWorkspace::new_with_init_std_lib();
ws.def_file("linter.lua", LINTER);
for code in [
"---@type Partial<Linter>\nlocal b = { cmd = 'x' }",
"---@class SubPartial: Partial<Linter>\n---@field extra? integer\n\n---@type SubPartial\nlocal a = { cmd = 'x' }",
] {
assert!(
ws.has_no_diagnostic(DiagnosticCode::InjectField, code),
"{code}"
);
assert!(
ws.has_no_diagnostic(DiagnosticCode::MissingFields, code),
"{code}"
);
}
}
}
10 changes: 10 additions & 0 deletions crates/emmylua_code_analysis/src/semantic_model/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ impl<'db> SemanticModel<'db> {
let LuaType::Generic(generic) = &prefix_ty else {
return None;
};
// A field of an alias instance (`Partial<T>`) has the alias's own (mapped) type,
// `string?`, not the source member's `string`.
if self
.type_def_of(generic.get_base_type_id_ref())
.is_some_and(|def| def.kind == TypeDefKind::Alias)
&& let Some(name) = index_expr.get_index_name_token()
{
let key = LuaMemberKey::Name(name.text().into());
return self.member_info(&prefix_ty, &key).map(|info| info.typ);
}
let member_type = self.type_of_member(member_id)?;
let names = self
.type_def_of(generic.get_base_type_id_ref())
Expand Down
52 changes: 49 additions & 3 deletions crates/emmylua_code_analysis/src/semantic_model/member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use hashbrown::HashMap;
use crate::semantic_db::def::{DeclKind, MemberRef, SemanticId, TypeDef, TypeDefKind};
use crate::semantic_model::infer::unify::{self, TplBindings};
use crate::semantic_model::type_eval::{map_type, substitute_named_refs, with_expanded_alias};
use crate::{FileId, GenericTplId, InFiled, LuaMemberKey, LuaType};
use crate::{
FileId, GenericTplId, InFiled, LuaAliasCallKind, LuaGenericType, LuaMemberKey, LuaType,
};
use crate::{Member, semantic_db::facts::FileFacts};
use smol_str::SmolStr;

Expand Down Expand Up @@ -228,10 +230,17 @@ fn direct_member_info(
LuaType::Generic(generic) => {
let def = model.type_def_of(&generic.get_base_type_id())?;
if def.kind == TypeDefKind::Alias {
return with_expanded_alias(model, prefix_type, |expanded| {
let mut info = with_expanded_alias(model, prefix_type, |expanded| {
direct_member_info(model, expanded, key)
})
.flatten();
.flatten()?;
if info.id.is_none()
&& let Some(source) = mapped_alias_source_member(model, &def, generic, key)
{
info.id = source.id;
info.file_id = source.file_id;
}
return Some(info);
}
let bindings: TplBindings = generic
.get_params()
Expand Down Expand Up @@ -296,6 +305,33 @@ fn direct_member_info(
}
}

/// For a mapped alias over `keyof X` (`Partial<T>` is `{ [P in keyof T]?: T[P] }`), the member of
/// `X` that a produced field comes from. The field keeps its own (mapped) type, but takes the
/// source declaration, so hover and completion show `X`'s field docs.
fn mapped_alias_source_member(
model: &SemanticModel,
def: &TypeDef,
generic: &LuaGenericType,
key: &LuaMemberKey,
) -> Option<MemberInfo> {
let LuaType::Mapped(mapped) = model.alias_target(def)? else {
return None;
};
let LuaType::Call(call) = mapped.param.1.constraint.as_ref()? else {
return None;
};
if call.get_call_kind() != LuaAliasCallKind::KeyOf {
return None;
}
let names: Vec<SmolStr> = def.generic_params.iter().map(|p| p.name.clone()).collect();
let source = model.substitute_generic_params_named(
call.get_operands().first()?,
generic.get_params(),
&names,
);
member_info(model, &source, key).filter(|info| info.id.is_some())
}

pub(crate) fn member_info_impl(
model: &SemanticModel,
prefix_type: &LuaType,
Expand Down Expand Up @@ -641,9 +677,19 @@ fn collect_members(
if let Some(def) = model.type_def_of(&generic.get_base_type_id())
&& def.kind == TypeDefKind::Alias
{
let start = out.len();
with_expanded_alias(model, prefix_type, |expanded| {
collect_members(model, expanded, None, None, visited, out, filter);
});
for info in &mut out[start..] {
if info.id.is_none()
&& let Some(source) =
mapped_alias_source_member(model, &def, generic, &info.key)
{
info.id = source.id;
info.file_id = source.file_id;
}
}
return;
}
let def = model.type_def_of(&generic.get_base_type_id());
Expand Down
25 changes: 25 additions & 0 deletions crates/emmylua_ls/src/handlers/test/completion_resolve_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,29 @@ mod tests {
));
Ok(())
}

/// #1318: completing a `Partial<T>` field shows `T`'s field docs. Resolve renders the
/// declaration site, so the detail is `T`'s declared field type.
#[gtest]
fn test_partial_field_keeps_source_docs() -> Result<()> {
let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib();
ws.def(
r#"
---@class Linter
---@field cmd string the command to run
"#,
);
check!(ws.check_completion_resolve(
r#"
---@type Partial<Linter>
local p = {}
p.<??>
"#,
VirtualCompletionResolveItem {
detail: "(field) cmd: string".to_string(),
documentation: Some("\nthe command to run".to_string()),
},
));
Ok(())
}
}
21 changes: 21 additions & 0 deletions crates/emmylua_ls/src/handlers/test/hover_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,4 +955,25 @@ mod tests {
};
assert!(content.value.contains("a: integer"), "{}", content.value);
}

/// #1318: a field read through `Partial<T>` hovers with `T`'s declaration and docs.
#[gtest]
fn test_hover_partial_field_keeps_source_docs() -> Result<()> {
let mut ws = ProviderVirtualWorkspace::new_with_init_std_lib();
check!(ws.check_hover(
r#"
---@class Linter
---@field cmd string the command to run
---@field stdin boolean pass content via stdin

---@type Partial<Linter>
local p = {}
print(p.<??>cmd)
"#,
VirtualHoverResult {
value: "```lua\n(field) cmd: string?\n```\n\n---\n\nthe command to run".to_string(),
},
));
Ok(())
}
}
Loading