From c76c2012e028a3303e5a4e1b9809396ad394ad8b Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:38:34 +0100 Subject: [PATCH 1/5] fix: re-defined tables could incorrectly bind to original rather than scoped definition --- .../src/compilation/analyzer/gmod/mod.rs | 371 +++++++++++++++--- .../compilation/analyzer/unresolve/resolve.rs | 31 +- .../test/gmod_scripted_class_test.rs | 335 ++++++++++++++++ .../src/semantic/cache/mod.rs | 9 + .../instantiate_func_generic.rs | 19 +- .../src/semantic/infer/infer_index/mod.rs | 12 +- .../src/semantic/infer/infer_name.rs | 169 ++++++-- .../src/semantic/infer/mod.rs | 2 +- .../semantic/infer/narrow/get_type_at_flow.rs | 36 +- .../src/semantic/infer/narrow/mod.rs | 43 +- .../src/semantic/infer/narrow/var_ref_id.rs | 172 ++++++-- .../src/semantic/infer/test.rs | 54 +++ crates/glua_code_analysis/src/semantic/mod.rs | 2 +- 13 files changed, 1118 insertions(+), 137 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs index 8ebb1475..71bba941 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -9,14 +9,15 @@ use glua_parser::{ LuaDocTagFileparam, LuaDocTagRealm, LuaElseClauseStat, LuaElseIfClauseStat, LuaExpr, LuaForRangeStat, LuaForStat, LuaFuncStat, LuaIfStat, LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalName, LuaLocalStat, LuaRepeatStat, LuaStat, LuaSyntaxNode, - LuaVarExpr, LuaWhileStat, NumberResult, PathTrait, + LuaTableExpr, LuaVarExpr, LuaWhileStat, NumberResult, PathTrait, }; use crate::{ EmmyrcGmodRealm, FileId, GmodClassCallLiteral, GmodScriptedClassCallKind, - GmodScriptedClassCallMetadata, GmodScriptedClassFileMetadata, LuaDecl, LuaDeclExtra, LuaDeclId, - LuaDeclLocation, LuaDeclTypeKind, LuaFunctionType, LuaMember, LuaMemberFeature, LuaMemberId, - LuaMemberKey, LuaType, LuaTypeCache, LuaTypeDecl, LuaTypeDeclId, LuaTypeFlag, + GmodScriptedClassCallMetadata, GmodScriptedClassFileMetadata, InFiled, LuaDecl, LuaDeclExtra, + LuaDeclId, LuaDeclLocation, LuaDeclTypeKind, LuaFunctionType, LuaMember, LuaMemberFeature, + LuaMemberId, LuaMemberKey, LuaType, LuaTypeCache, LuaTypeDecl, LuaTypeDeclId, LuaTypeFlag, + LuaTypeOwner, compilation::analyzer::{AnalysisPipeline, AnalyzeContext, common::add_member}, db_index::{ AsyncState, DbIndex, GmodCallbackSiteMetadata, GmodConVarKind, GmodConVarSiteMetadata, @@ -2189,7 +2190,6 @@ fn synthesize_scripted_class_members( /// Synthesize vgui.Register / derma.DefineControl class types. fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { - let mut original_decl_table_types: HashMap> = HashMap::new(); // Track (file_id, table_var_name, panel_name) for AccessorFunc synthesis let mut vgui_table_vars: Vec<(FileId, String, String)> = Vec::new(); @@ -2212,7 +2212,7 @@ fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { vgui_table_vars.push((file_id, table_var.clone(), panel_name.clone())); } } - synthesize_vgui_register(db, file_id, call, &mut original_decl_table_types); + synthesize_vgui_register(db, file_id, call); } for call in &metadata.derma_define_control_calls { @@ -2224,7 +2224,7 @@ fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { vgui_table_vars.push((file_id, table_var.clone(), panel_name.clone())); } } - synthesize_derma_define_control(db, file_id, call, &mut original_decl_table_types); + synthesize_derma_define_control(db, file_id, call); } } @@ -3206,7 +3206,6 @@ fn synthesize_vgui_register( db: &mut DbIndex, file_id: FileId, call: &GmodScriptedClassCallMetadata, - original_decl_table_types: &mut HashMap>, ) { // vgui.Register("PanelName", TABLE, "BasePanel") // args[0] = panel name (string) @@ -3234,7 +3233,6 @@ fn synthesize_vgui_register( table_var_name.as_deref(), base_panel.as_deref(), call, - original_decl_table_types, ); } @@ -3242,7 +3240,6 @@ fn synthesize_derma_define_control( db: &mut DbIndex, file_id: FileId, call: &GmodScriptedClassCallMetadata, - original_decl_table_types: &mut HashMap>, ) { // derma.DefineControl("ControlName", "description", TABLE, "BasePanel") // args[0] = control name (string) @@ -3271,7 +3268,6 @@ fn synthesize_derma_define_control( table_var_name.as_deref(), base_panel.as_deref(), call, - original_decl_table_types, ); // Register the control name as a global variable with the panel type @@ -3319,25 +3315,88 @@ fn register_global_panel( ); } -fn find_table_type_for_register( +// REMOVED: find_table_type_for_register — it fell back to the shared decl-level +// type cache, which is exactly the position-insensitive slot that caused +// reassigned-PANEL collapse. Resolution now goes through the concrete table +// expression (find_registered_table_expr) instead. + +/// Locate the concrete table-constructor (`{}`) expression that backs the +/// variable being registered, by scanning to the variable's latest write +/// before the register call and taking the matching RHS expression. +/// +/// VGUI files commonly reuse a single `local PANEL` decl with repeated plain +/// reassignments (`PANEL = {}`), one per registered class. The class identity +/// belongs to each individual table value, not to the shared decl slot — so we +/// resolve the exact `{}` literal at the latest write position and return its +/// table range plus syntax id. Callers bind the synthesized class to that +/// `SyntaxId`, which the public `infer_expr` override consults, giving correct +/// per-region resolution for hover/diagnostics/CodeLens alike. +/// +/// Returns `None` (caller skips SyntaxId binding) when the RHS is not a table +/// literal (e.g. `PANEL = make()`, `PANEL = SomeOther`), keeping behavior +/// conservative for non-literal table values. +fn find_registered_table_expr( db: &DbIndex, file_id: FileId, decl_id: LuaDeclId, register_position: TextSize, -) -> Option { - let latest_write_decl_id = +) -> Option { + // The latest write position is the start of the assigned name range for the + // most recent plain reassignment (`PANEL = {}`) before the register call. + // + // The original `local PANEL = {}` declaration is NOT recorded as a write + // reference cell (only later assignments are), so for the FIRST region + // there is no prior write — fall back to the decl's own position, where the + // enclosing `LuaLocalStat` yields the initializer table RHS. + let write_position = find_latest_decl_write_before_position(db, file_id, decl_id, register_position) - .map(|position| LuaDeclId::new(file_id, position)); + .unwrap_or(decl_id.position); - if let Some(write_decl_id) = latest_write_decl_id - && let Some(type_cache) = db.get_type_index().get_type_cache(&write_decl_id.into()) - { - return Some(type_cache.as_type().clone()); + let tree = db.get_vfs().get_syntax_tree(&file_id)?; + let chunk = tree.get_chunk_node(); + + // Find the name node at the write position, then walk up to its enclosing + // statement and select the RHS expression at the matching variable index. + let name_token = chunk + .syntax() + .token_at_offset(write_position) + .right_biased()?; + + for ancestor in name_token.parent_ancestors() { + if let Some(local_stat) = LuaLocalStat::cast(ancestor.clone()) { + let names: Vec = local_stat.get_local_name_list().collect(); + let values: Vec = local_stat.get_value_exprs().collect(); + let var_index = names.iter().position(|name| { + name.get_name_token() + .is_some_and(|tok| tok.syntax().text_range().start() == write_position) + })?; + return value_expr_as_table(values.get(var_index)?); + } + + if let Some(assign_stat) = LuaAssignStat::cast(ancestor.clone()) { + let (vars, exprs) = assign_stat.get_var_and_expr_list(); + let var_index = vars.iter().position(|var| { + var.syntax().text_range().start() == write_position + })?; + return value_expr_as_table(exprs.get(var_index)?); + } } - db.get_type_index() - .get_type_cache(&decl_id.into()) - .map(|type_cache| type_cache.as_type().clone()) + None +} + +/// Unwrap parenthesized expressions and require a table constructor. +fn value_expr_as_table(expr: &LuaExpr) -> Option { + let mut current = expr.clone(); + loop { + match current { + LuaExpr::TableExpr(table_expr) => return Some(table_expr), + LuaExpr::ParenExpr(paren_expr) => { + current = paren_expr.get_expr()?; + } + _ => return None, + } + } } fn find_latest_decl_write_before_position( @@ -3365,7 +3424,6 @@ fn synthesize_panel_class( table_var_name: Option<&str>, base_panel: Option<&str>, call: &GmodScriptedClassCallMetadata, - original_decl_table_types: &mut HashMap>, ) { let class_decl_id = LuaTypeDeclId::global(panel_name); @@ -3399,7 +3457,17 @@ fn synthesize_panel_class( synthesize_panel_baseclass_member(db, file_id, &class_decl_id, base_name, call); } - // Bind the table variable to the panel class + // Bind the table variable to the panel class. + // + // VGUI files reuse a single `local PANEL` decl with repeated plain + // reassignments (`PANEL = {}`), one per registered class. The class + // identity belongs to each concrete table value (the `{}` literal), NOT to + // the shared decl slot. Binding the decl slot collapses every region onto a + // single class (last-write-wins), which is the root cause of the + // reassigned-PANEL mis-binding. Instead we bind the class to the exact + // table-constructor expression via `LuaTypeOwner::SyntaxId`, which the + // public `infer_expr` override consults — yielding correct per-region + // resolution for hover, diagnostics, completion and CodeLens uniformly. if let Some(var_name) = table_var_name { let Some(decl_tree) = db.get_decl_index().get_decl_tree(&file_id) else { return; @@ -3414,42 +3482,74 @@ fn synthesize_panel_class( return; }; - let previous_decl_type = - find_table_type_for_register(db, file_id, decl_id, register_position); - let decl_table_type = original_decl_table_types - .entry(decl_id) - .or_insert_with(|| { - db.get_type_index() - .get_type_cache(&decl_id.into()) - .map(|type_cache| type_cache.as_type().clone()) - }) - .clone(); + let class_type = LuaType::Def(class_decl_id.clone()); let latest_write_position = find_latest_decl_write_before_position(db, file_id, decl_id, register_position); - db.get_type_index_mut().force_bind_type( - decl_id.into(), - LuaTypeCache::InferType(LuaType::Def(class_decl_id.clone())), - ); + // Resolve the concrete `{}` table literal backing this registration. + let registered_table = find_registered_table_expr(db, file_id, decl_id, register_position); - // Transfer table members to the class - let mut table_ranges = Vec::new(); - if let Some(LuaType::TableConst(table_range)) = previous_decl_type { - table_ranges.push(table_range); - } - if let Some(LuaType::TableConst(table_range)) = decl_table_type - && !table_ranges.iter().any(|existing| existing == &table_range) - { - table_ranges.push(table_range); + if let Some(table_expr) = ®istered_table { + // Bind the class to this exact table-constructor expression. + // Preserve any user `@as`/cast (DocType) binding already present. + let table_syntax_owner = + LuaTypeOwner::SyntaxId(InFiled::new(file_id, table_expr.get_syntax_id())); + let preserve_doc = db + .get_type_index() + .get_type_cache(&table_syntax_owner) + .is_some_and(|cache| cache.is_doc()); + if !preserve_doc { + db.get_type_index_mut().force_bind_type( + table_syntax_owner, + LuaTypeCache::InferType(class_type.clone()), + ); + } + } else if !decl_has_reassignment(db, file_id, decl_id) { + // Compatibility fallback for single-panel files (one `local PANEL`, + // no plain reassignments) where the RHS is not a recoverable table + // literal. Binding the decl slot is safe here because the local is + // never reused, so there is no region to collapse. Reassigned + // locals are deliberately left untouched to avoid collapse. + db.get_type_index_mut().force_bind_type( + decl_id.into(), + LuaTypeCache::InferType(class_type.clone()), + ); } - if !table_ranges.is_empty() { + // Transfer the members defined in this registration's table region to + // the class, then rewrite that exact table-const range so persistent + // type caches (cross-file accesses, exports) resolve to the class. + if let Some(table_expr) = ®istered_table { + let table_range = InFiled::new(file_id, table_expr.get_range()); let class_member_owner = LuaMemberOwner::Type(class_decl_id.clone()); - let mut table_member_ids = HashSet::new(); - for table_range in &table_ranges { - let table_member_owner = LuaMemberOwner::Element(table_range.clone()); - if let Some(members) = db.get_member_index().get_members(&table_member_owner) { + // Members defined via `function PANEL:Method()` / `PANEL.Field =` + // are collected during the `lua` analysis pass — which runs BEFORE + // this gmod post-analysis SyntaxId binding exists. At that point the + // flow inference of the reused `PANEL` local resolves to its + // *initializer* table literal, so EVERY region's members accumulate + // under that single `Element` owner, differentiated only by source + // position. The per-region table literal's own `Element` owner is + // therefore usually empty. + // + // To bridge synthesis (which knows the per-region boundary) with + // collection (which keyed everything on the initializer table), we + // gather all candidate member-source `Element` owners and slice them + // by source position `[latest_write_position, register_position)`. + // This stays correct if a future flow-aware collector starts keying + // members under the per-region literal instead. + let member_source_ranges = collect_panel_member_source_ranges( + db, + file_id, + decl_id, + &table_range, + ); + + let mut table_member_ids = HashSet::new(); + for (source_idx, source_range) in member_source_ranges.iter().enumerate() { + let is_initializer_fallback = source_idx > 0; + let source_owner = LuaMemberOwner::Element(source_range.clone()); + if let Some(members) = db.get_member_index().get_members(&source_owner) { for member in members { let member_position = member.get_id().get_position(); if member_position < register_position @@ -3457,6 +3557,21 @@ fn synthesize_panel_class( .map(|write_position| member_position >= write_position) .unwrap_or(true) { + // For the initializer table fallback, verify the member + // was defined using the registered variable name. Members + // defined through aliases (e.g. `local OLD = PANEL; + // function OLD:Method()`) must not be transferred to the + // new panel class. + if is_initializer_fallback + && !member_defined_via_variable( + db, + file_id, + member_position, + var_name, + ) + { + continue; + } table_member_ids.insert(member.get_id()); } } @@ -3467,14 +3582,156 @@ fn synthesize_panel_class( add_member(db, class_member_owner.clone(), member_id); } - // Replace stale table-const types with the synthesized class type so cross-file accesses resolve correctly. - let class_type = LuaType::Def(class_decl_id.clone()); - for table_range in &table_ranges { - db.get_type_index_mut() - .replace_table_const_type(table_range, &class_type); + // Backfill persistent type caches that still hold this exact + // table-const identity (scoped to the current range only — never + // carried forward across registrations). + db.get_type_index_mut() + .replace_table_const_type(&table_range, &class_type); + } + } +} + +/// Collect the candidate `Element` owner ranges that may hold this +/// registration region's members, deduped and most-specific first. +/// +/// `function PANEL:Method()` member collection happens in the `lua` pass before +/// the gmod-post SyntaxId binding exists, so members of reused locals end up +/// under the local's *initializer* table `Element` owner rather than each +/// region's own table literal. We therefore consider: +/// +/// 1. the exact per-region table literal range (precise / future-proof), and +/// 2. the original local declaration's initializer `TableConst` range (where +/// the lua pass actually accumulated the members today). +/// +/// Callers slice the resulting members by source position to attribute them to +/// the correct region. +fn collect_panel_member_source_ranges( + db: &DbIndex, + file_id: FileId, + decl_id: LuaDeclId, + region_table_range: &InFiled, +) -> Vec> { + let mut ranges: Vec> = Vec::with_capacity(2); + ranges.push(region_table_range.clone()); + + // The original local decl's initializer table literal (`local PANEL = {}`) + // is the `Element` owner the lua pass keyed all reused-local members under. + // + // We derive this range from the AST rather than the decl type cache: the + // cache is rewritten in-place by `replace_table_const_type` as each region + // is synthesized, so by the second registration the original decl's cache + // no longer reports its initializer `TableConst`. + if let Some(initializer_range) = find_decl_initializer_table_range(db, file_id, decl_id) + && !ranges.iter().any(|existing| existing == &initializer_range) + { + ranges.push(initializer_range); + } + + ranges +} + +/// Find the range of the table literal in a local declaration's initializer +/// (`local PANEL = {}` -> range of `{}`), derived purely from the AST so it is +/// stable against type-cache mutation during synthesis. +fn find_decl_initializer_table_range( + db: &DbIndex, + file_id: FileId, + decl_id: LuaDeclId, +) -> Option> { + let tree = db.get_vfs().get_syntax_tree(&file_id)?; + let chunk = tree.get_chunk_node(); + let name_token = chunk + .syntax() + .token_at_offset(decl_id.position) + .right_biased()?; + + for ancestor in name_token.parent_ancestors() { + if let Some(local_stat) = LuaLocalStat::cast(ancestor.clone()) { + let names: Vec = local_stat.get_local_name_list().collect(); + let values: Vec = local_stat.get_value_exprs().collect(); + let var_index = names.iter().position(|name| { + name.get_name_token() + .is_some_and(|tok| tok.syntax().text_range().start() == decl_id.position) + })?; + let table_expr = value_expr_as_table(values.get(var_index)?)?; + return Some(InFiled::new(file_id, table_expr.get_range())); + } + } + + None +} + +/// Returns true when the local decl has at least one write that is not its +/// initial declaration position — i.e. it is reassigned (`PANEL = {}`) after +/// the original `local PANEL`. Used to keep the single-panel decl-binding +/// compatibility path from contaminating reused locals. +fn decl_has_reassignment(db: &DbIndex, file_id: FileId, decl_id: LuaDeclId) -> bool { + let decl_position = decl_id.position; + db.get_reference_index() + .get_decl_references(&file_id, &decl_id) + .map(|decl_references| { + decl_references + .cells + .iter() + .any(|cell| cell.is_write && cell.range.start() != decl_position) + }) + .unwrap_or(false) +} + +/// Check if a member at the given position was defined using a specific +/// variable name. Walks up from the member's syntax position to find the +/// enclosing `function VAR:Method()` / `VAR.Field = value` and checks the +/// prefix variable name. +/// +/// Returns `true` (conservative include) when the variable name cannot be +/// determined, so callers don't accidentally drop members they can't trace. +fn member_defined_via_variable( + db: &DbIndex, + file_id: FileId, + member_position: TextSize, + var_name: &str, +) -> bool { + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return true; + }; + let chunk = tree.get_chunk_node(); + let Some(token) = chunk + .syntax() + .token_at_offset(member_position) + .right_biased() + else { + return true; + }; + + for ancestor in token.parent_ancestors() { + if let Some(func_stat) = LuaFuncStat::cast(ancestor.clone()) { + if let Some(LuaVarExpr::IndexExpr(index_expr)) = func_stat.get_func_name() { + return index_expr_prefix_matches(&index_expr, var_name); + } + return false; + } + if let Some(assign_stat) = LuaAssignStat::cast(ancestor.clone()) { + let (vars, _) = assign_stat.get_var_and_expr_list(); + for var in vars { + if let LuaVarExpr::IndexExpr(index_expr) = &var { + if index_expr_prefix_matches(index_expr, var_name) { + return true; + } + } } + return false; } } + + true +} + +fn index_expr_prefix_matches(index_expr: &glua_parser::LuaIndexExpr, var_name: &str) -> bool { + if let Some(LuaExpr::NameExpr(prefix)) = index_expr.get_prefix_expr() { + prefix.get_name_text().as_deref() == Some(var_name) + } else { + false + } } fn synthesize_panel_baseclass_member( diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs index 5b54d875..98a1766a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -25,7 +25,8 @@ use crate::{ db_index::{LuaFunctionType, LuaMemberOwner, LuaSignature, LuaSignatureId}, find_members_with_key, humanize_type, semantic::{ - InferGuard, LuaInferCache, SemanticDeclGuard, VarRefId, get_var_expr_var_ref_id, + InferGuard, LuaInferCache, SelfRefId, SemanticDeclGuard, VarRefId, VarRefRootId, + get_var_expr_var_ref_id, infer_call_expr_func, infer_expr, infer_expr_semantic_decl, }, }; @@ -1738,7 +1739,7 @@ fn semantic_decl_has_write_before_position( fn semantic_decl_from_var_ref_id(var_ref_id: &VarRefId) -> Option { match var_ref_id { VarRefId::VarRef(decl_id) => Some(LuaSemanticDeclId::LuaDecl(*decl_id)), - VarRefId::SelfRef(decl_or_member_id) => match decl_or_member_id { + VarRefId::SelfRef(self_ref_id) => match &self_ref_id.receiver { LuaDeclOrMemberId::Decl(decl_id) => Some(LuaSemanticDeclId::LuaDecl(*decl_id)), LuaDeclOrMemberId::Member(member_id) => Some(LuaSemanticDeclId::Member(*member_id)), }, @@ -1758,7 +1759,14 @@ fn type_owner_to_var_ref_id(type_owner: LuaTypeOwner) -> Option { match type_owner { LuaTypeOwner::Decl(decl_id) => Some(VarRefId::VarRef(decl_id)), LuaTypeOwner::Member(member_id) => { - Some(VarRefId::SelfRef(LuaDeclOrMemberId::Member(member_id))) + // Represent a member-owner effect target as a member-rooted `SelfRef`. + // The `self_decl_id` is synthesized from the member's own location so + // the identity is unique; `receiver` carries the member used for + // base/member type lookup and index-ref extension. + Some(VarRefId::SelfRef(SelfRefId { + self_decl_id: LuaDeclId::new(member_id.file_id, member_id.get_position()), + receiver: LuaDeclOrMemberId::Member(member_id), + })) } LuaTypeOwner::SyntaxId(_) => None, } @@ -1777,18 +1785,19 @@ fn extend_var_ref_id_with_path( Some(access_path) => format!("{}.{}", access_path, field_path.join(".")), None => field_path.join("."), }; + let arc_path = ArcIntern::from(SmolStr::new(&full_path)); match var_ref_id { VarRefId::VarRef(decl_id) => Some(VarRefId::IndexRef( - LuaDeclOrMemberId::Decl(decl_id), - ArcIntern::from(SmolStr::new(&full_path)), + VarRefRootId::Decl(decl_id), + arc_path, )), - VarRefId::SelfRef(decl_or_member_id) => Some(VarRefId::IndexRef( - decl_or_member_id, - ArcIntern::from(SmolStr::new(&full_path)), + VarRefId::SelfRef(self_ref_id) => Some(VarRefId::IndexRef( + VarRefRootId::SelfRef(self_ref_id), + arc_path, )), - VarRefId::IndexRef(decl_or_member_id, _) => Some(VarRefId::IndexRef( - decl_or_member_id, - ArcIntern::from(SmolStr::new(&full_path)), + VarRefId::IndexRef(root, _) => Some(VarRefId::IndexRef( + root, + arc_path, )), VarRefId::GlobalName(_, _) => None, } diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs index a127527d..335205a0 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs @@ -1705,6 +1705,218 @@ mod test { assert_eq!(panel_local_types[1].1, LuaType::Def(second_class_id)); } + /// Regression: PANEL reassignment (`PANEL = {}`) should create distinct classes + /// per region. Today all PANEL references bind to a single class, so the set of + /// resolved class names contains only one entry instead of three. + #[gtest] + fn test_vgui_reassigned_panel_name_expr_resolves_per_region() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "lua/vgui/reassigned_panel_regions.lua", + r#" + local PANEL = {} + function PANEL:Init() end + vgui.Register("ReFrame", PANEL, "DFrame") + + PANEL = {} + function PANEL:Paint() end + vgui.Register("ReButton", PANEL, "DButton") + + PANEL = {} + function PANEL:Layout() end + derma.DefineControl("ReTree", "", PANEL, "DTree") + "#, + ); + + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + + let mut resolved_classes: Vec<_> = semantic_model + .get_root() + .descendants::() + .filter_map(|name_expr| { + let token = name_expr.get_name_token()?; + if token.get_name_text() != "PANEL" { + return None; + } + let info = semantic_model.get_semantic_info(token.syntax().clone().into())?; + match &info.typ { + LuaType::Def(id) => Some((name_expr.get_position(), id.get_simple_name().to_string())), + _ => None, + } + }) + .collect(); + resolved_classes.sort_by_key(|(pos, _)| *pos); + + let class_names: std::collections::HashSet<&str> = + resolved_classes.iter().map(|(_, n)| n.as_str()).collect(); + + assert!( + class_names.contains("ReFrame"), + "expected PANEL references to include ReFrame, got {class_names:?}" + ); + assert!( + class_names.contains("ReButton"), + "expected PANEL references to include ReButton, got {class_names:?}" + ); + assert!( + class_names.contains("ReTree"), + "expected PANEL references to include ReTree, got {class_names:?}" + ); + } + + /// Regression: the `local PANEL = {}` declaration should resolve to the FIRST + /// region's class ("ReFrame"), not a later region. Today it resolves to the + /// wrong (usually last) class. + #[gtest] + fn test_vgui_reassigned_panel_decl_token_first_region_class() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "lua/vgui/reassigned_panel_decl_type.lua", + r#" + local PANEL = {} + function PANEL:Init() end + vgui.Register("ReFrame", PANEL, "DFrame") + + PANEL = {} + function PANEL:Paint() end + vgui.Register("ReButton", PANEL, "DButton") + + PANEL = {} + function PANEL:Layout() end + derma.DefineControl("ReTree", "", PANEL, "DTree") + "#, + ); + + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + + let local_panel = semantic_model + .get_root() + .descendants::() + .find(|local_name| { + local_name + .get_name_token() + .is_some_and(|t| t.get_name_text() == "PANEL") + }) + .expect("expected one local PANEL declaration"); + + let token = local_panel.get_name_token().expect("expected PANEL token"); + let semantic_info = semantic_model + .get_semantic_info(token.syntax().clone().into()) + .expect("expected semantic info for local PANEL"); + + assert_eq!( + semantic_info.typ, + LuaType::Def(LuaTypeDeclId::global("ReFrame")), + "local PANEL should resolve to first region class ReFrame" + ); + } + + /// Regression: `self:ReloadTree()` inside the FIRST PANEL region should not + /// produce a false undefined-field diagnostic. Under the bug, all PANEL + /// references collapse to the LAST registered class ("BrowserPanel"), so + /// `self` in the early region resolves to BrowserPanel which lacks + /// ReloadTree → false positive. After the fix, each region resolves to its + /// own class: self in TreePanel's region finds ReloadTree → no diagnostic. + #[gtest] + fn test_vgui_reassigned_panel_self_method_no_undefined_field() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.infer_dynamic_fields = false; + ws.update_emmyrc(emmyrc); + ws.enable_check(DiagnosticCode::UndefinedField); + + let file_id = ws.def_file( + "lua/vgui/reassigned_panel_self_method.lua", + r#" + local PANEL = {} + function PANEL:ReloadTree() end + function PANEL:Refresh() + self:ReloadTree() + end + vgui.Register("TreePanel", PANEL, "DTree") + + PANEL = {} + function PANEL:Paint() end + vgui.Register("BrowserPanel", PANEL, "DPanel") + "#, + ); + + let diagnostics = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default(); + + let undefined_field_code = Some(NumberOrString::String( + DiagnosticCode::UndefinedField.get_name().to_string(), + )); + assert!( + diagnostics + .iter() + .all(|diag| diag.code != undefined_field_code), + "self:ReloadTree() in TreePanel region should not trigger undefined-field, got {diagnostics:?}" + ); + } + + /// Negative control: calling a method that exists on NO region should still + /// produce an undefined-field diagnostic. Guards against an over-broad fix + /// that suppresses all field checks on reassigned PANEL. + #[gtest] + fn test_vgui_reassigned_panel_wrong_method_still_flags_undefined_field() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.infer_dynamic_fields = false; + ws.update_emmyrc(emmyrc); + ws.enable_check(DiagnosticCode::UndefinedField); + + let file_id = ws.def_file( + "lua/vgui/reassigned_panel_wrong_method.lua", + r#" + local PANEL = {} + function PANEL:Init() end + vgui.Register("AlphaPanel", PANEL, "DFrame") + + PANEL = {} + function PANEL:Refresh() + self:DoesNotExistAnywhere() + end + vgui.Register("BetaPanel", PANEL, "DPanel") + "#, + ); + + let diagnostics = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default(); + + let undefined_field_code = Some(NumberOrString::String( + DiagnosticCode::UndefinedField.get_name().to_string(), + )); + assert!( + diagnostics + .iter() + .any(|diag| diag.code == undefined_field_code), + "self:DoesNotExistAnywhere() should produce an undefined-field diagnostic, got {diagnostics:?}" + ); + } + #[gtest] fn test_vgui_panel_self_dynamic_field_no_undefined_field_diagnostic() { let mut ws = VirtualWorkspace::new(); @@ -7125,4 +7337,127 @@ mod test { "unexpected GetSpeed from nonexistent target in {member_names:?}" ); } + + /// Oracle audit: `self.field` IndexRef root uses the shared receiver decl, + /// NOT the method-aware self_decl_id. Two methods of the SAME reused + /// `local PANEL` (regions A and B) share the same receiver `Decl(panel_decl)`. + /// If the IndexRef key for `self.value` collapses across regions, then + /// `self.value` typed as string in region A could poison region B's + /// `self.value` (which should be number), or vice-versa. + /// + /// This test assigns DIFFERENT types to `self.value` in each region and + /// asserts each resolves independently. + #[gtest] + fn test_vgui_reassigned_panel_self_field_type_not_collapsed_across_regions() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "lua/vgui/self_field_collapse.lua", + r#" + local PANEL = {} + function PANEL:Init() + self.value = "stringval" + end + function PANEL:UseValue() + local x = self.value + end + vgui.Register("PanelA", PANEL, "DPanel") + + PANEL = {} + function PANEL:Init() + self.value = 123 + end + function PANEL:UseValue() + local y = self.value + end + vgui.Register("PanelB", PANEL, "DPanel") + "#, + ); + + let x_type = local_name_type(&mut ws, file_id, "x"); + let y_type = local_name_type(&mut ws, file_id, "y"); + + let x_display = ws.humanize_type(x_type.clone()); + let y_display = ws.humanize_type(y_type.clone()); + + // Region A: self.value = "stringval" → x should resolve to string + assert!( + x_display.contains("string"), + "PanelA region: self.value should resolve to string, got {x_display:?}" + ); + + // Region B: self.value = 123 → y should resolve to number/integer + assert!( + y_display.contains("number") + || y_display.contains("integer") + || matches!(y_type, LuaType::IntegerConst(_) | LuaType::Number | LuaType::Integer), + "PanelB region: self.value should resolve to number/integer, got {y_display:?} ({y_type:?})" + ); + + // The two types must be distinct — collapse would make them identical + assert_ne!( + x_type, y_type, + "self.value types should differ across regions (collapse detected): \ + x={x_display:?}, y={y_display:?}" + ); + } + + /// Regression: when PANEL is aliased (`local OLD = PANEL`), then PANEL is + /// reassigned (`PANEL = {}`), and a method is added via the OLD alias + /// (`function OLD:OldOnly() end`), the fallback member transfer must NOT + /// carry `OldOnly` into the new panel class. The member belongs to the + /// original initializer table through the OLD alias, not to the reassigned + /// PANEL table that is being registered. + /// + /// The risk: `collect_panel_member_source_ranges` includes the initializer + /// table range as a fallback source. If `OldOnly`'s position falls within + /// `[latest_write_position, register_position)` of the PANEL decl, the + /// position-slicing filter could wrongly transfer it to NewPanel. + #[gtest] + fn test_vgui_register_alias_after_reassignment_does_not_transfer_aliased_members() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "lua/vgui/alias_after_reassign.lua", + r#" + local PANEL = {} + local OLD = PANEL + PANEL = {} + function OLD:OldOnly() end + vgui.Register("NewPanel", PANEL, "DPanel") + "#, + ); + + let db = ws.get_db_mut(); + let new_panel_class_id = LuaTypeDeclId::global("NewPanel"); + + assert!( + db.get_type_index() + .get_type_decl(&new_panel_class_id) + .is_some(), + "NewPanel class should be created" + ); + + let new_panel_members = db + .get_member_index() + .get_members(&LuaMemberOwner::Type(new_panel_class_id.clone())) + .map(|members| { + members + .iter() + .filter_map(|member| member.get_key().get_name().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + assert!( + !new_panel_members.contains(&"OldOnly".to_string()), + "NewPanel should NOT inherit OldOnly from the aliased OLD table, got {new_panel_members:?}" + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 29694939..ee01967d 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -50,6 +50,13 @@ pub struct LuaInferCache { /// Avoids repeated ancestor walks and type resolution for each `self` reference /// within the same method body. pub self_type_cache: FxHashMap>, + /// Region-aware base type seed for an implicit `self` flow query, set by + /// `infer_self` for the duration of a single `infer_expr_narrow_type_with_self_base` + /// call. When the flow walk reaches the origin for the matching `SelfRef`, + /// this seed is used as the base type instead of the (position-insensitive) + /// receiver decl/member cache, so reused locals resolve `self` per region + /// while still going through the normal narrowing pipeline. + pub self_base_seed: Option<(VarRefId, LuaType)>, /// Cache for `find_decl` results so that multiple diagnostic checkers /// processing the same file don't redo the full member-resolution chain. pub decl_cache: FxHashMap>, @@ -161,6 +168,7 @@ impl LuaInferCache { scoped_scripted_global_cache: None, pending_str_tpl_type_decls: Vec::new(), self_type_cache: FxHashMap::default(), + self_base_seed: None, decl_cache: FxHashMap::default(), for_range_iter_var_type_cache: FxHashMap::default(), local_reassignment_positions_cache: FxHashMap::default(), @@ -285,6 +293,7 @@ impl LuaInferCache { self.scoped_scripted_global_cache = None; self.pending_str_tpl_type_decls.clear(); self.self_type_cache.clear(); + self.self_base_seed = None; self.decl_cache.clear(); self.for_range_iter_var_type_cache.clear(); self.local_reassignment_positions_cache.clear(); diff --git a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs index 8b2c2f27..c541d233 100644 --- a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs +++ b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs @@ -299,13 +299,22 @@ fn infer_enclosing_self_type( name_expr: &LuaNameExpr, ) -> Option { for func_stat in name_expr.ancestors::() { - let func_name = func_stat.get_func_name()?; - if let LuaVarExpr::IndexExpr(index_expr) = func_name - && index_expr.get_index_token()?.is_colon() + // Skip anonymous/non-colon ancestors (e.g. nested closures) and keep + // walking outward to the enclosing colon method, rather than bailing out + // on the first ancestor that lacks a colon-method name. + let Some(LuaVarExpr::IndexExpr(index_expr)) = func_stat.get_func_name() else { + continue; + }; + if !index_expr + .get_index_token() + .is_some_and(|token| token.is_colon()) { - let prefix_expr = index_expr.get_prefix_expr()?; - return infer_expr(db, cache, prefix_expr).ok(); + continue; } + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + continue; + }; + return infer_expr(db, cache, prefix_expr).ok(); } None diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs index b57fb933..15eb694a 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs @@ -13,7 +13,7 @@ use smol_str::SmolStr; use crate::{ CacheEntry, FileId, GenericTpl, GlobalId, InFiled, InferGuardRef, LuaAliasCallKind, LuaDeclId, - LuaDeclOrMemberId, LuaInferCache, LuaInstanceType, LuaMemberOwner, LuaOperatorOwner, TypeOps, + LuaInferCache, LuaInstanceType, LuaMemberOwner, LuaOperatorOwner, TypeOps, compilation::{get_scripted_class_info_for_file, get_scripted_class_type_decl_id}, db_index::{ DbIndex, LuaGenericType, LuaIntersectionType, LuaMemberKey, LuaMergedTableType, @@ -24,7 +24,7 @@ use crate::{ InferGuard, generic::{TypeSubstitutor, instantiate_type_generic}, infer::{ - VarRefId, infer_index::infer_array::infer_array_member, + VarRefId, VarRefRootId, infer_index::infer_array::infer_array_member, infer_name::get_name_expr_var_ref_id, narrow::infer_expr_narrow_type, }, is_doc_tag_table_const, @@ -213,13 +213,13 @@ pub fn get_index_expr_var_ref_id( } if let LuaExpr::NameExpr(name_expr) = prefix_expr { - let decl_or_member_id = match get_name_expr_var_ref_id(db, cache, &name_expr) { - Some(VarRefId::SelfRef(decl_or_id)) => decl_or_id, - Some(VarRefId::VarRef(decl_id)) => LuaDeclOrMemberId::Decl(decl_id), + let root = match get_name_expr_var_ref_id(db, cache, &name_expr) { + Some(VarRefId::SelfRef(self_ref_id)) => VarRefRootId::SelfRef(self_ref_id), + Some(VarRefId::VarRef(decl_id)) => VarRefRootId::Decl(decl_id), _ => return None, }; - let var_ref_id = VarRefId::IndexRef(decl_or_member_id, access_path); + let var_ref_id = VarRefId::IndexRef(root, access_path); cache .expr_var_ref_id_cache .insert(syntax_id, var_ref_id.clone()); diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 4a111dec..57f9adb9 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -13,7 +13,9 @@ use crate::{ db_index::{DbIndex, LuaDeclOrMemberId}, infer_node_semantic_decl, semantic::{ - infer::narrow::{VarRefId, infer_expr_narrow_type}, + infer::narrow::{ + SelfRefId, VarRefId, infer_expr_narrow_type, infer_expr_narrow_type_with_self_base, + }, member::merge_open_table_types, semantic_info::resolve_global_decl_id, }, @@ -512,22 +514,45 @@ fn is_in_scripted_class_scope(db: &DbIndex, file_id: FileId) -> bool { } fn infer_self(db: &DbIndex, cache: &mut LuaInferCache, name_expr: LuaNameExpr) -> InferResult { - if let Some(scoped_self_type) = infer_scoped_implicit_self_type(db, cache, &name_expr) { - return Ok(scoped_self_type); - } - - let decl_or_member_id = - find_self_decl_or_member_id(db, cache, &name_expr).ok_or(InferFailReason::None)?; - // LuaDeclOrMemberId::Member(member_id) => find_decl_member_type(db, member_id), - infer_expr_narrow_type( + let self_ref_id = find_self_ref_id(db, cache, &name_expr).ok_or(InferFailReason::None)?; + + // Compute a region-aware base for the implicit `self` (the colon-method + // receiver inferred at its own position). For reused locals reassigned per + // region this yields the correct per-region class; for stable globals it + // yields the same declared type the generic path would. We then run the + // normal flow-narrowing pipeline on top of this base, so guards like + // `if self == self.parent then ... end` still narrow correctly. + // + // The base is only seeded when concrete (Def/Ref/TableConst/Instance/...), + // so generic `SelfInfer`/declared-parameter `self` still falls through to + // the canonical `get_var_ref_type` resolution. + let base_seed = infer_implicit_method_self_type(db, cache, &name_expr); + + infer_expr_narrow_type_with_self_base( db, cache, LuaExpr::NameExpr(name_expr), - VarRefId::SelfRef(decl_or_member_id), + VarRefId::SelfRef(self_ref_id), + base_seed, ) } -fn infer_scoped_implicit_self_type( +/// Resolves the type of an implicit `self` inside a colon method by binding it +/// to the method's receiver (the colon-method prefix), so `self` always agrees +/// with the parent it is defined within — including reused locals reassigned to +/// distinct tables/classes per region. +/// +/// Resolution order: +/// 1. Path-scoped seeded class locals (ENT/SWEP/GM) resolve by their scoped +/// class name (one class per file) — preserved as-is. +/// 2. Otherwise infer the enclosing colon-method prefix expression *at its +/// position* (region-aware via flow + GMod table-literal class binding) and +/// use it when it yields a concrete receiver type. +/// +/// Returns `None` for explicit (non-implicit) `self`, or when no concrete +/// receiver type can be derived, so callers fall back to the generic +/// declaration/member `SelfRef` path. +fn infer_implicit_method_self_type( db: &DbIndex, cache: &mut LuaInferCache, name_expr: &LuaNameExpr, @@ -542,23 +567,43 @@ fn infer_scoped_implicit_self_type( let func_stat = name_expr.ancestors::().next()?; let func_syntax_id = func_stat.get_syntax_id(); - // Check self type cache for this method + // Cache the unified result (including a negative result) for subsequent + // `self` references in the same method body. if let Some(cached) = cache.self_type_cache.get(&func_syntax_id) { return cached.clone(); } - let result = infer_scoped_implicit_self_type_inner(db, cache, func_stat); - - // Cache the result for subsequent `self` references in the same method + let result = infer_implicit_method_self_type_inner(db, cache, name_expr); cache.self_type_cache.insert(func_syntax_id, result.clone()); result } -fn infer_scoped_implicit_self_type_inner( +fn infer_implicit_method_self_type_inner( + db: &DbIndex, + cache: &mut LuaInferCache, + name_expr: &LuaNameExpr, +) -> Option { + // 1. Path-scoped seeded class locals (ENT/SWEP/GM): name/path-driven, one + // class per file. Keep this first to preserve scoped-class behavior. + if let Some(scoped_type) = infer_scoped_seeded_class_self_type(db, cache, name_expr) { + return Some(scoped_type); + } + + // 2. General case: infer the enclosing colon-method prefix at its position. + // This is region-aware, so a reused local resolves `self` to the class + // of the table backing the current region. + let prefix_type = infer_enclosing_self_type(db, cache, name_expr)?; + is_concrete_self_receiver_type(&prefix_type).then_some(prefix_type) +} + +/// Resolves `self` for synthetically-seeded scoped class locals (ENT/SWEP/GM), +/// which map a file to a single class by name/path. +fn infer_scoped_seeded_class_self_type( db: &DbIndex, cache: &mut LuaInferCache, - func_stat: LuaFuncStat, + name_expr: &LuaNameExpr, ) -> Option { + let func_stat = name_expr.ancestors::().next()?; let func_name = func_stat.get_func_name()?; let LuaVarExpr::IndexExpr(index_expr) = func_name else { return None; @@ -585,6 +630,22 @@ fn infer_scoped_implicit_self_type_inner( Some(LuaType::Def(class_decl_id)) } +/// Returns true when `typ` is a concrete receiver type suitable to be used +/// directly as an implicit `self` type. Rejects unconstrained/unknown types so +/// the caller falls back to the generic `SelfRef` resolution path (preserving +/// generic `SelfInfer` and declared-parameter behavior). +fn is_concrete_self_receiver_type(typ: &LuaType) -> bool { + match typ { + LuaType::Def(_) + | LuaType::Ref(_) + | LuaType::TableConst(_) + | LuaType::Instance(_) + | LuaType::Object(_) => true, + LuaType::Union(union) => union.into_vec().iter().any(is_concrete_self_receiver_type), + _ => false, + } +} + pub fn get_name_expr_var_ref_id( db: &DbIndex, cache: &mut LuaInferCache, @@ -594,8 +655,8 @@ pub fn get_name_expr_var_ref_id( let name = name_token.get_name_text(); match name { "self" => { - let decl_or_id = find_self_decl_or_member_id(db, cache, name_expr)?; - Some(VarRefId::SelfRef(decl_or_id)) + let self_ref_id = find_self_ref_id(db, cache, name_expr)?; + Some(VarRefId::SelfRef(self_ref_id)) } _ => { let file_id = cache.get_file_id(); @@ -1308,19 +1369,49 @@ fn is_realm_compatible(call_realm: GmodRealm, decl_realm: GmodRealm) -> bool { ) } -pub fn find_self_decl_or_member_id( +/// Resolves the full `self` reference identity for a `self` name expression. +/// +/// Returns a [`SelfRefId`] carrying: +/// - `self_decl_id`: the (implicit or explicit) `self` declaration — unique per +/// method body, used as the flow-cache / `VarRefId` identity. +/// - `receiver`: the colon-method prefix owner used for base/member lookup. +/// +/// For an explicit (shadowing) `self` local/param, the receiver is the `self` +/// decl itself, so it behaves like an ordinary local. +pub fn find_self_ref_id( db: &DbIndex, cache: &mut LuaInferCache, name_expr: &LuaNameExpr, -) -> Option { +) -> Option { let file_id = cache.get_file_id(); let tree = db.get_decl_index().get_decl_tree(&file_id)?; let self_decl = tree.find_local_decl("self", name_expr.get_position())?; + let self_decl_id = self_decl.get_id(); if !self_decl.is_implicit_self() { - return Some(LuaDeclOrMemberId::Decl(self_decl.get_id())); + return Some(SelfRefId { + self_decl_id, + receiver: LuaDeclOrMemberId::Decl(self_decl_id), + }); } + let receiver = find_self_receiver_id(db, cache, &self_decl, name_expr)?; + Some(SelfRefId { + self_decl_id, + receiver, + }) +} + +/// Resolves the receiver owner (colon-method prefix) for an implicit `self`. +fn find_self_receiver_id( + db: &DbIndex, + cache: &mut LuaInferCache, + self_decl: &LuaDecl, + name_expr: &LuaNameExpr, +) -> Option { + let file_id = cache.get_file_id(); + let tree = db.get_decl_index().get_decl_tree(&file_id)?; + let root = name_expr.get_root(); let syntax_id = self_decl.get_syntax_id(); let index_token = syntax_id.to_token_from_root(&root)?; @@ -1356,6 +1447,19 @@ pub fn find_self_decl_or_member_id( } } +/// Resolves only the receiver owner of a `self` expression (decl or member). +/// +/// Retained for callers that need the receiver owner (member/base lookup, +/// unresolved-reference rewriting) and do not care about the per-method `self` +/// identity. +pub fn find_self_decl_or_member_id( + db: &DbIndex, + cache: &mut LuaInferCache, + name_expr: &LuaNameExpr, +) -> Option { + Some(find_self_ref_id(db, cache, name_expr)?.receiver) +} + /// Returns true if the type contains an unresolved `SelfInfer`. fn contains_self_infer(typ: &LuaType) -> bool { match typ { @@ -1391,13 +1495,22 @@ fn infer_enclosing_self_type( name_expr: &LuaNameExpr, ) -> Option { for func_stat in name_expr.ancestors::() { - let func_name = func_stat.get_func_name()?; - if let LuaVarExpr::IndexExpr(index_expr) = func_name { - if index_expr.get_index_token()?.is_colon() { - let prefix_expr = index_expr.get_prefix_expr()?; - return infer_expr(db, cache, prefix_expr).ok(); - } + // Skip anonymous/non-colon ancestors (e.g. nested closures) and keep + // walking outward to the enclosing colon method, rather than bailing out + // on the first ancestor that lacks a colon-method name. + let Some(LuaVarExpr::IndexExpr(index_expr)) = func_stat.get_func_name() else { + continue; + }; + if !index_expr + .get_index_token() + .is_some_and(|token| token.is_colon()) + { + continue; } + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + continue; + }; + return infer_expr(db, cache, prefix_expr).ok(); } None } diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 0cb894ef..1efece56 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -31,7 +31,7 @@ pub use infer_name::{find_self_decl_or_member_id, infer_param}; use infer_table::infer_table_expr; pub use infer_table::{infer_table_field_value_should_be, infer_table_should_be}; use infer_unary::infer_unary_expr; -pub use narrow::VarRefId; +pub use narrow::{SelfRefId, VarRefId, VarRefRootId}; pub(crate) use narrow::{contains_gmod_null_type, get_var_expr_var_ref_id, remove_false_or_nil}; use rowan::TextRange; diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index f1c01aba..389bbfbc 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -959,8 +959,8 @@ fn special_call_effect_matches_var_ref(effect_target: &VarRefId, var_ref_id: &Va effect_target == var_ref_id || matches!( (effect_target, var_ref_id), - (VarRefId::SelfRef(effect_base), VarRefId::IndexRef(current_base, _)) - if effect_base == current_base + (VarRefId::SelfRef(effect_self), VarRefId::IndexRef(root, _)) + if root.receiver_eq(&effect_self.receiver) ) } @@ -1036,7 +1036,24 @@ fn get_type_at_assign_stat( } }; - let narrowed = if source_type == LuaType::Nil { + // Assignment is value REPLACEMENT, not condition refinement. When the RHS is a + // fresh table-literal constructor, its table identity must replace the + // antecedent's identity rather than being narrowed against it. Narrowing + // (`narrow_down_type`) intentionally preserves the antecedent `TableConst` + // identity for `TableConst -> TableConst`, which is correct for guards like + // `if type(x) == "table"` but wrong for `x = {}`: it would keep the previous + // table, collapsing every reassigned region of a reused local onto the first + // table literal. Bypass narrowing for literal-table assignments (no explicit + // doc `@type` override) so each region keeps its own table identity. + let rhs_is_fresh_table_literal = explicit_var_type.is_none() + && matches!(expr_type, LuaType::TableConst(_)) + && exprs + .get(i) + .is_some_and(expr_is_table_constructor); + + let narrowed = if rhs_is_fresh_table_literal { + Some(expr_type.clone()) + } else if source_type == LuaType::Nil { None } else { let declared = @@ -1064,6 +1081,19 @@ fn get_type_at_assign_stat( Ok(ResultTypeOrContinue::Continue) } +/// Returns true when `expr` is a table-constructor literal `{ ... }`, unwrapping +/// redundant parentheses (e.g. `({})`). Used to detect value-replacement +/// assignments where the RHS introduces a fresh table identity. +fn expr_is_table_constructor(expr: &LuaExpr) -> bool { + match expr { + LuaExpr::TableExpr(_) => true, + LuaExpr::ParenExpr(paren_expr) => paren_expr + .get_expr() + .is_some_and(|inner| expr_is_table_constructor(&inner)), + _ => false, + } +} + fn guarded_global_self_assignment_type( db: &DbIndex, cache: &mut LuaInferCache, diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs index c173f592..3f9a9166 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs @@ -17,7 +17,7 @@ use crate::{ pub use get_type_at_cast_flow::get_type_at_call_expr_inline_cast; use glua_parser::{LuaAstNode, LuaChunk, LuaExpr}; pub use narrow_type::{narrow_down_type, narrow_false_or_nil, remove_false_or_nil}; -pub use var_ref_id::{VarRefId, get_var_expr_var_ref_id}; +pub use var_ref_id::{SelfRefId, VarRefId, VarRefRootId, get_var_expr_var_ref_id}; const GMOD_NULL_TYPE_NAME: &str = "NULL"; @@ -113,11 +113,52 @@ pub fn infer_expr_narrow_type( result } +/// Like [`infer_expr_narrow_type`], but seeds the flow origin with a +/// region-aware base type for an implicit `self` reference. +/// +/// `self_base` is the receiver type inferred at the colon-method prefix +/// position (region-aware). When the flow walk reaches the origin for this +/// exact `var_ref_id`, the seed is used as the base instead of the +/// position-insensitive receiver decl/member cache. This keeps `self` flowing +/// through the normal narrowing pipeline (so guards still narrow) while fixing +/// reused-local region collapse. +/// +/// When `self_base` is `None`, this is identical to [`infer_expr_narrow_type`]. +pub fn infer_expr_narrow_type_with_self_base( + db: &DbIndex, + cache: &mut LuaInferCache, + expr: LuaExpr, + var_ref_id: VarRefId, + self_base: Option, +) -> InferResult { + let Some(self_base) = self_base else { + return infer_expr_narrow_type(db, cache, expr, var_ref_id); + }; + + let previous_seed = cache + .self_base_seed + .replace((var_ref_id.clone(), self_base)); + let result = infer_expr_narrow_type(db, cache, expr, var_ref_id); + cache.self_base_seed = previous_seed; + result +} + pub fn get_var_ref_type( db: &DbIndex, cache: &mut LuaInferCache, var_ref_id: &VarRefId, ) -> InferResult { + // Region-aware `self` base: if a seed was set for this exact `self` + // reference, use it as the flow origin type instead of resolving the + // (position-insensitive) receiver decl/member cache. See + // `infer_expr_narrow_type_with_self_base`. + if var_ref_id.is_self_ref() + && let Some((seed_ref, seed_type)) = cache.self_base_seed.as_ref() + && seed_ref == var_ref_id + { + return Ok(seed_type.clone()); + } + if let Some(decl_id) = var_ref_id.get_decl_id_ref() { let decl = db .get_decl_index() diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/var_ref_id.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/var_ref_id.rs index 8ae85df4..021c834d 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/var_ref_id.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/var_ref_id.rs @@ -16,12 +16,136 @@ use crate::{ }, }; +/// Identity for an implicit `self` reference inside a colon method. +/// +/// `self_decl_id` is the method's implicit `self` declaration, which is unique +/// per method body. It is used as the flow-cache / `VarRefId` identity so that +/// two methods of the *same* reused local (e.g. `local PANEL` reassigned and +/// redefined per region) do NOT share a `SelfRef` key and poison each other's +/// flow narrowing. +/// +/// `receiver` is the colon-method prefix owner (decl or member) and is used +/// only for base/member type lookup, never for identity. +#[derive(Debug, Clone)] +pub struct SelfRefId { + pub self_decl_id: LuaDeclId, + pub receiver: LuaDeclOrMemberId, +} + +impl PartialEq for SelfRefId { + fn eq(&self, other: &Self) -> bool { + // Identity is keyed on the unique implicit-self decl, NOT the receiver. + self.self_decl_id == other.self_decl_id + } +} + +impl Eq for SelfRefId {} + +impl Hash for SelfRefId { + fn hash(&self, state: &mut H) { + self.self_decl_id.hash(state); + } +} + +/// Root identity for [`VarRefId::IndexRef`]. +/// +/// An index expression like `self.value` or `tbl.field` has two parts: the root +/// (what is being indexed) and the access path (`"value"`). This enum captures +/// the root identity with enough precision so that two index expressions from +/// *different* regions of a reused local (e.g. `local PANEL` reassigned and +/// redefined per `vgui.Register` region) do NOT share a flow-cache key. +/// +/// - `Decl` / `Member` preserve the old behaviour for ordinary table/variable +/// index refs. +/// - `SelfRef` carries the full [`SelfRefId`] (method-aware identity) so that +/// `self.field` inside different colon-methods of the *same* reused local +/// keeps distinct var-ref identity. This prevents flow narrowing from one +/// region poisoning another. +#[derive(Debug, Clone)] +pub enum VarRefRootId { + Decl(LuaDeclId), + Member(LuaMemberId), + SelfRef(SelfRefId), +} + +impl PartialEq for VarRefRootId { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Decl(l), Self::Decl(r)) => l == r, + (Self::Member(l), Self::Member(r)) => l == r, + (Self::SelfRef(l), Self::SelfRef(r)) => l == r, + _ => false, + } + } +} + +impl Eq for VarRefRootId {} + +impl Hash for VarRefRootId { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + match self { + Self::Decl(d) => d.hash(state), + Self::Member(m) => m.hash(state), + Self::SelfRef(s) => s.hash(state), + } + } +} + +impl VarRefRootId { + /// Returns the underlying decl id, if any. + /// + /// For `SelfRef` roots this resolves through the receiver. + pub fn as_decl_id(&self) -> Option { + match self { + Self::Decl(d) => Some(*d), + Self::SelfRef(s) => s.receiver.as_decl_id(), + Self::Member(_) => None, + } + } + + /// Returns the underlying member id, if any. + pub fn as_member_id(&self) -> Option { + match self { + Self::Member(m) => Some(*m), + Self::SelfRef(s) => s.receiver.as_member_id(), + Self::Decl(_) => None, + } + } + + /// Source position used for realm resolution. + pub fn get_position(&self) -> TextSize { + match self { + Self::Decl(d) => d.position, + Self::Member(m) => m.get_position(), + // Use the implicit-self decl position so the flow query resolves the + // realm at the method body, consistent with the self identity. + Self::SelfRef(s) => s.self_decl_id.position, + } + } + + /// Returns true when this root represents the same *receiver object* as the + /// given [`LuaDeclOrMemberId`]. + /// + /// For `Decl` / `Member` roots the comparison is direct. For `SelfRef` + /// roots the comparison goes through `SelfRefId::receiver`, so that effects + /// targeting `self` (as a `SelfRef`) correctly match index refs rooted in + /// the same receiver even across different method bodies. + pub fn receiver_eq(&self, other: &LuaDeclOrMemberId) -> bool { + match self { + Self::Decl(d) => LuaDeclOrMemberId::Decl(*d) == *other, + Self::Member(m) => LuaDeclOrMemberId::Member(*m) == *other, + Self::SelfRef(s) => s.receiver == *other, + } + } +} + #[allow(clippy::enum_variant_names)] #[derive(Debug, Clone)] pub enum VarRefId { VarRef(LuaDeclId), - SelfRef(LuaDeclOrMemberId), - IndexRef(LuaDeclOrMemberId, ArcIntern), + SelfRef(SelfRefId), + IndexRef(VarRefRootId, ArcIntern), GlobalName(ArcIntern, TextSize), } @@ -31,9 +155,9 @@ impl PartialEq for VarRefId { (VarRefId::VarRef(left), VarRefId::VarRef(right)) => left == right, (VarRefId::SelfRef(left), VarRefId::SelfRef(right)) => left == right, ( - VarRefId::IndexRef(left_owner, left_path), - VarRefId::IndexRef(right_owner, right_path), - ) => left_owner == right_owner && left_path == right_path, + VarRefId::IndexRef(left_root, left_path), + VarRefId::IndexRef(right_root, right_path), + ) => left_root == right_root && left_path == right_path, (VarRefId::GlobalName(left_name, _), VarRefId::GlobalName(right_name, _)) => { left_name == right_name } @@ -49,9 +173,9 @@ impl Hash for VarRefId { std::mem::discriminant(self).hash(state); match self { VarRefId::VarRef(decl_id) => decl_id.hash(state), - VarRefId::SelfRef(decl_or_member_id) => decl_or_member_id.hash(state), - VarRefId::IndexRef(decl_or_member_id, path) => { - decl_or_member_id.hash(state); + VarRefId::SelfRef(self_ref_id) => self_ref_id.hash(state), + VarRefId::IndexRef(root, path) => { + root.hash(state); path.hash(state); } VarRefId::GlobalName(name, _) => name.hash(state), @@ -63,14 +187,14 @@ impl VarRefId { pub fn get_decl_id_ref(&self) -> Option { match self { VarRefId::VarRef(decl_id) => Some(*decl_id), - VarRefId::SelfRef(decl_or_member_id) => decl_or_member_id.as_decl_id(), + VarRefId::SelfRef(self_ref_id) => self_ref_id.receiver.as_decl_id(), _ => None, } } pub fn get_member_id_ref(&self) -> Option { match self { - VarRefId::SelfRef(decl_or_member_id) => decl_or_member_id.as_member_id(), + VarRefId::SelfRef(self_ref_id) => self_ref_id.receiver.as_member_id(), _ => None, } } @@ -78,25 +202,25 @@ impl VarRefId { pub fn get_position(&self) -> TextSize { match self { VarRefId::VarRef(decl_id) => decl_id.position, - VarRefId::SelfRef(decl_or_member_id) => decl_or_member_id.get_position(), - VarRefId::IndexRef(decl_or_member_id, _) => decl_or_member_id.get_position(), + // Use the implicit-self decl position so the flow query resolves the + // realm at the method body, consistent with the self identity. + VarRefId::SelfRef(self_ref_id) => self_ref_id.self_decl_id.position, + VarRefId::IndexRef(root, _) => root.get_position(), VarRefId::GlobalName(_, position) => *position, } } pub fn start_with(&self, prefix: &VarRefId) -> bool { - let (decl_or_member_id, path) = match self { - VarRefId::IndexRef(decl_or_member_id, path) => { - (decl_or_member_id.clone(), path.clone()) - } + let (root, path) = match self { + VarRefId::IndexRef(root, path) => (root, path.clone()), _ => return false, }; match prefix { - VarRefId::VarRef(decl_id) => decl_or_member_id.as_decl_id() == Some(*decl_id), - VarRefId::SelfRef(ref_decl_or_member_id) => *ref_decl_or_member_id == decl_or_member_id, - VarRefId::IndexRef(ref_decl_or_member_id, prefix_path) => { - *ref_decl_or_member_id == decl_or_member_id + VarRefId::VarRef(decl_id) => root.as_decl_id() == Some(*decl_id), + VarRefId::SelfRef(self_ref_id) => root.receiver_eq(&self_ref_id.receiver), + VarRefId::IndexRef(prefix_root, prefix_path) => { + *prefix_root == *root && (path == *prefix_path || path .strip_prefix(prefix_path.deref().as_str()) @@ -137,9 +261,9 @@ fn get_call_expr_var_ref_id( let mut args_iter = args_list.get_args(); let obj_expr = args_iter.next()?; - let decl_or_member_id = match get_var_expr_var_ref_id(db, cache, obj_expr.clone()) { - Some(VarRefId::SelfRef(decl_or_id)) => decl_or_id, - Some(VarRefId::VarRef(decl_id)) => LuaDeclOrMemberId::Decl(decl_id), + let root = match get_var_expr_var_ref_id(db, cache, obj_expr.clone()) { + Some(VarRefId::SelfRef(self_ref_id)) => VarRefRootId::SelfRef(self_ref_id), + Some(VarRefId::VarRef(decl_id)) => VarRefRootId::Decl(decl_id), _ => return None, }; // 开始构建 access_path @@ -167,7 +291,7 @@ fn get_call_expr_var_ref_id( } Some(VarRefId::IndexRef( - decl_or_member_id, + root, ArcIntern::new(SmolStr::new(access_path)), )) } diff --git a/crates/glua_code_analysis/src/semantic/infer/test.rs b/crates/glua_code_analysis/src/semantic/infer/test.rs index 397fa064..8d3bf527 100644 --- a/crates/glua_code_analysis/src/semantic/infer/test.rs +++ b/crates/glua_code_analysis/src/semantic/infer/test.rs @@ -1070,4 +1070,58 @@ mod test { let ty = infer_last_name_expr_type(&mut ws, code, "A"); assert!(!ty.is_unknown()); } + + #[test] + fn test_reassigned_local_table_resolves_to_new_table_identity() { + // General Lua semantics (no GMod): after `t = {}` the local must hold the + // NEW table literal identity, not the initializer table identity. The two + // table literals carry distinct fields; resolving the wrong identity is the + // root cause of reused-variable collapse. + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + local t = {} + function t.first() end + t = {} + function t.second() end + "#, + ); + + let first_t = { + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("Semantic model must exist"); + // The first `t` reference (after the initializer) should resolve to the + // initializer table identity. + let exprs = semantic_model + .get_root() + .descendants::() + .filter(|expr| expr.get_name_text().as_deref() == Some("t")) + .collect::>(); + // exprs: [t in `function t.first`, t in `t = {}` (lhs), t in `function t.second`] + let first_use = exprs.first().expect("first t use").clone(); + let last_use = exprs.last().expect("last t use").clone(); + let first_ty = semantic_model + .infer_expr(LuaExpr::NameExpr(first_use)) + .unwrap_or(LuaType::Unknown); + let last_ty = semantic_model + .infer_expr(LuaExpr::NameExpr(last_use)) + .unwrap_or(LuaType::Unknown); + (first_ty, last_ty) + }; + + let (LuaType::TableConst(first_range), LuaType::TableConst(last_range)) = first_t else { + panic!("expected both t uses to be table consts, got: {first_t:?}"); + }; + + // The bug: both resolve to the SAME (initializer) table identity. + // Correct Lua: the use after reassignment must be a DISTINCT table identity. + assert!( + first_range != last_range, + "reassigned local `t` collapsed to the initializer table identity \ + (first={first_range:?}, last={last_range:?}); each region must keep its own table" + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index d186594a..c5b1509c 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -63,7 +63,7 @@ use crate::{LuaFunctionType, LuaMemberId, LuaMemberKey, LuaTypeOwner}; pub use generic::*; pub use guard::{InferGuard, InferGuardRef}; pub use infer::InferFailReason; -pub use infer::VarRefId; +pub use infer::{SelfRefId, VarRefId, VarRefRootId}; pub use infer::infer_call_expr_func; pub(crate) use infer::infer_expr; pub use infer::infer_param; From 9f3e2ebea2e44639ed3d91bebe2131d6068d7305 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:39:30 +0100 Subject: [PATCH 2/5] chore: use same paths for codelens as diagnostics --- .../src/handlers/code_lens/build_code_lens.rs | 152 +++++++++++++++--- 1 file changed, 127 insertions(+), 25 deletions(-) diff --git a/crates/glua_ls/src/handlers/code_lens/build_code_lens.rs b/crates/glua_ls/src/handlers/code_lens/build_code_lens.rs index f6f6b16e..c5915e66 100644 --- a/crates/glua_ls/src/handlers/code_lens/build_code_lens.rs +++ b/crates/glua_ls/src/handlers/code_lens/build_code_lens.rs @@ -118,7 +118,9 @@ fn add_func_stat_code_lens( }); if enable_vgui_code_lens - && let Some(info) = find_gmod_class_from_decl(semantic_model, decl_id) + && let Some(semantic_info) = + semantic_model.get_semantic_info(NodeOrToken::Node(name_expr.syntax().clone())) + && let Some(info) = find_gmod_class_from_type(semantic_model, &semantic_info.typ) { push_gmod_class_code_lens(result, range, &info); } @@ -150,7 +152,10 @@ fn add_local_func_stat_code_lens( data: Some(serde_json::to_value(data).unwrap()), }); - if enable_vgui_code_lens && let Some(info) = find_gmod_class_from_decl(semantic_model, decl_id) + if enable_vgui_code_lens + && let Some(semantic_info) = + semantic_model.get_semantic_info(NodeOrToken::Node(func_name.syntax().clone())) + && let Some(info) = find_gmod_class_from_type(semantic_model, &semantic_info.typ) { push_gmod_class_code_lens(result, range, &info); } @@ -168,7 +173,6 @@ fn add_local_stat_code_lens( return Some(()); } - let file_id = semantic_model.get_file_id(); let document = semantic_model.get_document(); for local_name in local_stat.get_local_name_list() { @@ -176,8 +180,12 @@ fn add_local_stat_code_lens( continue; }; - let decl_id = LuaDeclId::new(file_id, name_token.get_position()); - let Some(info) = find_gmod_class_from_decl(semantic_model, decl_id) else { + let Some(semantic_info) = + semantic_model.get_semantic_info(NodeOrToken::Node(local_name.syntax().clone())) + else { + continue; + }; + let Some(info) = find_gmod_class_from_type(semantic_model, &semantic_info.typ) else { continue; }; @@ -199,15 +207,17 @@ fn add_assign_stat_code_lens( } let document = semantic_model.get_document(); - let (vars, _) = assign_stat.get_var_and_expr_list(); + let (vars, exprs) = assign_stat.get_var_and_expr_list(); - for var in vars { - let Some(semantic_info) = - semantic_model.get_semantic_info(NodeOrToken::Node(var.syntax().clone())) - else { + for (i, var) in vars.into_iter().enumerate() { + let Some(expr) = exprs.get(i) else { continue; }; - let Some(info) = find_gmod_class_from_type(semantic_model, &semantic_info.typ) else { + + let Ok(expr_type) = semantic_model.infer_expr(expr.clone()) else { + continue; + }; + let Some(info) = find_gmod_class_from_type(semantic_model, &expr_type) else { continue; }; @@ -223,19 +233,6 @@ struct GmodClassInfo { base_name: Option, } -fn find_gmod_class_from_decl( - semantic_model: &SemanticModel, - decl_id: LuaDeclId, -) -> Option { - let typ = semantic_model - .get_db() - .get_type_index() - .get_type_cache(&decl_id.into())? - .as_type(); - - find_gmod_class_from_type(semantic_model, typ) -} - fn find_gmod_class_from_member_owner( semantic_model: &SemanticModel, owner: &LuaMemberOwner, @@ -480,7 +477,7 @@ fn resolve_receive_kind_label(semantic_model: &SemanticModel, call_expr: &LuaCal #[cfg(test)] mod tests { use glua_code_analysis::VirtualWorkspace; - use glua_parser::{LuaAstNode, LuaLocalName}; + use glua_parser::{LuaAssignStat, LuaAst, LuaAstNode, LuaLocalName}; use googletest::prelude::*; use super::build_code_lens; @@ -519,4 +516,109 @@ mod tests { }; assert_that!(actual_decl, eq(expected_decl)); } + + #[gtest] + fn vgui_reassigned_panel_code_lens_labels_resolve_per_region() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = glua_code_analysis::Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.vgui.code_lens_enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def( + r#" + local PANEL = {} + function PANEL:Init() end + vgui.Register("ReFrame", PANEL, "DFrame") + + PANEL = {} + function PANEL:Paint() end + vgui.Register("ReButton", PANEL, "DButton") + + PANEL = {} + function PANEL:Think() end + vgui.Register("ReTree", PANEL, "DTree") + "#, + ); + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + let lenses = build_code_lens(&semantic_model).expect("expected code lenses"); + + let titles: Vec = lenses + .iter() + .filter_map(|l| l.command.as_ref().map(|c| c.title.clone())) + .collect(); + + assert_that!(titles, contains(eq("ReFrame : DFrame"))); + assert_that!(titles, contains(eq("ReButton : DButton"))); + assert_that!(titles, contains(eq("ReTree : DTree"))); + } + + #[gtest] + fn vgui_reassigned_panel_assignment_code_lens_resolves_per_region() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = glua_code_analysis::Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.vgui.code_lens_enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def( + r#" + local PANEL = {} + function PANEL:Init() end + vgui.Register("ReFrame", PANEL, "DFrame") + + PANEL = {} + function PANEL:Paint() end + vgui.Register("ReButton", PANEL, "DButton") + + PANEL = {} + function PANEL:Think() end + vgui.Register("ReTree", PANEL, "DTree") + "#, + ); + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + let document = semantic_model.get_document(); + let lenses = build_code_lens(&semantic_model).expect("expected code lenses"); + + let assign_ranges: Vec<_> = semantic_model + .get_root() + .clone() + .descendants::() + .filter_map(|node| match node { + LuaAst::LuaAssignStat(assign_stat) => Some(assign_stat), + _ => None, + }) + .map(|assign_stat: LuaAssignStat| { + let (vars, _) = assign_stat.get_var_and_expr_list(); + document + .to_lsp_range(vars[0].get_range()) + .expect("assignment var range") + }) + .collect(); + + assert_that!(assign_ranges.len(), eq(2usize)); + + let assignment_titles: Vec<_> = assign_ranges + .iter() + .map(|range| { + lenses + .iter() + .find(|lens| lens.range == *range) + .and_then(|lens| lens.command.as_ref()) + .map(|command| command.title.clone()) + .expect("expected class CodeLens on assignment") + }) + .collect(); + + assert_that!(assignment_titles[0].as_str(), eq("ReButton : DButton")); + assert_that!(assignment_titles[1].as_str(), eq("ReTree : DTree")); + } } From bd023905fb24c0b3c0da658be639702279e5f11e Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:41:53 +0100 Subject: [PATCH 3/5] perf: use cache for variable reference lookup --- .../src/semantic/cache/mod.rs | 68 +++++++++++++-- .../src/semantic/infer/infer_index/mod.rs | 8 +- .../src/semantic/infer/infer_name.rs | 84 +++++++++++++++---- .../semantic/infer/narrow/get_type_at_flow.rs | 3 +- .../src/semantic/infer/narrow/mod.rs | 2 +- 5 files changed, 137 insertions(+), 28 deletions(-) diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index ee01967d..a8c5c6da 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -2,17 +2,55 @@ mod cache_options; pub use cache_options::{CacheOptions, LuaAnalysisPhase}; use glua_parser::LuaSyntaxId; +use internment::ArcIntern; use rowan::{TextRange, TextSize}; use rustc_hash::FxHashMap; +use smol_str::SmolStr; use std::{collections::HashSet, sync::Arc}; use crate::{ FileId, FlowId, GmodRealm, LuaDeclId, LuaFunctionType, LuaMemberId, LuaMemberKey, - LuaSemanticDeclId, VarRefId, + LuaSemanticDeclId, VarRefId, VarRefRootId, db_index::{LuaType, LuaTypeDeclId}, semantic::infer::InferFailReason, }; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VarRefCacheRootKey { + Decl(LuaDeclId), + Member(LuaMemberId), + SelfRef(LuaDeclId), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VarRefCacheKey { + VarRef(LuaDeclId), + SelfRef(LuaDeclId), + IndexRef(VarRefCacheRootKey, ArcIntern), + GlobalName(ArcIntern), +} + +impl From<&VarRefRootId> for VarRefCacheRootKey { + fn from(value: &VarRefRootId) -> Self { + match value { + VarRefRootId::Decl(decl_id) => Self::Decl(*decl_id), + VarRefRootId::Member(member_id) => Self::Member(*member_id), + VarRefRootId::SelfRef(self_ref_id) => Self::SelfRef(self_ref_id.self_decl_id), + } + } +} + +impl From<&VarRefId> for VarRefCacheKey { + fn from(value: &VarRefId) -> Self { + match value { + VarRefId::VarRef(decl_id) => Self::VarRef(*decl_id), + VarRefId::SelfRef(self_ref_id) => Self::SelfRef(self_ref_id.self_decl_id), + VarRefId::IndexRef(root, path) => Self::IndexRef(root.into(), path.clone()), + VarRefId::GlobalName(name, _) => Self::GlobalName(name.clone()), + } + } +} + #[derive(Debug, Clone)] pub enum CacheEntry { Ready, @@ -38,10 +76,11 @@ pub struct LuaInferCache { FxHashMap<(LuaSyntaxId, Option, LuaType), CacheEntry>>, pub call_arg_types_cache: FxHashMap<(LuaSyntaxId, Option), Arc>>, - pub flow_node_cache: FxHashMap>>, + pub flow_node_cache: + FxHashMap>>, pub flow_query_realm: Option, pub flow_node_realm_cache: FxHashMap, - pub index_ref_origin_type_cache: FxHashMap>, + pub index_ref_origin_type_cache: FxHashMap>, pub expr_var_ref_id_cache: FxHashMap, pub narrow_by_literal_stop_position_cache: HashSet, pub scoped_scripted_global_cache: Option>, @@ -316,8 +355,9 @@ impl LuaInferCache { flow_id: FlowId, query_realm: GmodRealm, ) -> Option<&CacheEntry> { + let cache_key = VarRefCacheKey::from(var_ref_id); self.flow_node_cache - .get(var_ref_id) + .get(&cache_key) .and_then(|by_flow| by_flow.get(&(flow_id, query_realm))) } @@ -328,12 +368,30 @@ impl LuaInferCache { query_realm: GmodRealm, entry: CacheEntry, ) { + let cache_key = VarRefCacheKey::from(var_ref_id); self.flow_node_cache - .entry(var_ref_id.clone()) + .entry(cache_key) .or_default() .insert((flow_id, query_realm), entry); } + pub fn get_index_ref_origin_type_cache( + &self, + var_ref_id: &VarRefId, + ) -> Option<&CacheEntry> { + let cache_key = VarRefCacheKey::from(var_ref_id); + self.index_ref_origin_type_cache.get(&cache_key) + } + + pub fn set_index_ref_origin_type_cache( + &mut self, + var_ref_id: &VarRefId, + entry: CacheEntry, + ) { + let cache_key = VarRefCacheKey::from(var_ref_id); + self.index_ref_origin_type_cache.insert(cache_key, entry); + } + pub fn flow_cache_entry_count(&self) -> usize { self.flow_node_cache .values() diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs index 15eb694a..cd30fc0c 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_index/mod.rs @@ -147,9 +147,7 @@ fn infer_member_type_pass_flow( return Ok(member_type.clone()); }; - cache - .index_ref_origin_type_cache - .insert(var_ref_id.clone(), CacheEntry::Cache(member_type.clone())); + cache.set_index_ref_origin_type_cache(&var_ref_id, CacheEntry::Cache(member_type.clone())); let result = infer_expr_narrow_type(db, cache, LuaExpr::IndexExpr(index_expr), var_ref_id); match &result { Err(InferFailReason::None) => Ok(member_type.clone()), @@ -171,9 +169,7 @@ fn infer_member_type_fallback_pass_flow( return Err(InferFailReason::FieldNotFound); }; - cache - .index_ref_origin_type_cache - .insert(var_ref_id.clone(), CacheEntry::Cache(LuaType::Nil)); + cache.set_index_ref_origin_type_cache(&var_ref_id, CacheEntry::Cache(LuaType::Nil)); match infer_expr_narrow_type(db, cache, LuaExpr::IndexExpr(index_expr), var_ref_id) { Ok(member_type) if !member_type.is_nil() && !member_type.is_unknown() => Ok(member_type), Ok(member_type) if member_type.is_unknown() && unknown_truthy_as_any => Ok(LuaType::Any), diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 57f9adb9..28179557 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -514,7 +514,10 @@ fn is_in_scripted_class_scope(db: &DbIndex, file_id: FileId) -> bool { } fn infer_self(db: &DbIndex, cache: &mut LuaInferCache, name_expr: LuaNameExpr) -> InferResult { - let self_ref_id = find_self_ref_id(db, cache, &name_expr).ok_or(InferFailReason::None)?; + let self_ref_id = match get_name_expr_var_ref_id(db, cache, &name_expr) { + Some(VarRefId::SelfRef(self_ref_id)) => self_ref_id, + _ => return Err(InferFailReason::None), + }; // Compute a region-aware base for the implicit `self` (the colon-method // receiver inferred at its own position). For reused locals reassigned per @@ -651,12 +654,17 @@ pub fn get_name_expr_var_ref_id( cache: &mut LuaInferCache, name_expr: &LuaNameExpr, ) -> Option { + let syntax_id = name_expr.get_syntax_id(); + if let Some(var_ref_id) = cache.expr_var_ref_id_cache.get(&syntax_id) { + return Some(var_ref_id.clone()); + } + let name_token = name_expr.get_name_token()?; let name = name_token.get_name_text(); - match name { + let var_ref_id = match name { "self" => { let self_ref_id = find_self_ref_id(db, cache, name_expr)?; - Some(VarRefId::SelfRef(self_ref_id)) + VarRefId::SelfRef(self_ref_id) } _ => { let file_id = cache.get_file_id(); @@ -666,19 +674,24 @@ pub fn get_name_expr_var_ref_id( .get_local_reference(&file_id) .and_then(|file_ref| file_ref.get_decl_id(&range)) { - return Some(VarRefId::VarRef(decl_id)); - } - - if let Some(global_decl_id) = resolve_global_decl_id(db, cache, name, Some(name_expr)) { - return Some(VarRefId::VarRef(global_decl_id)); + VarRefId::VarRef(decl_id) + } else if let Some(global_decl_id) = + resolve_global_decl_id(db, cache, name, Some(name_expr)) + { + VarRefId::VarRef(global_decl_id) + } else { + VarRefId::GlobalName( + internment::ArcIntern::new(smol_str::SmolStr::new(name)), + name_expr.get_position(), + ) } - - Some(VarRefId::GlobalName( - internment::ArcIntern::new(smol_str::SmolStr::new(name)), - name_expr.get_position(), - )) } - } + }; + + cache + .expr_var_ref_id_cache + .insert(syntax_id, var_ref_id.clone()); + Some(var_ref_id) } pub fn infer_param(db: &DbIndex, decl: &LuaDecl) -> InferResult { @@ -1514,3 +1527,46 @@ fn infer_enclosing_self_type( } None } + +#[cfg(test)] +mod test { + use super::infer_name_expr; + use crate::{LuaInferCache, VirtualWorkspace}; + use glua_parser::{LuaAstNode, LuaNameExpr}; + use googletest::prelude::*; + + #[gtest] + fn test_infer_self_populates_name_var_ref_cache() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + local PANEL = {} + + function PANEL:Init() + local value = self + end + "#, + ); + + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("semantic model must exist"); + let self_expr = semantic_model + .get_root() + .descendants::() + .find(|expr| expr.get_name_text().as_deref() == Some("self")) + .expect("expected self name expr"); + let syntax_id = self_expr.get_syntax_id(); + + let db = ws.analysis.compilation.get_db(); + let mut cache = LuaInferCache::new(file_id, Default::default()); + + expect_that!(cache.expr_var_ref_id_cache.contains_key(&syntax_id), eq(false)); + expect_that!(infer_name_expr(db, &mut cache, self_expr).is_ok(), eq(true)); + expect_that!(cache.expr_var_ref_id_cache.contains_key(&syntax_id), eq(true)); + + Ok(()) + } +} diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index 389bbfbc..f2e5a9e8 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -344,8 +344,7 @@ fn get_type_at_flow_walk( // type is preserved instead of being overridden by the // implementation signature. let is_undeclared = cache - .index_ref_origin_type_cache - .get(var_ref_id) + .get_index_ref_origin_type_cache(var_ref_id) .is_some_and(|entry| matches!(entry, CacheEntry::Cache(t) if t.is_nil())); if is_undeclared { diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs index 3f9a9166..a75fbe96 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/mod.rs @@ -231,7 +231,7 @@ pub fn get_var_ref_type( ) .unwrap_or(LuaType::Unknown)) } else { - if let Some(type_cache) = cache.index_ref_origin_type_cache.get(var_ref_id) + if let Some(type_cache) = cache.get_index_ref_origin_type_cache(var_ref_id) && let CacheEntry::Cache(ty) = type_cache { return Ok(ty.clone()); From 5946ac4fe0b1871873b4b97dc5f68822fef44774 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:08:31 +0100 Subject: [PATCH 4/5] fix: root panel definition could show incorrect class --- .../src/compilation/analyzer/gmod/mod.rs | 119 +++++++++++++----- .../test/gmod_scripted_class_test.rs | 112 +++++++++++++++++ .../instantiate_func_generic.rs | 31 +---- .../src/semantic/infer/infer_name.rs | 2 +- .../src/semantic/infer/mod.rs | 1 + crates/glua_code_analysis/src/semantic/mod.rs | 1 + 6 files changed, 208 insertions(+), 58 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs index 71bba941..77fdd4e1 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -2190,8 +2190,15 @@ fn synthesize_scripted_class_members( /// Synthesize vgui.Register / derma.DefineControl class types. fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { - // Track (file_id, table_var_name, panel_name) for AccessorFunc synthesis - let mut vgui_table_vars: Vec<(FileId, String, String)> = Vec::new(); + struct VguiRegistrationRegion { + file_id: FileId, + decl_id: LuaDeclId, + panel_name: String, + region_start: TextSize, + region_end: TextSize, + } + + let mut vgui_registration_regions: Vec = Vec::new(); for file_id in file_ids.iter().copied() { let metadata = match db @@ -2203,25 +2210,42 @@ fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { }; for call in &metadata.vgui_register_calls { - // Extract table var name and panel name before synthesizing + let register_position = call.syntax_id.get_range().start(); if let Some(Some(GmodClassCallLiteral::String(panel_name))) = call.literal_args.first() { if let Some(Some(GmodClassCallLiteral::NameRef(table_var))) = call.literal_args.get(1) + && let Some((decl_id, region_start)) = + resolve_local_registration_region(db, file_id, table_var, register_position) { - vgui_table_vars.push((file_id, table_var.clone(), panel_name.clone())); + vgui_registration_regions.push(VguiRegistrationRegion { + file_id, + decl_id, + panel_name: panel_name.clone(), + region_start, + region_end: register_position, + }); } } synthesize_vgui_register(db, file_id, call); } for call in &metadata.derma_define_control_calls { + let register_position = call.syntax_id.get_range().start(); if let Some(Some(GmodClassCallLiteral::String(panel_name))) = call.literal_args.first() { if let Some(Some(GmodClassCallLiteral::NameRef(table_var))) = call.literal_args.get(2) + && let Some((decl_id, region_start)) = + resolve_local_registration_region(db, file_id, table_var, register_position) { - vgui_table_vars.push((file_id, table_var.clone(), panel_name.clone())); + vgui_registration_regions.push(VguiRegistrationRegion { + file_id, + decl_id, + panel_name: panel_name.clone(), + region_start, + region_end: register_position, + }); } } synthesize_derma_define_control(db, file_id, call); @@ -2229,36 +2253,72 @@ fn synthesize_vgui_registrations(db: &mut DbIndex, file_ids: &[FileId]) { } // Synthesize AccessorFunc members for VGUI-registered classes - for (file_id, table_var_name, panel_name) in &vgui_table_vars { + for registration in &vgui_registration_regions { let metadata = match db .get_gmod_class_metadata_index() - .get_file_metadata(file_id) + .get_file_metadata(®istration.file_id) { Some(m) => m.clone(), None => continue, }; log::debug!( - "VGUI AccessorFunc: file {:?} has {} accessor_func_calls for table_var={} panel={}", - file_id, + "VGUI AccessorFunc: file {:?} has {} accessor_func_calls for panel={} region={:?}..{:?}", + registration.file_id, metadata.accessor_func_calls.len(), - table_var_name, - panel_name + registration.panel_name, + registration.region_start, + registration.region_end, ); - let class_decl_id = LuaTypeDeclId::global(panel_name); + let class_decl_id = LuaTypeDeclId::global(®istration.panel_name); for call in &metadata.accessor_func_calls { - // Check if the AccessorFunc's first arg matches this table variable if let Some(Some(GmodClassCallLiteral::NameRef(target_name))) = call.literal_args.first() + && let Some(target_arg) = call.args.first() { - if target_name == table_var_name { - synthesize_accessor_func(db, *file_id, &class_decl_id, call); + let accessor_position = call.syntax_id.get_range().start(); + let target_decl_id = resolve_local_decl_id_at_position( + db, + registration.file_id, + target_name, + target_arg.syntax_id.get_range().start(), + ); + + if target_decl_id == Some(registration.decl_id) + && accessor_position >= registration.region_start + && accessor_position < registration.region_end + { + synthesize_accessor_func(db, registration.file_id, &class_decl_id, call); } } } } } +fn resolve_local_registration_region( + db: &DbIndex, + file_id: FileId, + var_name: &str, + register_position: TextSize, +) -> Option<(LuaDeclId, TextSize)> { + let decl_id = resolve_local_decl_id_at_position(db, file_id, var_name, register_position)?; + let region_start = find_latest_decl_write_before_position(db, file_id, decl_id, register_position) + .unwrap_or(decl_id.position); + Some((decl_id, region_start)) +} + +fn resolve_local_decl_id_at_position( + db: &DbIndex, + file_id: FileId, + var_name: &str, + position: TextSize, +) -> Option { + db.get_decl_index() + .get_decl_tree(&file_id)? + .find_local_decl(var_name, position) + .map(|decl| decl.get_id()) +} + fn synthesize_scoped_base_assignments_with( db: &mut DbIndex, file_id: FileId, @@ -3232,6 +3292,7 @@ fn synthesize_vgui_register( &panel_name, table_var_name.as_deref(), base_panel.as_deref(), + GmodScriptedClassCallKind::VguiRegister, call, ); } @@ -3267,6 +3328,7 @@ fn synthesize_derma_define_control( &control_name, table_var_name.as_deref(), base_panel.as_deref(), + GmodScriptedClassCallKind::DermaDefineControl, call, ); @@ -3423,6 +3485,7 @@ fn synthesize_panel_class( panel_name: &str, table_var_name: Option<&str>, base_panel: Option<&str>, + call_kind: GmodScriptedClassCallKind, call: &GmodScriptedClassCallMetadata, ) { let class_decl_id = LuaTypeDeclId::global(panel_name); @@ -3454,7 +3517,7 @@ fn synthesize_panel_class( db.get_type_index_mut() .add_super_type(class_decl_id.clone(), file_id, super_type); } - synthesize_panel_baseclass_member(db, file_id, &class_decl_id, base_name, call); + synthesize_panel_baseclass_member(db, file_id, &class_decl_id, base_name, call_kind, call); } // Bind the table variable to the panel class. @@ -3469,22 +3532,15 @@ fn synthesize_panel_class( // public `infer_expr` override consults — yielding correct per-region // resolution for hover, diagnostics, completion and CodeLens uniformly. if let Some(var_name) = table_var_name { - let Some(decl_tree) = db.get_decl_index().get_decl_tree(&file_id) else { - return; - }; - let register_position = call.syntax_id.get_range().start(); - let selected_decl_id = decl_tree - .find_local_decl(var_name, register_position) - .map(|decl| decl.get_id()); - - let Some(decl_id) = selected_decl_id else { + let Some((decl_id, region_start)) = + resolve_local_registration_region(db, file_id, var_name, register_position) + else { return; }; let class_type = LuaType::Def(class_decl_id.clone()); - let latest_write_position = - find_latest_decl_write_before_position(db, file_id, decl_id, register_position); + let latest_write_position = Some(region_start); // Resolve the concrete `{}` table literal backing this registration. let registered_table = find_registered_table_expr(db, file_id, decl_id, register_position); @@ -3739,6 +3795,7 @@ fn synthesize_panel_baseclass_member( file_id: FileId, class_decl_id: &LuaTypeDeclId, base_name: &str, + call_kind: GmodScriptedClassCallKind, call: &GmodScriptedClassCallMetadata, ) { let owner = LuaMemberOwner::Type(class_decl_id.clone()); @@ -3751,9 +3808,15 @@ fn synthesize_panel_baseclass_member( return; } + let base_arg_index = match call_kind { + GmodScriptedClassCallKind::VguiRegister => 2, + GmodScriptedClassCallKind::DermaDefineControl => 3, + _ => return, + }; + let syntax_id = call .args - .get(2) + .get(base_arg_index) .map(|arg| arg.syntax_id) .unwrap_or(call.syntax_id); let member_id = LuaMemberId::new(syntax_id, file_id); diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs index 335205a0..c150a405 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs @@ -1074,6 +1074,53 @@ mod test { ); } + #[gtest] + fn test_derma_define_control_baseclass_member_uses_base_arg_syntax_id() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "lua/vgui/baseclass_source_anchor.lua", + r#" + local PANEL = {} + derma.DefineControl("AnchorPanel", "", PANEL, "EditablePanel") + "#, + ); + + let db = ws.get_db_mut(); + let metadata = db + .get_gmod_class_metadata_index() + .get_file_metadata(&file_id) + .expect("expected gmod metadata"); + let call = metadata + .derma_define_control_calls + .first() + .expect("expected derma.DefineControl call metadata"); + let expected_member_id = LuaMemberId::new( + call.args + .get(3) + .expect("expected base panel arg metadata") + .syntax_id, + file_id, + ); + + let member_item = db + .get_member_index() + .get_member_item( + &LuaMemberOwner::Type(LuaTypeDeclId::global("AnchorPanel")), + &LuaMemberKey::Name("BaseClass".into()), + ) + .expect("expected synthesized BaseClass member"); + + assert_eq!( + *member_item, + crate::LuaMemberIndexItem::One(expected_member_id), + "BaseClass member should be anchored to the base-panel arg syntax id" + ); + } + #[gtest] fn test_ent_base_from_shared_file_sets_folder_class_super_type() { let mut ws = VirtualWorkspace::new(); @@ -1416,6 +1463,71 @@ mod test { ); } + #[gtest] + fn test_vgui_reassigned_panel_accessor_func_stays_in_own_region() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "lua/vgui/reassigned_panel_accessors.lua", + r#" + local PANEL = {} + AccessorFunc(PANEL, "m_a", "A") + vgui.Register("PanelA", PANEL, "DPanel") + + PANEL = {} + AccessorFunc(PANEL, "m_b", "B") + vgui.Register("PanelB", PANEL, "DPanel") + "#, + ); + + let db = ws.get_db_mut(); + let panel_a_members = db + .get_member_index() + .get_members(&LuaMemberOwner::Type(LuaTypeDeclId::global("PanelA"))) + .map(|members| { + members + .iter() + .filter_map(|member| member.get_key().get_name().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + let panel_b_members = db + .get_member_index() + .get_members(&LuaMemberOwner::Type(LuaTypeDeclId::global("PanelB"))) + .map(|members| { + members + .iter() + .filter_map(|member| member.get_key().get_name().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + assert!( + panel_a_members.contains(&"GetA".to_string()) + && panel_a_members.contains(&"SetA".to_string()), + "PanelA should contain only A accessors, got {panel_a_members:?}" + ); + assert!( + !panel_a_members.contains(&"GetB".to_string()) + && !panel_a_members.contains(&"SetB".to_string()), + "PanelA should not inherit B accessors, got {panel_a_members:?}" + ); + + assert!( + panel_b_members.contains(&"GetB".to_string()) + && panel_b_members.contains(&"SetB".to_string()), + "PanelB should contain only B accessors, got {panel_b_members:?}" + ); + assert!( + !panel_b_members.contains(&"GetA".to_string()) + && !panel_b_members.contains(&"SetA".to_string()), + "PanelB should not inherit A accessors, got {panel_b_members:?}" + ); + } + #[gtest] fn test_vgui_register_reassigned_local_panel_stress_three_panels() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs index c541d233..60df9f61 100644 --- a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs +++ b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs @@ -1,6 +1,6 @@ use std::{collections::HashSet, ops::Deref, sync::Arc}; -use glua_parser::{LuaAstNode, LuaDocTypeList, LuaFuncStat, LuaNameExpr, LuaVarExpr}; +use glua_parser::{LuaAstNode, LuaDocTypeList, LuaNameExpr}; use glua_parser::{LuaCallExpr, LuaExpr}; use internment::ArcIntern; @@ -20,7 +20,7 @@ use crate::{ }, }, infer::InferFailReason, - infer_expr, + infer_enclosing_self_type, infer_expr, }, }; use crate::{LuaMemberOwner, LuaSemanticDeclId, SemanticDeclLevel, infer_node_semantic_decl}; @@ -293,33 +293,6 @@ fn is_implicit_self_name(db: &DbIndex, cache: &LuaInferCache, name_expr: &LuaNam .is_some_and(|decl| decl.is_implicit_self()) } -fn infer_enclosing_self_type( - db: &DbIndex, - cache: &mut LuaInferCache, - name_expr: &LuaNameExpr, -) -> Option { - for func_stat in name_expr.ancestors::() { - // Skip anonymous/non-colon ancestors (e.g. nested closures) and keep - // walking outward to the enclosing colon method, rather than bailing out - // on the first ancestor that lacks a colon-method name. - let Some(LuaVarExpr::IndexExpr(index_expr)) = func_stat.get_func_name() else { - continue; - }; - if !index_expr - .get_index_token() - .is_some_and(|token| token.is_colon()) - { - continue; - } - let Some(prefix_expr) = index_expr.get_prefix_expr() else { - continue; - }; - return infer_expr(db, cache, prefix_expr).ok(); - } - - None -} - fn check_expr_can_later_infer( context: &mut TplContext, func_param_type: &LuaType, diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 28179557..34ff942a 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -1502,7 +1502,7 @@ fn resolve_self_infer(typ: &LuaType, self_type: &LuaType) -> LuaType { /// Infers the self type from the enclosing method (colon function). /// For `function ENT:Update() ... end`, this returns the type of `ENT`. -fn infer_enclosing_self_type( +pub(crate) fn infer_enclosing_self_type( db: &DbIndex, cache: &mut LuaInferCache, name_expr: &LuaNameExpr, diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 1efece56..20db9a13 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -25,6 +25,7 @@ pub use infer_index::infer_index_expr; pub(crate) use infer_index::infer_member_by_member_key; pub(crate) use infer_index::resolve_decl_backed_global_path_member_type; use infer_name::infer_name_expr; +pub(crate) use infer_name::infer_enclosing_self_type; pub(crate) use infer_name::resolve_scoped_scripted_global_type_decl_id; pub(crate) use infer_name::try_local_decl_initializer_fallback_type; pub use infer_name::{find_self_decl_or_member_id, infer_param}; diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index c5b1509c..f9e38134 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -65,6 +65,7 @@ pub use guard::{InferGuard, InferGuardRef}; pub use infer::InferFailReason; pub use infer::{SelfRefId, VarRefId, VarRefRootId}; pub use infer::infer_call_expr_func; +pub(crate) use infer::infer_enclosing_self_type; pub(crate) use infer::infer_expr; pub use infer::infer_param; pub(crate) use infer::remove_false_or_nil; From dfdc0889f162253442fa1df0357e67a05a472f63 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:18:50 +0100 Subject: [PATCH 5/5] feat: change redundant-parameter default to info --- crates/glua_code_analysis/src/diagnostic/lua_diagnostic_code.rs | 1 + docs/mintlify/configuration/diagnostics.mdx | 2 +- docs/mintlify/language/diagnostics.mdx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_code.rs b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_code.rs index 87bf0f81..653eea77 100644 --- a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_code.rs +++ b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_code.rs @@ -194,6 +194,7 @@ pub fn get_default_severity(code: DiagnosticCode) -> DiagnosticSeverity { DiagnosticCode::GmodNetReadWriteBitsMismatch => DiagnosticSeverity::WARNING, DiagnosticCode::GmodDuplicateSystemRegistration => DiagnosticSeverity::HINT, DiagnosticCode::GmodNullCheck => DiagnosticSeverity::WARNING, + DiagnosticCode::RedundantParameter => DiagnosticSeverity::INFORMATION, _ => DiagnosticSeverity::WARNING, } } diff --git a/docs/mintlify/configuration/diagnostics.mdx b/docs/mintlify/configuration/diagnostics.mdx index 2d691991..c123bdac 100644 --- a/docs/mintlify/configuration/diagnostics.mdx +++ b/docs/mintlify/configuration/diagnostics.mdx @@ -56,7 +56,7 @@ Codes marked **Off** start disabled. Add them to `enables` to turn them on. | `type-not-found` | On | Warning | Referenced type not found in scope | | `param-type-mismatch` | On | Warning | Argument type doesn't match declared `@param` type | | `missing-parameter` | On | Warning | Required parameter not passed | -| `redundant-parameter` | On | Warning | Extra argument passed beyond declared parameters | +| `redundant-parameter` | On | Information | Extra argument passed beyond declared parameters | | `assign-type-mismatch` | On | Warning | Assignment value type doesn't match declared type | | `return-type-mismatch` | **Off** | Warning | Returned type doesn't match declared `@return` type | | `missing-return` | **Off** | Warning | Function with `@return` annotation missing a return statement | diff --git a/docs/mintlify/language/diagnostics.mdx b/docs/mintlify/language/diagnostics.mdx index e9aab24b..1708a24c 100644 --- a/docs/mintlify/language/diagnostics.mdx +++ b/docs/mintlify/language/diagnostics.mdx @@ -55,7 +55,7 @@ Use the [interactive settings panel](/configuration/overview#settings-panel-reco |---|---|---| | `param-type-mismatch` | Warning | Argument type doesn't match the expected parameter type | | `missing-parameter` | Warning | Required parameter not provided | -| `redundant-parameter` | Warning | Extra argument passed beyond what the function accepts | +| `redundant-parameter` | Information | Extra argument passed beyond what the function accepts | | `assign-type-mismatch` | Warning | Assigned value type doesn't match the variable type | | `return-type-mismatch` | Off | Return type doesn't match the declared return type | | `missing-return-value` | Warning | Function declared to return a value but doesn't in all paths |